diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e7aae23ec --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# CI/http-fixtures/ is served byte for byte to the networking specs, which +# assert the exact bodies that come back, so a Windows checkout must not turn +# its newlines into CRLF. +CI/http-fixtures/** -text + +# Each .mpackage archive stores a copy of the sources beside it, byte for byte, +# so a Windows checkout must not rewrite the newlines of one and not the other. +src/packages/** -text diff --git a/.github/repo-metadata.yml b/.github/repo-metadata.yml index 0224a3dd6..0bcb19ce1 100644 --- a/.github/repo-metadata.yml +++ b/.github/repo-metadata.yml @@ -2,4 +2,4 @@ milestones: # assign new PRs to this milestone - next-milestone: 4.23.0 + next-milestone: 5.0.0 diff --git a/.github/scripts/resolve-milestone.sh b/.github/scripts/resolve-milestone.sh new file mode 100755 index 000000000..ccb65c02c --- /dev/null +++ b/.github/scripts/resolve-milestone.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# Turns the version in .github/repo-metadata.yml into the number of the matching +# open milestone, and prints " ". +# +# Milestone titles carry a suffix in practice - "4.23.0 next release" for a +# metadata value of "4.23.0" - so the exact-title match this replaces found +# nothing, set an empty number, and exited 0, leaving every pull request in the +# repository unassigned without ever failing (#9671). Anything short of exactly +# one match is now an error, so a renamed milestone cannot go unnoticed again. +# +# Diagnostics go to stderr, because stdout is this script's answer. +# +# Inputs, all from the environment: +# REPO owner/name of the repository (required) +# NEXT_MILESTONE version to look for (required) +# GH_TOKEN token for the gh call + +set -euo pipefail + +: "${REPO:?REPO must be set}" +: "${NEXT_MILESTONE:?NEXT_MILESTONE must be set}" + +if ! milestones=$(gh api "repos/${REPO}/milestones?state=open&per_page=100"); then + echo "::error::Could not read the open milestones of ${REPO}" >&2 + exit 1 +fi + +# An exact title wins outright; otherwise the version has to be the leading word +# of exactly one title, so a genuinely ambiguous set is refused rather than +# guessed at +matches=$(jq -c --arg wanted "${NEXT_MILESTONE}" '[.[] | select(.title == $wanted)]' <<< "${milestones}") +if [ "$(jq 'length' <<< "${matches}")" -eq 0 ]; then + matches=$(jq -c --arg wanted "${NEXT_MILESTONE}" \ + '[.[] | select(.title | startswith($wanted + " "))]' <<< "${milestones}") +fi + +match_count=$(jq 'length' <<< "${matches}") + +if [ "${match_count}" -eq 0 ]; then + echo "::error::No open milestone matches '${NEXT_MILESTONE}' from .github/repo-metadata.yml. Open milestones: $(jq -r '[.[].title] | join(", ")' <<< "${milestones}")" >&2 + exit 1 +fi + +if [ "${match_count}" -gt 1 ]; then + echo "::error::'${NEXT_MILESTONE}' matches several open milestones, so which one to use is not clear: $(jq -r '[.[].title] | join(", ")' <<< "${matches}")" >&2 + exit 1 +fi + +jq -r '"\(.[0].number) \(.[0].title)"' <<< "${matches}" diff --git a/.github/workflows/build-mudlet-pr.yml b/.github/workflows/build-mudlet-pr.yml index 8c555b3b1..ec9b077ab 100644 --- a/.github/workflows/build-mudlet-pr.yml +++ b/.github/workflows/build-mudlet-pr.yml @@ -5,10 +5,38 @@ on: pull_request: jobs: + changes: + name: detect buildable changes + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'Mudlet' }} + outputs: + build: ${{steps.filter.outputs.build}} + steps: + - name: Check whether anything affecting the build changed + id: filter + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Only genuinely inert paths are listed - CI, CMake and packaging scripts + # all affect the build. Anything unexpected falls through to building. + files=$(gh api --paginate \ + "/repos/${{github.repository}}/pulls/${{github.event.pull_request.number}}/files" \ + -q '.[].filename') + echo "Changed files:" + printf '%s\n' "$files" + + if printf '%s\n' "$files" | grep -qvE '(^docs/|^\.github/ISSUE_TEMPLATE/|\.md$)'; then + echo "build=true" >> "$GITHUB_OUTPUT" + else + echo "build=false" >> "$GITHUB_OUTPUT" + echo "::notice::Documentation-only change - skipping the build matrix" + fi + compile-mudlet: name: ${{matrix.buildname}} runs-on: ${{matrix.os}} - if: ${{ github.repository_owner == 'Mudlet' }} + needs: changes + if: ${{ github.repository_owner == 'Mudlet' && needs.changes.outputs.build == 'true' }} concurrency: group: ${{github.workflow}}-${{matrix.buildname}}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -22,6 +50,9 @@ jobs: # Note: using / in the "buildname" has significance - replacing the # ',' in some of the following with a '/' seemed to cause extra # spurious steps in the build and broke things! - SlySven + # Smaller than build-mudlet.yml's on purpose - only builds that produce + # an artifact. 'ubuntu (x86_64)' and 'ubuntu / clang' still run there on + # every push to development and nightly. # oldest OS supported for maximum compatibility of built AppImage - os: ubuntu-22.04 buildname: 'ubuntu / gcc / lua tests + leak detection' @@ -33,17 +64,6 @@ jobs: # Enable AddressSanitizer for PTB and testing builds, but not release # builds (tagged Mudlet-*) - ASAN adds significant runtime overhead enable_asan: 'true' - - os: ubuntu-22.04 - # Another try to use GCC12 - buildname: 'ubuntu (x86_64)' - compiler: gcc_64 - gcc_compiler_version: 12 - qt: '6.9.0' - - os: ubuntu-latest - buildname: 'ubuntu / clang' - compiler: clang_64 - gcc_compiler_version: 10 - qt: '6.9.0' - os: macos-15-intel buildname: 'macos (x86_64) / c++, lua tests' compiler: clang_64 @@ -72,8 +92,15 @@ jobs: cache: true modules: qt5compat qtmultimedia qtspeech - - name: Use CMake 3.30.3 - uses: lukka/get-cmake@v4.4.0 + - name: Cache Lua build + uses: actions/cache@v6 + with: + path: .lua + # Not gh-actions-lua's own buildCache: that shares one key across + # runner images, and a .lua built on ubuntu-latest cannot run on + # ubuntu-22.04 (newer glibc). The action skips its lua.org download + # whenever .lua already exists. + key: lua-${{ env.LUA_VERSION }}-${{ matrix.os }}-${{ runner.arch }} - name: (macOS) Install Lua via GitHub Actions if: runner.os == 'macOS' @@ -215,21 +242,37 @@ jobs: echo "LUA_PATH=$LUA_PATH" >> $GITHUB_ENV echo "LUA_CPATH=$LUA_CPATH" >> $GITHUB_ENV + - name: (Linux) Cache xcb-util-cursor 0.1.5 + id: cache-xcb-cursor + if: runner.os == 'Linux' + uses: actions/cache@v6 + with: + path: ${{runner.workspace}}/xcb-util-cursor-stage + key: xcb-util-cursor-0.1.5-${{matrix.os}}-${{runner.arch}} + - name: (Linux) Build xcb-util-cursor 0.1.5 timeout-minutes: 5 - if: runner.os == 'Linux' + if: runner.os == 'Linux' && steps.cache-xcb-cursor.outputs.cache-hit != 'true' run: | - # Download and extract xcb-util-cursor 0.1.5 # This version fixes the off-by-one heap buffer overflow in _XcursorThemeInherits # that causes PTB builds to crash with AddressSanitizer - # Fall back to the xcb project's dist host: xorg.freedesktop.org served an - # expired TLS certificate on 2026-07-28, breaking every CI run + # Several mirrors, each retried: xorg.freedesktop.org served an expired + # TLS certificate on 2026-07-28, breaking every CI run for url in \ https://xorg.freedesktop.org/archive/individual/lib/xcb-util-cursor-0.1.5.tar.xz \ - https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz; do - wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url" && break - done || echo "all xcb-util-cursor mirrors failed" >&2 + https://www.x.org/releases/individual/lib/xcb-util-cursor-0.1.5.tar.xz \ + https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz + do + for attempt in 1 2 3; do + if wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url"; then + break 2 + fi + echo "::warning::download attempt $attempt from $url failed, retrying..." + sleep $((attempt * 5)) + done + done # Checksum from https://lists.x.org/archives/xorg-announce/2023-October/003428.html + # also fails the step if every mirror was unreachable echo "0caf99b0d60970f81ce41c7ba694e5eaaf833227bb2cbcdb2f6dc9666a663c57 xcb-util-cursor-0.1.5.tar.xz" | sha256sum -c tar xf xcb-util-cursor-0.1.5.tar.xz cd xcb-util-cursor-0.1.5 @@ -238,8 +281,14 @@ jobs: ./configure --prefix=/usr --libdir=/usr/lib/x86_64-linux-gnu make -j$(nproc) + # staged rather than installed directly so the result can be cached + make DESTDIR="${{runner.workspace}}/xcb-util-cursor-stage" install + + - name: (Linux) Install xcb-util-cursor 0.1.5 + if: runner.os == 'Linux' + run: | # Install system-wide (replaces Ubuntu 22.04's buggy 0.1.1) - sudo make install + sudo cp -a "${{runner.workspace}}/xcb-util-cursor-stage/." / # Update library cache so linker finds new version sudo ldconfig @@ -368,16 +417,21 @@ jobs: run: ctest --output-on-failure env: QT_QPA_PLATFORM: offscreen + # This runner has Qt's FFmpeg backend and can decode, so TMediaLoopTest must + # demonstrate every media behaviour rather than skip any of them. Without a floor + # somewhere, a lost codec or a changed default backend would turn the whole file + # green-by-skip on every platform and say nothing about it. + MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK: 1 - name: (macOS) Run C++ tests if: runner.os == 'macOS' working-directory: '${{runner.workspace}}/b/ninja' run: ctest --output-on-failure + # the full ctest run above already covers every functional-labelled test - name: Run QTest + if: matrix.run_tests != 'true' run: ctest --test-dir ${{runner.workspace}}/b/ninja -L functional --output-on-failure - env: - QT_FORCE_STDERR_LOGGING: 1 - name: add ssh-agent for release uploads if: (runner.os == 'Linux' || runner.os == 'macOS') && matrix.deploy == 'deploy' && startsWith(github.ref, 'refs/tags/Mudlet-') @@ -448,13 +502,121 @@ jobs: cd ~/Desktop sudo codesign --remove-signature ~/Desktop/Mudlet.app + - name: (Linux/macOS) Start fixture HTTP server for Lua tests + if: matrix.run_tests == 'true' + shell: bash + run: | + port_file="${{runner.temp}}/mudlet-http-fixture-port" + rm -f "${port_file}" + MUDLET_TEST_HTTP_PORT_FILE="${port_file}" nohup python3 "${{github.workspace}}/CI/http-fixture-server.py" > "${{runner.temp}}/http-fixture-server.log" 2>&1 & + for _ in $(seq 1 50); do + [ -s "${port_file}" ] && break + sleep 0.1 + done + if [ ! -s "${port_file}" ]; then + echo "fixture HTTP server failed to start" >&2 + cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true + exit 1 + fi + port="$(cat "${port_file}")" + if ! curl -fsS "http://127.0.0.1:${port}/fixture.txt" > /dev/null; then + echo "fixture HTTP server is not serving fixtures" >&2 + cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true + exit 1 + fi + echo "MUDLET_TEST_HTTP_PORT=${port}" >> "${GITHUB_ENV}" + echo "fixture HTTP server ready on 127.0.0.1:${port}" + + - name: (Linux) Start fake Discord IPC server for Lua tests + if: matrix.run_tests == 'true' && runner.os == 'Linux' + shell: bash + run: | + # A short runtime directory of its own: the whole discord-ipc-0 socket + # path has to fit into sockaddr_un's 108 character sun_path, and + # runner.temp does not leave room for it. + runtime_dir="$(mktemp -d /tmp/mdxdg-XXXX)" + ready_file="${{runner.temp}}/mudlet-discord-fixture-ready" + rm -f "${ready_file}" + nohup python3 "${{github.workspace}}/CI/discord-ipc-fixture.py" \ + --runtime-dir "${runtime_dir}" \ + --capture-file "${{runner.temp}}/mudlet-discord-frames.jsonl" \ + --ready-file "${ready_file}" > "${{runner.temp}}/discord-ipc-fixture.log" 2>&1 & + for _ in $(seq 1 50); do + [ -s "${ready_file}" ] && break + sleep 0.1 + done + if [ ! -s "${ready_file}" ] || [ ! -S "${runtime_dir}/discord-ipc-0" ]; then + echo "fake Discord IPC server failed to start" >&2 + cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true + exit 1 + fi + cat "${ready_file}" >> "${GITHUB_ENV}" + # Prepended rather than replacing: the Qt install action puts Qt's own + # libraries on this path. + echo "MUDLET_TEST_DISCORD_LIB_PATH=${{github.workspace}}/3rdparty/discord/rpc/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" >> "${GITHUB_ENV}" + echo "fake Discord IPC server ready in ${runtime_dir}" + + - name: (Linux/macOS) Start MMCP peer fixture for Lua tests + if: matrix.run_tests == 'true' + shell: bash + run: | + peer_dir="${{runner.temp}}/mudlet-mmcp-peer" + rm -rf "${peer_dir}" + mkdir -p "${peer_dir}" + MUDLET_TEST_MMCP_DIR="${peer_dir}" nohup python3 "${{github.workspace}}/CI/mmcp-peer.py" > "${{runner.temp}}/mmcp-peer.log" 2>&1 & + for _ in $(seq 1 50); do + [ -s "${peer_dir}/port" ] && break + sleep 0.1 + done + if [ ! -s "${peer_dir}/port" ]; then + echo "MMCP peer fixture failed to start" >&2 + cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true + exit 1 + fi + port="$(cat "${peer_dir}/port")" + if ! python3 -c "import socket, sys; socket.create_connection(('127.0.0.1', int(sys.argv[1])), 5).close()" "${port}"; then + echo "MMCP peer fixture is not accepting connections" >&2 + cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true + exit 1 + fi + echo "MUDLET_TEST_MMCP_DIR=${peer_dir}" >> "${GITHUB_ENV}" + echo "MMCP peer fixture ready on 127.0.0.1:$(cat "${peer_dir}/port")" + - name: (Linux) Run Lua tests if: matrix.run_tests == 'true' && runner.os == 'Linux' - timeout-minutes: 1 + # This leg drives the real discord-rpc library, whose reconnects + # happen in wall-clock time. + timeout-minutes: 3 run: xvfb-run --auto-servernum ${{github.workspace}}/src/mudlet --profile "Mudlet self-test" --mirror env: AUTORUN_BUSTED_TESTS: 'true' MUDLET_TEST_MODE: 1 + MUDLET_TEST_REQUIRE_TTS_MOCK: 1 + MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1 + MUDLET_TEST_REQUIRE_MMCP_PEER: 1 + # Qt Multimedia runs a player here even without an audio device, so a + # media effect spec that cannot play is a regression rather than an + # environment quirk. macOS runners start no player at all, so the gate + # stays off there. + MUDLET_TEST_REQUIRE_MEDIA: 1 + # Xvfb has no window manager to overrule a resize request, so the main + # window here follows setMainWindowSize exactly and one that stops + # following it is a regression rather than an environment quirk. The + # macOS runners are not gated: their window server can refuse a resize. + MUDLET_TEST_REQUIRE_WINDOW_RESIZE: 1 + # The fake Discord IPC server's socket lives in this runtime directory, + # and the bundled discord-rpc library is only reachable through this + # library path - without both the Discord specs have nothing to talk to. + MUDLET_TEST_REQUIRE_DISCORD: 1 + XDG_RUNTIME_DIR: ${{env.MUDLET_TEST_DISCORD_RUNTIME_DIR}} + LD_LIBRARY_PATH: ${{env.MUDLET_TEST_DISCORD_LIB_PATH}} + # The session bus socket lives in the runtime directory replaced above, + # so Qt's D-Bus platform theme can no longer find it and would fall back + # to spawning dbus-launch. Point it at nothing instead: libdbus leaks + # the buffer it reads the autolaunch reply into (1032 bytes, reported + # against this job by LeakSanitizer with no Mudlet frames in the stack), + # and no spec needs a session bus. + DBUS_SESSION_BUS_ADDRESS: 'disabled:' TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests QUIT_MUDLET_AFTER_TESTS: 'true' ASAN_OPTIONS: ${{ matrix.enable_asan == 'true' && 'detect_leaks=1' || '' }} @@ -462,15 +624,30 @@ jobs: - name: (macOS) Run Lua tests if: matrix.run_tests == 'true' && runner.os == 'macOS' - timeout-minutes: 1 + # The suite alone can run past a minute on the slower Intel runners + timeout-minutes: 3 run: ~/Desktop/Mudlet.app/Contents/MacOS/mudlet --profile "Mudlet self-test" --mirror env: AUTORUN_BUSTED_TESTS: 'true' MUDLET_TEST_MODE: 1 + MUDLET_TEST_REQUIRE_TTS_MOCK: 1 + MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1 + MUDLET_TEST_REQUIRE_MMCP_PEER: 1 + # No MUDLET_TEST_REQUIRE_MEDIA: Qt Multimedia starts no player on the + # macOS runners, so the media effect specs pend here by design. TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests QUIT_MUDLET_AFTER_TESTS: 'true' ASAN_OPTIONS: detect_leaks=0 + - name: (Linux) Show the captured Discord frames on failure + if: failure() && matrix.run_tests == 'true' && runner.os == 'Linux' + shell: bash + run: | + echo "--- fake Discord IPC server log ---" + cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true + echo "--- frames it captured ---" + cat "${{runner.temp}}/mudlet-discord-frames.jsonl" 2>/dev/null || true + - name: Passed Lua tests if: matrix.run_tests == 'true' run: | diff --git a/.github/workflows/build-mudlet-win-pr.yml b/.github/workflows/build-mudlet-win-pr.yml index a46349762..ebe425210 100644 --- a/.github/workflows/build-mudlet-win-pr.yml +++ b/.github/workflows/build-mudlet-win-pr.yml @@ -9,12 +9,41 @@ permissions: id-token: write contents: read actions: read + pull-requests: read jobs: + changes: + name: detect buildable changes + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'Mudlet' }} + outputs: + build: ${{steps.filter.outputs.build}} + steps: + - name: Check whether anything affecting the build changed + id: filter + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Only genuinely inert paths are listed - CI, CMake and packaging scripts + # all affect the build. Anything unexpected falls through to building. + files=$(gh api --paginate \ + "/repos/${{github.repository}}/pulls/${{github.event.pull_request.number}}/files" \ + -q '.[].filename') + echo "Changed files:" + printf '%s\n' "$files" + + if printf '%s\n' "$files" | grep -qvE '(^docs/|^\.github/ISSUE_TEMPLATE/|\.md$)'; then + echo "build=true" >> "$GITHUB_OUTPUT" + else + echo "build=false" >> "$GITHUB_OUTPUT" + echo "::notice::Documentation-only change - skipping the build matrix" + fi + compile-mudlet: name: ${{matrix.buildname}} runs-on: ${{matrix.os}} - if: ${{ github.repository_owner == 'Mudlet' }} + needs: changes + if: ${{ github.repository_owner == 'Mudlet' && needs.changes.outputs.build == 'true' }} concurrency: group: ${{github.workflow}}-${{matrix.buildname}}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -103,29 +132,71 @@ jobs: - name: (Windows) Run QTest shell: msys2 {0} run: | - LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" ) + LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path) export LUA_PATH - LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" ) + LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath) export LUA_CPATH ctest --test-dir $GITHUB_WORKSPACE/build-$MSYSTEM/test --output-on-failure env: - QT_FORCE_STDERR_LOGGING: 1 + # This runner has Qt's FFmpeg backend and can decode, so TMediaLoopTest must + # demonstrate every media behaviour rather than skip any of them. Without a floor + # somewhere, a lost codec or a changed default backend would turn the whole file + # green-by-skip on every platform and say nothing about it. + MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK: 1 - name: (Windows) Run Lua tests - timeout-minutes: 1 + timeout-minutes: 2 shell: msys2 {0} env: AUTORUN_BUSTED_TESTS: 'true' MUDLET_TEST_MODE: 1 + MUDLET_TEST_REQUIRE_TTS_MOCK: 1 + MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1 TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests QUIT_MUDLET_AFTER_TESTS: 'true' run: | - LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" ) + LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path) export LUA_PATH - LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" ) + LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath) export LUA_CPATH + # Linux and macOS start the fixture HTTP server in a step of their own, + # but each msys2 step here is its own shell and a process backgrounded + # in one is not guaranteed to still be around in the next, so start the + # server in the very shell that runs the tests and stop it on the way + # out. Paths are handed over in mixed form (C:/...) because bash and + # the native Windows python both understand that. + temp_dir="$(cygpath -m "${RUNNER_TEMP}")" + port_file="${temp_dir}/mudlet-http-fixture-port" + fixture_log="${temp_dir}/http-fixture-server.log" + rm -f "${port_file}" + python_bin=python3 + command -v "${python_bin}" > /dev/null 2>&1 || python_bin=python + MUDLET_TEST_HTTP_PORT_FILE="${port_file}" "${python_bin}" "$GITHUB_WORKSPACE/CI/http-fixture-server.py" > "${fixture_log}" 2>&1 & + fixture_pid=$! + # Disowned so that stopping it is not reported as a job crash, and -9 + # because a plain SIGTERM does not reach a native Windows process from + # msys2. The server's log is one line when all is well, so print it + # either way rather than only where trouble is expected. + disown "${fixture_pid}" 2>/dev/null || true + trap 'kill -9 "${fixture_pid}" > /dev/null 2>&1 || true; cat "${fixture_log}" 2>/dev/null || true' EXIT + for _ in $(seq 1 100); do + [ -s "${port_file}" ] && break + sleep 0.1 + done + if [ ! -s "${port_file}" ]; then + echo "fixture HTTP server failed to start" >&2 + exit 1 + fi + MUDLET_TEST_HTTP_PORT="$(cat "${port_file}")" + export MUDLET_TEST_HTTP_PORT + if ! curl -fsS --max-time 10 "http://127.0.0.1:${MUDLET_TEST_HTTP_PORT}/fixture.txt" > /dev/null; then + echo "fixture HTTP server is not serving fixtures" >&2 + exit 1 + fi + echo "fixture HTTP server ready on 127.0.0.1:${MUDLET_TEST_HTTP_PORT}" + $GITHUB_WORKSPACE/build-$MSYSTEM/release/mudlet.exe --profile "Mudlet self-test" - name: Passed Lua tests diff --git a/.github/workflows/build-mudlet-win.yml b/.github/workflows/build-mudlet-win.yml index 81eb538c5..109b5b634 100644 --- a/.github/workflows/build-mudlet-win.yml +++ b/.github/workflows/build-mudlet-win.yml @@ -109,29 +109,65 @@ jobs: - name: (Windows) Run QTest shell: msys2 {0} run: | - LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" ) + LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path) export LUA_PATH - LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" ) + LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath) export LUA_CPATH ctest --test-dir $GITHUB_WORKSPACE/build-$MSYSTEM/test --output-on-failure - env: - QT_FORCE_STDERR_LOGGING: 1 - name: (Windows) Run Lua tests - timeout-minutes: 1 + timeout-minutes: 2 shell: msys2 {0} env: AUTORUN_BUSTED_TESTS: 'true' MUDLET_TEST_MODE: 1 + MUDLET_TEST_REQUIRE_TTS_MOCK: 1 + MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1 TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests QUIT_MUDLET_AFTER_TESTS: 'true' run: | - LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" ) + LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path) export LUA_PATH - LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" ) + LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath) export LUA_CPATH + # Linux and macOS start the fixture HTTP server in a step of their own, + # but each msys2 step here is its own shell and a process backgrounded + # in one is not guaranteed to still be around in the next, so start the + # server in the very shell that runs the tests and stop it on the way + # out. Paths are handed over in mixed form (C:/...) because bash and + # the native Windows python both understand that. + temp_dir="$(cygpath -m "${RUNNER_TEMP}")" + port_file="${temp_dir}/mudlet-http-fixture-port" + fixture_log="${temp_dir}/http-fixture-server.log" + rm -f "${port_file}" + python_bin=python3 + command -v "${python_bin}" > /dev/null 2>&1 || python_bin=python + MUDLET_TEST_HTTP_PORT_FILE="${port_file}" "${python_bin}" "$GITHUB_WORKSPACE/CI/http-fixture-server.py" > "${fixture_log}" 2>&1 & + fixture_pid=$! + # Disowned so that stopping it is not reported as a job crash, and -9 + # because a plain SIGTERM does not reach a native Windows process from + # msys2. The server's log is one line when all is well, so print it + # either way rather than only where trouble is expected. + disown "${fixture_pid}" 2>/dev/null || true + trap 'kill -9 "${fixture_pid}" > /dev/null 2>&1 || true; cat "${fixture_log}" 2>/dev/null || true' EXIT + for _ in $(seq 1 100); do + [ -s "${port_file}" ] && break + sleep 0.1 + done + if [ ! -s "${port_file}" ]; then + echo "fixture HTTP server failed to start" >&2 + exit 1 + fi + MUDLET_TEST_HTTP_PORT="$(cat "${port_file}")" + export MUDLET_TEST_HTTP_PORT + if ! curl -fsS --max-time 10 "http://127.0.0.1:${MUDLET_TEST_HTTP_PORT}/fixture.txt" > /dev/null; then + echo "fixture HTTP server is not serving fixtures" >&2 + exit 1 + fi + echo "fixture HTTP server ready on 127.0.0.1:${MUDLET_TEST_HTTP_PORT}" + $GITHUB_WORKSPACE/build-$MSYSTEM/release/mudlet.exe --profile "Mudlet self-test" - name: Passed Lua tests diff --git a/.github/workflows/build-mudlet.yml b/.github/workflows/build-mudlet.yml index c81fa7018..e850ba3e9 100644 --- a/.github/workflows/build-mudlet.yml +++ b/.github/workflows/build-mudlet.yml @@ -78,8 +78,15 @@ jobs: cache: true modules: qt5compat qtmultimedia qtspeech - - name: Use CMake 3.30.3 - uses: lukka/get-cmake@v4.4.0 + - name: Cache Lua build + uses: actions/cache@v6 + with: + path: .lua + # Not gh-actions-lua's own buildCache: that shares one key across + # runner images, and a .lua built on ubuntu-latest cannot run on + # ubuntu-22.04 (newer glibc). The action skips its lua.org download + # whenever .lua already exists. + key: lua-${{ env.LUA_VERSION }}-${{ matrix.os }}-${{ runner.arch }} - name: (macOS) Install Lua via GitHub Actions if: runner.os == 'macOS' @@ -221,21 +228,37 @@ jobs: echo "LUA_PATH=$LUA_PATH" >> $GITHUB_ENV echo "LUA_CPATH=$LUA_CPATH" >> $GITHUB_ENV + - name: (Linux) Cache xcb-util-cursor 0.1.5 + id: cache-xcb-cursor + if: runner.os == 'Linux' + uses: actions/cache@v6 + with: + path: ${{runner.workspace}}/xcb-util-cursor-stage + key: xcb-util-cursor-0.1.5-${{matrix.os}}-${{runner.arch}} + - name: (Linux) Build xcb-util-cursor 0.1.5 timeout-minutes: 5 - if: runner.os == 'Linux' + if: runner.os == 'Linux' && steps.cache-xcb-cursor.outputs.cache-hit != 'true' run: | - # Download and extract xcb-util-cursor 0.1.5 # This version fixes the off-by-one heap buffer overflow in _XcursorThemeInherits # that causes PTB builds to crash with AddressSanitizer - # Fall back to the xcb project's dist host: xorg.freedesktop.org served an - # expired TLS certificate on 2026-07-28, breaking every CI run + # Several mirrors, each retried: xorg.freedesktop.org served an expired + # TLS certificate on 2026-07-28, breaking every CI run for url in \ https://xorg.freedesktop.org/archive/individual/lib/xcb-util-cursor-0.1.5.tar.xz \ - https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz; do - wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url" && break - done || echo "all xcb-util-cursor mirrors failed" >&2 + https://www.x.org/releases/individual/lib/xcb-util-cursor-0.1.5.tar.xz \ + https://xcb.freedesktop.org/dist/xcb-util-cursor-0.1.5.tar.xz + do + for attempt in 1 2 3; do + if wget -q --connect-timeout=10 --read-timeout=30 --tries=3 --waitretry=5 -O xcb-util-cursor-0.1.5.tar.xz "$url"; then + break 2 + fi + echo "::warning::download attempt $attempt from $url failed, retrying..." + sleep $((attempt * 5)) + done + done # Checksum from https://lists.x.org/archives/xorg-announce/2023-October/003428.html + # also fails the step if every mirror was unreachable echo "0caf99b0d60970f81ce41c7ba694e5eaaf833227bb2cbcdb2f6dc9666a663c57 xcb-util-cursor-0.1.5.tar.xz" | sha256sum -c tar xf xcb-util-cursor-0.1.5.tar.xz cd xcb-util-cursor-0.1.5 @@ -244,8 +267,14 @@ jobs: ./configure --prefix=/usr --libdir=/usr/lib/x86_64-linux-gnu make -j$(nproc) + # staged rather than installed directly so the result can be cached + make DESTDIR="${{runner.workspace}}/xcb-util-cursor-stage" install + + - name: (Linux) Install xcb-util-cursor 0.1.5 + if: runner.os == 'Linux' + run: | # Install system-wide (replaces Ubuntu 22.04's buggy 0.1.1) - sudo make install + sudo cp -a "${{runner.workspace}}/xcb-util-cursor-stage/." / # Update library cache so linker finds new version sudo ldconfig @@ -380,10 +409,10 @@ jobs: working-directory: '${{runner.workspace}}/b/ninja' run: ctest --output-on-failure + # the full ctest run above already covers every functional-labelled test - name: Run QTest + if: matrix.run_tests != 'true' run: ctest --test-dir ${{runner.workspace}}/b/ninja -L functional --output-on-failure - env: - QT_FORCE_STDERR_LOGGING: 1 - name: add ssh-agent for release uploads if: (runner.os == 'Linux' || runner.os == 'macOS') && matrix.deploy == 'deploy' && startsWith(github.ref, 'refs/tags/Mudlet-') @@ -484,13 +513,116 @@ jobs: cd ~/Desktop sudo codesign --remove-signature ~/Desktop/Mudlet.app + - name: (Linux/macOS) Start fixture HTTP server for Lua tests + if: matrix.run_tests == 'true' + shell: bash + run: | + port_file="${{runner.temp}}/mudlet-http-fixture-port" + rm -f "${port_file}" + MUDLET_TEST_HTTP_PORT_FILE="${port_file}" nohup python3 "${{github.workspace}}/CI/http-fixture-server.py" > "${{runner.temp}}/http-fixture-server.log" 2>&1 & + for _ in $(seq 1 50); do + [ -s "${port_file}" ] && break + sleep 0.1 + done + if [ ! -s "${port_file}" ]; then + echo "fixture HTTP server failed to start" >&2 + cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true + exit 1 + fi + port="$(cat "${port_file}")" + if ! curl -fsS "http://127.0.0.1:${port}/fixture.txt" > /dev/null; then + echo "fixture HTTP server is not serving fixtures" >&2 + cat "${{runner.temp}}/http-fixture-server.log" 2>/dev/null || true + exit 1 + fi + echo "MUDLET_TEST_HTTP_PORT=${port}" >> "${GITHUB_ENV}" + echo "fixture HTTP server ready on 127.0.0.1:${port}" + + - name: (Linux) Start fake Discord IPC server for Lua tests + if: matrix.run_tests == 'true' && runner.os == 'Linux' + shell: bash + run: | + # A short runtime directory of its own: the whole discord-ipc-0 socket + # path has to fit into sockaddr_un's 108 character sun_path, and + # runner.temp does not leave room for it. + runtime_dir="$(mktemp -d /tmp/mdxdg-XXXX)" + ready_file="${{runner.temp}}/mudlet-discord-fixture-ready" + rm -f "${ready_file}" + nohup python3 "${{github.workspace}}/CI/discord-ipc-fixture.py" \ + --runtime-dir "${runtime_dir}" \ + --capture-file "${{runner.temp}}/mudlet-discord-frames.jsonl" \ + --ready-file "${ready_file}" > "${{runner.temp}}/discord-ipc-fixture.log" 2>&1 & + for _ in $(seq 1 50); do + [ -s "${ready_file}" ] && break + sleep 0.1 + done + if [ ! -s "${ready_file}" ] || [ ! -S "${runtime_dir}/discord-ipc-0" ]; then + echo "fake Discord IPC server failed to start" >&2 + cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true + exit 1 + fi + cat "${ready_file}" >> "${GITHUB_ENV}" + # Prepended rather than replacing: the Qt install action puts Qt's own + # libraries on this path. + echo "MUDLET_TEST_DISCORD_LIB_PATH=${{github.workspace}}/3rdparty/discord/rpc/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" >> "${GITHUB_ENV}" + echo "fake Discord IPC server ready in ${runtime_dir}" + + - name: (Linux/macOS) Start MMCP peer fixture for Lua tests + if: matrix.run_tests == 'true' + shell: bash + run: | + peer_dir="${{runner.temp}}/mudlet-mmcp-peer" + rm -rf "${peer_dir}" + mkdir -p "${peer_dir}" + MUDLET_TEST_MMCP_DIR="${peer_dir}" nohup python3 "${{github.workspace}}/CI/mmcp-peer.py" > "${{runner.temp}}/mmcp-peer.log" 2>&1 & + for _ in $(seq 1 50); do + [ -s "${peer_dir}/port" ] && break + sleep 0.1 + done + if [ ! -s "${peer_dir}/port" ]; then + echo "MMCP peer fixture failed to start" >&2 + cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true + exit 1 + fi + port="$(cat "${peer_dir}/port")" + if ! python3 -c "import socket, sys; socket.create_connection(('127.0.0.1', int(sys.argv[1])), 5).close()" "${port}"; then + echo "MMCP peer fixture is not accepting connections" >&2 + cat "${{runner.temp}}/mmcp-peer.log" 2>/dev/null || true + exit 1 + fi + echo "MUDLET_TEST_MMCP_DIR=${peer_dir}" >> "${GITHUB_ENV}" + echo "MMCP peer fixture ready on 127.0.0.1:$(cat "${peer_dir}/port")" + - name: (Linux) Run Lua tests if: matrix.run_tests == 'true' && runner.os == 'Linux' - timeout-minutes: 1 + # This leg drives the real discord-rpc library, whose reconnects + # happen in wall-clock time. + timeout-minutes: 3 run: xvfb-run --auto-servernum ${{github.workspace}}/src/mudlet --profile "Mudlet self-test" --mirror env: AUTORUN_BUSTED_TESTS: 'true' MUDLET_TEST_MODE: 1 + MUDLET_TEST_REQUIRE_TTS_MOCK: 1 + MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1 + MUDLET_TEST_REQUIRE_MMCP_PEER: 1 + # Qt Multimedia runs a player here even without an audio device, so a + # media effect spec that cannot play is a regression rather than an + # environment quirk. macOS runners start no player at all, so the gate + # stays off there. + MUDLET_TEST_REQUIRE_MEDIA: 1 + # The fake Discord IPC server's socket lives in this runtime directory, + # and the bundled discord-rpc library is only reachable through this + # library path - without both the Discord specs have nothing to talk to. + MUDLET_TEST_REQUIRE_DISCORD: 1 + XDG_RUNTIME_DIR: ${{env.MUDLET_TEST_DISCORD_RUNTIME_DIR}} + LD_LIBRARY_PATH: ${{env.MUDLET_TEST_DISCORD_LIB_PATH}} + # The session bus socket lives in the runtime directory replaced above, + # so Qt's D-Bus platform theme can no longer find it and would fall back + # to spawning dbus-launch. Point it at nothing instead: libdbus leaks + # the buffer it reads the autolaunch reply into (1032 bytes, reported + # against this job by LeakSanitizer with no Mudlet frames in the stack), + # and no spec needs a session bus. + DBUS_SESSION_BUS_ADDRESS: 'disabled:' TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests QUIT_MUDLET_AFTER_TESTS: 'true' ASAN_OPTIONS: ${{ matrix.enable_asan == 'true' && 'detect_leaks=1' || '' }} @@ -498,15 +630,30 @@ jobs: - name: (macOS) Run Lua tests if: matrix.run_tests == 'true' && runner.os == 'macOS' - timeout-minutes: 1 + # The suite alone can run past a minute on the slower Intel runners + timeout-minutes: 3 run: ~/Desktop/Mudlet.app/Contents/MacOS/mudlet --profile "Mudlet self-test" --mirror env: AUTORUN_BUSTED_TESTS: 'true' MUDLET_TEST_MODE: 1 + MUDLET_TEST_REQUIRE_TTS_MOCK: 1 + MUDLET_TEST_REQUIRE_HTTP_FIXTURE: 1 + MUDLET_TEST_REQUIRE_MMCP_PEER: 1 + # No MUDLET_TEST_REQUIRE_MEDIA: Qt Multimedia starts no player on the + # macOS runners, so the media effect specs pend here by design. TESTS_DIRECTORY: ${{github.workspace}}/src/mudlet-lua/tests QUIT_MUDLET_AFTER_TESTS: 'true' ASAN_OPTIONS: detect_leaks=0 + - name: (Linux) Show the captured Discord frames on failure + if: failure() && matrix.run_tests == 'true' && runner.os == 'Linux' + shell: bash + run: | + echo "--- fake Discord IPC server log ---" + cat "${{runner.temp}}/discord-ipc-fixture.log" 2>/dev/null || true + echo "--- frames it captured ---" + cat "${{runner.temp}}/mudlet-discord-frames.jsonl" 2>/dev/null || true + - name: Passed Lua tests if: matrix.run_tests == 'true' run: | diff --git a/.github/workflows/check-mpackages.yml b/.github/workflows/check-mpackages.yml new file mode 100644 index 000000000..2d1642c66 --- /dev/null +++ b/.github/workflows/check-mpackages.yml @@ -0,0 +1,39 @@ +# Mudlet installs the .mpackage archives, not the loose sources next to them, +# so an edit that isn't re-zipped silently does nothing. See CI/check-mpackage-sync.lua. +name: Check mpackages + +on: + pull_request: + paths: + - 'src/packages/**' + - 'CI/check-mpackage-sync.lua' + - '.github/workflows/check-mpackages.yml' + workflow_dispatch: + +concurrency: + group: ${{github.workflow}}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check-mpackage-sync: + # Skip release PRs (base main): a package that changed during the cycle was + # already version-checked when it landed on development. + if: github.event.pull_request.base.ref != 'main' + name: Check archives match their sources + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install Lua 5.1.5 + uses: leafo/gh-actions-lua@v13 + with: + luaVersion: "5.1.5" + + - name: Fetch base branch + if: github.event_name == 'pull_request' + run: git fetch --no-tags origin ${{ github.base_ref }} + + - name: Check mpackage archives + run: lua CI/check-mpackage-sync.lua ${{ github.event_name == 'pull_request' && format('--base-ref origin/{0}', github.base_ref) || '' }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4604a98b9..cdcd2d755 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -88,7 +88,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.6 with: config-file: ./.github/codeql/codeql-config.yml languages: ${{ matrix.language }} @@ -98,7 +98,7 @@ jobs: queries: security-extended, security-and-quality - name: Use CMake 3.30.3 - uses: lukka/get-cmake@v4.4.0 + uses: lukka/get-cmake@v4.4.2 - name: (Linux) Install Lua via GitHub Actions uses: leafo/gh-actions-lua@v13 @@ -156,7 +156,7 @@ jobs: NINJA_STATUS: '[%f/%t %o/sec] ' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.6 with: category: "/language:${{ matrix.language }}" upload: false @@ -171,6 +171,6 @@ jobs: output: sarif-results/${{ matrix.language }}.sarif - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@v4.37.6 with: sarif_file: sarif-results/${{ matrix.language }}.sarif diff --git a/.github/workflows/create-github-release.yml b/.github/workflows/create-github-release.yml index 51f541789..81deec8b7 100644 --- a/.github/workflows/create-github-release.yml +++ b/.github/workflows/create-github-release.yml @@ -80,6 +80,16 @@ jobs: ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 + # workflow_run always runs this file from the default branch, while the + # checkout above is the commit that was built - which may predate the release + # scripts below. Take them from the same ref as this workflow file. + - uses: actions/checkout@v7 + if: steps.check.outputs.ready == 'true' + with: + ref: ${{ github.workflow_sha }} + path: release-scripts + fetch-depth: 1 + - uses: leafo/gh-actions-lua@v13 if: steps.check.outputs.ready == 'true' with: @@ -150,6 +160,10 @@ jobs: COMMIT=$(echo "$META" | jq -r '.commit') if [[ "$REF" == refs/tags/Mudlet-* ]]; then + # release-scripts/ is this workflow's own ref, so the guard is present + # even when the tagged commit predates it + bash release-scripts/CI/check-release-tag.sh "${VERSION}" "${REF#refs/tags/}" + echo "type=release" >> "$GITHUB_OUTPUT" echo "tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT" echo "title=Mudlet ${VERSION}" >> "$GITHUB_OUTPUT" @@ -159,6 +173,10 @@ jobs: echo "::error::COMMIT field is empty or missing in release metadata for PTB" exit 1 fi + # Nothing validates APP_VERSION on development, and a two-component one + # makes a PTB unofferable the same way + bash release-scripts/CI/check-release-tag.sh "${VERSION}" + PTB_TAG="Mudlet-${VERSION}${BUILD_SUFFIX}-${COMMIT}" echo "type=ptb" >> "$GITHUB_OUTPUT" echo "tag=${PTB_TAG}" >> "$GITHUB_OUTPUT" @@ -191,55 +209,57 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} path: assets/ - - name: Verify release assets + # A download that fails leaves the release short of a platform, so surface it + # rather than letting continue-on-error hide it + - name: Report asset download failures if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' env: - RELEASE_TYPE: ${{ steps.release-type.outputs.type }} + LINUX_MACOS_OUTCOME: ${{ steps.download-linux-macos.outcome }} + WINDOWS_OUTCOME: ${{ steps.download-windows.outcome }} run: | - mkdir -p assets/ - echo "Downloaded assets:" - find assets/ -type f | sort - - MISSING=() - if ! find assets/ -name '*.AppImage.tar' -type f | grep -q .; then - MISSING+=("Linux (.AppImage.tar)") + if [[ "${LINUX_MACOS_OUTCOME}" == "failure" ]]; then + echo "::warning::Downloading the Linux/macOS release assets failed - they will be missing from this release" fi - if ! find assets/ -name '*.dmg' -type f | grep -q .; then - MISSING+=("macOS (.dmg)") - fi - if ! find assets/ -name '*.exe' -type f | grep -q .; then - MISSING+=("Windows (.exe)") + if [[ "${WINDOWS_OUTCOME}" == "failure" ]]; then + echo "::warning::Downloading the Windows release assets failed - it will be missing from this release" fi - if [[ ${#MISSING[@]} -gt 0 ]]; then - echo "::warning::Missing release assets for: ${MISSING[*]}" - fi + - name: Prepare release assets + if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' + env: + RELEASE_TAG: ${{ steps.release-type.outputs.tag }} + RELEASE_TYPE: ${{ steps.release-type.outputs.type }} + run: bash release-scripts/CI/prepare-release-assets.sh assets/ "${RELEASE_TAG}" "${RELEASE_TYPE}" - # Stable releases must have all platforms; PTB tolerates partial - if [[ "${RELEASE_TYPE}" == "release" && ${#MISSING[@]} -gt 0 ]]; then - echo "::error::Stable release is missing assets for: ${MISSING[*]}" + # The release may already carry assets from the other build workflow's run of + # this job, so its SHA256SUMS.txt has to be merged rather than overwritten + - name: Fetch published SHA256SUMS.txt + if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' + env: + RELEASE_TAG: ${{ steps.release-type.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p published/ + if ! gh release view "${RELEASE_TAG}" 2> view-error.txt; then + if grep -qi 'release not found' view-error.txt; then + echo "Release ${RELEASE_TAG} does not exist yet - no checksums to merge" + exit 0 + fi + cat view-error.txt + echo "::error::Could not read release ${RELEASE_TAG} - refusing to rebuild its checksums from a partial view" exit 1 fi - - if [[ ${#MISSING[@]} -eq 3 ]]; then - echo "::error::No release assets found for any platform" - exit 1 + if gh release download "${RELEASE_TAG}" --pattern SHA256SUMS.txt --dir published/; then + echo "Already published on ${RELEASE_TAG}:" + cat published/SHA256SUMS.txt + else + echo "::warning::${RELEASE_TAG} exists but its SHA256SUMS.txt could not be downloaded - any entry only it covers will be lost" fi # Assemble SHA256SUMS.txt from per-platform .sha256 sidecar files - name: Assemble SHA256SUMS.txt if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' - run: | - mapfile -t SHA_FILES < <(find assets/ -name '*.sha256' -type f) - - if [[ ${#SHA_FILES[@]} -eq 0 ]]; then - echo "::error::No .sha256 checksum files found in assets/" - exit 1 - fi - - cat "${SHA_FILES[@]}" > assets/SHA256SUMS.txt - echo "Generated SHA256SUMS.txt (${#SHA_FILES[@]} entries):" - cat assets/SHA256SUMS.txt + run: bash release-scripts/CI/assemble-release-checksums.sh assets/ published/SHA256SUMS.txt - name: Generate changelog if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' @@ -352,21 +372,43 @@ jobs: TARGET_SHA: ${{ github.event.workflow_run.head_sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Collect release files and combined checksums (exclude per-platform .sha256 sidecars) - mapfile -t FILES < <(find assets/ -type f \ - \( -name '*.AppImage.tar' -o -name '*.exe' -o -name '*.dmg' -o -name 'SHA256SUMS.txt' \)) + # Everything in assets/ except the per-platform sidecars, which only feed + # SHA256SUMS.txt. Not a list of binary suffixes: a new asset type must not + # be able to reach the release without the checksum gate below seeing it. + mapfile -t BINARIES < <(find assets/ -type f ! -name '*.sha256' ! -name 'SHA256SUMS.txt') - if [[ ${#FILES[@]} -eq 0 ]]; then + if [[ ${#BINARIES[@]} -eq 0 ]]; then echo "::error::No release files found to upload" exit 1 fi echo "Files to upload:" - printf '%s\n' "${FILES[@]}" + printf '%s\n' assets/SHA256SUMS.txt "${BINARIES[@]}" - if gh release view "${RELEASE_TAG}" &>/dev/null; then + # Guard against publishing a binary SHA256SUMS.txt does not cover: the + # release ends up holding what is already on it plus what we upload now + : > final-assets.txt + if gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' >> final-assets.txt 2> view-error.txt; then + RELEASE_EXISTS=yes + elif grep -qi 'release not found' view-error.txt; then + RELEASE_EXISTS=no + else + cat view-error.txt + echo "::error::Could not list the assets already published on ${RELEASE_TAG} - refusing to publish without checking checksum coverage" + exit 1 + fi + basename -a -- "${BINARIES[@]}" >> final-assets.txt + + echo "Assets the release will hold afterwards:" + sort -u final-assets.txt + bash release-scripts/CI/verify-release-checksums.sh assets/SHA256SUMS.txt final-assets.txt assets/ + + # SHA256SUMS.txt first: it is a superset of the old and new binaries, so if + # the upload dies partway the release is never left holding an uncovered one + if [[ "${RELEASE_EXISTS}" == "yes" ]]; then echo "Release ${RELEASE_TAG} already exists - uploading any missing assets" - gh release upload "${RELEASE_TAG}" "${FILES[@]}" --clobber + gh release upload "${RELEASE_TAG}" assets/SHA256SUMS.txt --clobber + gh release upload "${RELEASE_TAG}" "${BINARIES[@]}" --clobber else ARGS=( "${RELEASE_TAG}" @@ -378,10 +420,39 @@ jobs: ARGS+=(--prerelease --target "${TARGET_SHA}") fi - gh release create "${ARGS[@]}" "${FILES[@]}" \ - || gh release upload "${RELEASE_TAG}" "${FILES[@]}" --clobber + gh release create "${ARGS[@]}" assets/SHA256SUMS.txt \ + || gh release upload "${RELEASE_TAG}" assets/SHA256SUMS.txt --clobber + gh release upload "${RELEASE_TAG}" "${BINARIES[@]}" --clobber fi + # Confirm against the live release, not just the files we meant to upload + - name: Verify published release checksums + if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' + env: + RELEASE_TAG: ${{ steps.release-type.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # A freshly uploaded asset is not always immediately readable, so retry + # rather than declaring a good release broken + for attempt in 1 2 3 4 5; do + rm -rf published-final/ + mkdir -p published-final/ + if gh release download "${RELEASE_TAG}" --pattern SHA256SUMS.txt --dir published-final/ \ + && gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' > published-final/assets.txt; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo "::error::Could not read back the published ${RELEASE_TAG} assets to verify them" + exit 1 + fi + echo "Attempt ${attempt} could not read the published release yet - retrying" + sleep 10 + done + + echo "Published assets:" + cat published-final/assets.txt + bash release-scripts/CI/verify-release-checksums.sh published-final/SHA256SUMS.txt published-final/assets.txt + # Generate and upload Sparkle appcast XML for macOS updates - name: Add SSH agent for appcast upload if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb' diff --git a/.github/workflows/performance-analysis.yml b/.github/workflows/performance-analysis.yml index 8c511d809..0e9d0029f 100644 --- a/.github/workflows/performance-analysis.yml +++ b/.github/workflows/performance-analysis.yml @@ -28,7 +28,7 @@ jobs: submodules: recursive - name: Use CMake 3.30.3 - uses: lukka/get-cmake@v4.4.0 + uses: lukka/get-cmake@v4.4.2 - name: Install dependencies run: | diff --git a/.github/workflows/tag-pull-requests.yml b/.github/workflows/tag-pull-requests.yml index 7ab75a4a0..6b46f764f 100644 --- a/.github/workflows/tag-pull-requests.yml +++ b/.github/workflows/tag-pull-requests.yml @@ -3,15 +3,23 @@ name: Pull request on: pull_request_target: +# issues covers reading the milestone list and setting the milestone on the +# pull request, which the REST API treats as an issue; pull-requests is granted +# as well because GitHub gates issue endpoints on it when the target is a PR permissions: contents: read + issues: write + pull-requests: write jobs: add-milestone: runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'Mudlet' }} steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Get next milestone id: next-milestone-string @@ -20,30 +28,54 @@ jobs: cmd: yq eval '.milestones.next-milestone' '.github/repo-metadata.yml' - name: 'Convert milestone to Github #' - id: next-milestone-number + env: + # authenticated so this does not share the per-IP anonymous rate limit + # with every other job on the runner's address + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + NEXT_MILESTONE: ${{ steps.next-milestone-string.outputs.result }} run: | - MILESTONE_NUMBER=$(curl --silent --request GET \ - --url https://api.github.com/repos/Mudlet/Mudlet/milestones \ - -H "Accept: application/vnd.github.v3+json" | \ - jq '.[] | select(.title == "${{ steps.next-milestone-string.outputs.result }}").number') + set -euo pipefail - echo "MILESTONE_NUMBER=$MILESTONE_NUMBER" >> "$GITHUB_ENV" + # Plain assignment, not a process substitution, so a failure to resolve + # the milestone stops the job instead of being read as an empty answer + if ! resolved=$(.github/scripts/resolve-milestone.sh); then + exit 1 + fi + + read -r number title <<< "${resolved}" + echo "Milestone: ${title}" + echo "MILESTONE_NUMBER=${number}" >> "$GITHUB_ENV" + echo "MILESTONE_TITLE=${title}" >> "$GITHUB_ENV" - name: Assign PR to milestone env: - TOKEN: ${{ secrets.GH_PAT_UPDATE_PULL_REQUESTS }} + # The built-in token has write access here because pull_request_target + # runs in the base repository; the fine-grained PAT this replaces was + # blocked by the organisation's 366-day token lifetime policy + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | - # Fetch pull request details - PR_DETAILS=$(curl -s --request GET \ - -H "Accept: application/vnd.github.v3+json" \ - --url ${{ github.event.pull_request.issue_url }} \ - --header "authorization: token $TOKEN") + set -euo pipefail - # Check if the pull request has a milestone already - if [ $(echo "$PR_DETAILS" | jq '.milestone == null') == "true" ]; then - curl -s --request PATCH \ - -H "Accept: application/vnd.github.v3+json" \ - --url ${{ github.event.pull_request.issue_url }} \ - --header "authorization: token $TOKEN" \ - --data '{"milestone": '"$MILESTONE_NUMBER"'}' + if [ -z "${MILESTONE_NUMBER:-}" ]; then + echo "::error::No milestone number was resolved by the previous step" + exit 1 fi + + # Captured rather than tested inline, because a failed read inside the + # test would look identical to "has no milestone" and overwrite one that + # somebody set on purpose + if ! existing=$(gh api "repos/${REPO}/issues/${PR_NUMBER}" --jq '.milestone.number // empty'); then + echo "::error::Could not read the current milestone of #${PR_NUMBER}" + exit 1 + fi + + if [ -n "${existing}" ]; then + echo "#${PR_NUMBER} is already on milestone ${existing}, leaving it as it is" + exit 0 + fi + + gh api --method PATCH "repos/${REPO}/issues/${PR_NUMBER}" -F "milestone=${MILESTONE_NUMBER}" --silent + echo "Assigned #${PR_NUMBER} to milestone ${MILESTONE_TITLE}" diff --git a/.github/workflows/update-3rdparty.yml b/.github/workflows/update-3rdparty.yml index 5c26c5adc..8a6cf374d 100644 --- a/.github/workflows/update-3rdparty.yml +++ b/.github/workflows/update-3rdparty.yml @@ -18,7 +18,7 @@ jobs: type: download url: https://raw.githubusercontent.com/Mudlet/mudlet-package-repository/refs/heads/main/packages/mpkg.mpackage file: mpkg.mpackage - path: src/ + path: src/packages/mpkg/ branch: update-mpkg-mpackage title: "Infrastructure: Update bundled mpkg.mpackage to latest upstream" body: | diff --git a/.gitignore b/.gitignore index f86ea5a91..cd577ad2a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,10 +31,7 @@ CMakeLists.txt.user #CLion .idea -cmake-build-debug -cmake-build-minsizerel -cmake-build-release -cmake-build-relwithdebinfo +cmake-build-*/ #VS Code .vscode/*.code-workspace @@ -54,3 +51,7 @@ CMakeSettings.json # flatpak builder CI/.flatpak-builder CI/build-dir + +# python bytecode (CI helper scripts) +__pycache__/ +*.pyc diff --git a/3rdparty/sentry-native b/3rdparty/sentry-native index a1827544e..a185ce80b 160000 --- a/3rdparty/sentry-native +++ b/3rdparty/sentry-native @@ -1 +1 @@ -Subproject commit a1827544e2da7e50517615003288c25380f8d457 +Subproject commit a185ce80ba2416b0a0bb04b4ee8f11f1117ae08f diff --git a/CI/assemble-release-checksums.sh b/CI/assemble-release-checksums.sh new file mode 100755 index 000000000..8d6746667 --- /dev/null +++ b/CI/assemble-release-checksums.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Assembles SHA256SUMS.txt for a GitHub Release from the per-platform .sha256 +# sidecar files, merged over the SHA256SUMS.txt already published on the release. +# +# Merging matters because create-github-release.yml runs once per platform build +# workflow and uploads with --clobber. A run that sees fewer platforms than an +# earlier run - or a re-run of one platform whose binary is named differently - +# used to regenerate SHA256SUMS.txt from its own subset of sidecars and overwrite +# the complete file, while the binaries from the earlier run stayed published. That +# left release binaries with no checksum line, which the updater's download path +# refuses to install (see Feed::findChecksum and UpdateDialog::startDownload). +# Entries are keyed by filename and the freshly built sidecars win, so the +# published file only ever gains coverage. +# +# This is a read-modify-write of a file shared by both platform triggers; it is +# only safe because create-github-release.yml serialises them with a `concurrency` +# group keyed on the build's head_sha. +# +# Usage: assemble-release-checksums.sh <assets-dir> [published-sums-file] + +set -euo pipefail + +ASSETS_DIR="${1:?assets directory required}" +PUBLISHED_SUMS="${2:-}" + +OUTPUT="${ASSETS_DIR%/}/SHA256SUMS.txt" + +# no mapfile: macOS ships bash 3.2, and test/ci/release-checksums-test.sh runs +# these scripts under ctest there +SHA_FILES=() +while IFS= read -r sha_file; do + SHA_FILES+=("${sha_file}") +done < <(find "${ASSETS_DIR}" -name '*.sha256' -type f | sort) + +if [[ ${#SHA_FILES[@]} -eq 0 ]]; then + echo "::error::No .sha256 checksum files found in ${ASSETS_DIR}" + exit 1 +fi + +RECORDS="$(mktemp)" +trap 'rm -f "${RECORDS}"' EXIT + +# Appends "<filename>\t<priority>\t<hash>\t<binary marker>" to ${RECORDS} for +# every checksum line on stdin, so the lines can be deduplicated by filename with +# the lowest priority number winning. The output line is rebuilt from these fields +# rather than carried through verbatim, because a checksum line may itself contain +# a tab and would then be truncated by the tab-delimited dedupe. +collect() { + local priority="$1" + local source_label="$2" + local line + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + if [[ -z "${line}" ]]; then + continue + fi + # sha256sum writes "<hash> <name>" in text mode and "<hash> *<name>" in + # binary mode; MSYS2's defaults to binary, so the Windows entry uses " *" + if [[ ! "${line}" =~ ^([0-9a-fA-F]{64})[[:space:]]+(\*?)(.+)$ ]]; then + echo "::warning::Ignoring unparseable checksum line from ${source_label}: ${line}" + continue + fi + printf '%s\t%s\t%s\t%s\n' "${BASH_REMATCH[3]}" "${priority}" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" >> "${RECORDS}" + done +} + +record_count() { + wc -l < "${RECORDS}" | tr -d ' ' +} + +if [[ -n "${PUBLISHED_SUMS}" && -f "${PUBLISHED_SUMS}" ]]; then + echo "Merging over the SHA256SUMS.txt already published on the release" + collect 2 "the published SHA256SUMS.txt" < "${PUBLISHED_SUMS}" +fi + +# one sidecar at a time: concatenating them would join two records whenever a +# sidecar lacks a trailing newline +for sha_file in "${SHA_FILES[@]}"; do + before="$(record_count)" + sidecar_name="$(basename "${sha_file}")" + collect 1 "${sidecar_name}" < "${sha_file}" + if [[ "$(record_count)" -eq "${before}" ]]; then + echo "::error::${sha_file} contributed no checksum entry, so the binary it describes would be published uncovered" + exit 1 + fi +done + +if [[ ! -s "${RECORDS}" ]]; then + echo "::error::No valid checksum lines found in ${SHA_FILES[*]} ${PUBLISHED_SUMS}" + exit 1 +fi + +# First record per filename wins after sorting by filename then priority, so a +# freshly built sidecar (1) beats an already published entry (2). Two different +# hashes for one filename at the winning priority mean the inputs disagree, which +# must not be resolved by picking one arbitrarily. +if ! sort -t $'\t' -k1,1 -k2,2n "${RECORDS}" | awk -F '\t' ' +{ + filename = $1; priority = $2 + 0; hash = $3; marker = $4 + if (!(filename in winningPriority)) { + winningPriority[filename] = priority + winningHash[filename] = hash + winningMarker[filename] = marker + order[++count] = filename + } else if (priority == winningPriority[filename] && hash != winningHash[filename]) { + conflicting[filename] = 1 + } +} +END { + failed = 0 + for (i = 1; i <= count; i++) { + filename = order[i] + if (filename in conflicting) { + printf "::error::Conflicting checksums for %s - refusing to guess which is current\n", filename > "/dev/stderr" + failed = 1 + continue + } + separator = (winningMarker[filename] == "*") ? " *" : " " + printf "%s%s%s\n", winningHash[filename], separator, filename + } + exit failed +}' > "${OUTPUT}"; then + exit 1 +fi + +echo "Generated SHA256SUMS.txt ($(wc -l < "${OUTPUT}" | tr -d ' ') entries):" +cat "${OUTPUT}" diff --git a/CI/build-mudlet-for-windows.sh b/CI/build-mudlet-for-windows.sh index 8f9c13519..567a0a1ca 100644 --- a/CI/build-mudlet-for-windows.sh +++ b/CI/build-mudlet-for-windows.sh @@ -112,10 +112,9 @@ mkdir -p "build-${MSYSTEM}" cd "${GITHUB_WORKSPACE}"/build-"${MSYSTEM}" || exit 1 #### Lua environment setup #### -# Set up Lua 5.1 paths for translation processing and runtime -LUA_PATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-path)" ) +LUA_PATH=$(luarocks --lua-version 5.1 path --lr-path) export LUA_PATH -LUA_CPATH=$(cygpath -u "$(luarocks --lua-version 5.1 path --lr-cpath)" ) +LUA_CPATH=$(luarocks --lua-version 5.1 path --lr-cpath) export LUA_CPATH echo "" diff --git a/CI/check-mpackage-sync.lua b/CI/check-mpackage-sync.lua new file mode 100755 index 000000000..d3cdc26d6 --- /dev/null +++ b/CI/check-mpackage-sync.lua @@ -0,0 +1,194 @@ +#!/usr/bin/env lua +--[[ +Check packaged .mpackage archives against their checked-in sources. + +Mudlet installs the .mpackage archive, not the loose config.lua/.xml files +sitting next to it, so editing a source file without rebuilding the archive +silently changes nothing at all. + +Some of these packages are also published to the package repository +(Mudlet/mudlet-package-repository), which offers updates by comparing the +version in config.lua. A content change that keeps the old version number +never reaches players who installed the package with mpkg. + +Run with no arguments to check archive contents. Pass --base-ref to also +require a version bump for any package whose contents changed: + + lua CI/check-mpackage-sync.lua --base-ref origin/development +]] + +-- packages the package repository syncs weekly, where mpkg needs a version bump +-- to offer the update - see update-core-packages.yml over there +local publishedPackages = { + "src/packages/deleteOldProfiles/deleteOldProfiles.mpackage", + "src/packages/echo/echo.mpackage", + "src/packages/enable-accessibility/enable-accessibility.mpackage", + "src/packages/generic_mapper/generic_mapper.mpackage", + "src/packages/mudlet-base-ui/mudlet-base-ui.mpackage", + "src/packages/run-lua-code/run-lua-code.mpackage", +} + +local errors = {} + +local function contains(list, wanted) + for _, item in ipairs(list) do + if item == wanted then return true end + end + return false +end + +local function quote(argument) + return "'" .. argument:gsub("'", "'\\''") .. "'" +end + +local function capture(command) + local pipe = assert(io.popen(command, "r")) + local output = pipe:read("*a") + pipe:close() + return output +end + +local function readFile(path) + local file = io.open(path, "rb") + if not file then return nil end + local contents = file:read("*a") + file:close() + return contents +end + +-- Every entry in the archive, mapped to its bytes. Directory entries, which +-- zipinfo lists with a trailing slash, are not files and are skipped. +local function contentsOf(archive) + local members = {} + for name in capture("unzip -Z1 " .. quote(archive)):gmatch("[^\n]+") do + if not name:match("/$") then + members[name] = capture(string.format("unzip -p %s %s", quote(archive), quote(name))) + end + end + return members +end + +local function sameContents(one, other) + for name, bytes in pairs(one) do + if other[name] ~= bytes then return false end + end + for name in pairs(other) do + if one[name] == nil then return false end + end + return true +end + +local function versionOf(members) + for line in (members["config.lua"] or ""):gmatch("[^\n]+") do + local version = line:match("^version%s*=%s*(.-)%s*$") + if version then + return (version:gsub("^[%[\"']+", ""):gsub("[%]\"']+$", "")) + end + end + return nil +end + +-- Sortable form of a version, tolerating parts like "2" or "1.0.0rc1" +local function versionParts(version) + local parts = {} + for part in version:gmatch("[^.]+") do + parts[#parts + 1] = {tonumber(part:match("%d+")) or 0, part} + end + return parts +end + +local function isNewer(candidate, existing) + local new, old = versionParts(candidate), versionParts(existing) + for index = 1, math.max(#new, #old) do + local newPart = new[index] or {0, ""} + local oldPart = old[index] or {0, ""} + if newPart[1] ~= oldPart[1] then return newPart[1] > oldPart[1] end + if newPart[2] ~= oldPart[2] then return newPart[2] > oldPart[2] end + end + return false +end + +-- Archive contents at baseRef, or nil if the package is new there +local function contentsAtBaseRef(path, baseRef) + local temporary = os.tmpname() + local archive = capture(string.format("git show %s 2>/dev/null", quote(baseRef .. ":" .. path))) + if archive == "" then + os.remove(temporary) + return nil + end + + local file = assert(io.open(temporary, "wb")) + file:write(archive) + file:close() + local members = contentsOf(temporary) + os.remove(temporary) + return members +end + +-- Every member with a file of the same name beside the archive must match it +local function checkSourcesMatch(path, members) + local directory = path:match("^(.*)/[^/]+$") + for name, packaged in pairs(members) do + local source = directory .. "/" .. name + local onDisk = readFile(source) + if onDisk and onDisk ~= packaged then + table.insert(errors, string.format("%s does not match %s - rebuild the archive after editing the source", path, source)) + end + end +end + +local function checkVersionBumped(path, members, baseRef) + local was = contentsAtBaseRef(path, baseRef) + if not was or sameContents(was, members) then return end + + local old, new = versionOf(was), versionOf(members) + if not new then + table.insert(errors, string.format("%s has no version in its config.lua", path)) + elseif old and not isNewer(new, old) then + table.insert(errors, string.format("%s changed but is still version %s - bump it so mpkg offers the update", path, new)) + end +end + +local baseRef +for index = 1, #arg do + if arg[index] == "--base-ref" then + baseRef = arg[index + 1] + elseif arg[index]:match("^%-%-base%-ref=") then + baseRef = arg[index]:match("=(.*)$") + end +end + +-- every default package lives in its own directory under src/packages, named +-- after the package, holding the archive and the sources it was built from +local packages = {} +for name in capture("ls -1 src/packages"):gmatch("[^\n]+") do + local archive = string.format("src/packages/%s/%s.mpackage", name, name) + if readFile(archive) then table.insert(packages, archive) end +end +table.sort(packages) + +for _, path in ipairs(publishedPackages) do + if not contains(packages, path) then + table.insert(errors, string.format("%s is listed as published but is not in src/packages", path)) + end +end + +for _, path in ipairs(packages) do + local members = contentsOf(path) + checkSourcesMatch(path, members) + if baseRef and contains(publishedPackages, path) then + checkVersionBumped(path, members, baseRef) + end +end + +for _, message in ipairs(errors) do + print("error: " .. message) +end + +if #errors > 0 then + print(string.format("\n%d problem(s) found. Rebuild an archive from its sources with:", #errors)) + print(" cd src/packages/<name> && zip <name>.mpackage config.lua <name>.xml") + os.exit(1) +end + +print(string.format("%d mpackage archives match their sources.", #packages)) diff --git a/CI/check-release-tag.sh b/CI/check-release-tag.sh new file mode 100755 index 000000000..b11c21643 --- /dev/null +++ b/CI/check-release-tag.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Usage: check-release-tag.sh <version> [tag] +# +# The updater offers the version from the release tag, not from APP_VERSION +# (src/updater/Release.cpp), and SemVer needs three components - so "Mudlet-5.0" is +# never offered to anyone, silently. CI/prepare-release-assets.sh cannot see it: that +# check matches the tag as a prefix of the asset names, and "Mudlet-5.0.0-linux-x64" +# does start with "Mudlet-5.0". Only the opposite mistake fails there. +# +# "Mudlet-5.0.0-rc1" is rejected as well - SemVer would accept it, but APP_VERSION +# cannot carry a suffix, so the assets named after it would not match the tag. +# +# A PTB passes no tag, its tag being generated rather than pushed. + +set -euo pipefail + +VERSION="${1:-}" +TAG="${2:-}" + +if [ $# -lt 1 ] || [ $# -gt 2 ] || [ -z "${VERSION}" ]; then + echo "usage: $(basename "$0") <version> [tag]" >&2 + exit 2 +fi + +# A multi-line message cannot become a GitHub annotation, so summarise in one line +annotate() { + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "::error::$1" + fi +} + +# Same shape SemVer::getRegExp() accepts, leading zeros and all +if ! [[ "${VERSION}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + annotate "APP_VERSION '${VERSION}' is not a three-component version - nothing built from it would ever be offered as an update to anyone who already has Mudlet installed" + cat >&2 <<EOF +error: APP_VERSION '${VERSION}' is not a three-component version like 5.0.0. + +Mudlet's updater only recognises three-component semantic versions, so a build +carrying this version is never offered to anyone who already has Mudlet installed - +including public test builds. Set a three-component version in +set(APP_VERSION ...) in CMakeLists.txt. +EOF + exit 1 +fi + +if [ -n "${TAG}" ] && [ "${TAG}" != "Mudlet-${VERSION}" ]; then + annotate "Release tag '${TAG}' does not match APP_VERSION '${VERSION}' - it has to be exactly 'Mudlet-${VERSION}', or auto-update stops working for every existing user without saying so" + cat >&2 <<EOF +error: release tag '${TAG}' does not match APP_VERSION '${VERSION}'. +The tag has to be exactly 'Mudlet-${VERSION}'. + +Publishing under a mismatched tag breaks auto-update, without saying so. The +updater reads the version it offers from the tag rather than from the binary, so a +tag like 'Mudlet-5.0' offers version '5.0' - not a three-component semantic +version, therefore never newer than the installed 4.22.0, therefore never offered. +No error is shown and the update check logs "0 update(s) available", the same line +it logs when there is genuinely nothing new. + +It also desynchronises macOS: create-github-release.yml puts the tag's version into +<sparkle:version> while the app reports APP_VERSION as its CFBundleVersion, so a +tag ahead of APP_VERSION leaves Sparkle re-offering an update the installed app can +never satisfy. + +Delete the tag and push it again as 'Mudlet-${VERSION}'. If '${VERSION}' is not the +version you meant to release, change set(APP_VERSION ...) in CMakeLists.txt first. +EOF + exit 1 +fi + +# A release log with no line here reads the same whether the tag was compared or the +# guard was never reached +if [ -n "${TAG}" ]; then + echo "Release tag '${TAG}' matches APP_VERSION '${VERSION}'." +fi diff --git a/CI/discord-ipc-fixture.py b/CI/discord-ipc-fixture.py new file mode 100644 index 000000000..049977a02 --- /dev/null +++ b/CI/discord-ipc-fixture.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Fake Discord IPC server for Mudlet's Lua self-tests. + +Speaks enough of the discord-rpc wire protocol that the bundled +libdiscord-rpc library believes a Discord client is running, reports a +logged-in user, and accepts rich presence updates. Every frame the library +sends is appended to a capture file so the Lua specs can assert on the +SET_ACTIVITY payload that actually reached "Discord", rather than on the +return value of the setter that produced it. + +Wire framing: [opcode:uint32 LE][length:uint32 LE][json payload] + opcode 0 = HANDSHAKE (client -> server) + opcode 1 = FRAME (both directions) + opcode 2 = CLOSE + opcode 3 = PING + opcode 4 = PONG + +Capture file format: one JSON object per line, + {"op": <opcode>, "payload": <parsed frame> | {"raw": "<undecodable text>"}} +Records are appended under a lock to an O_APPEND descriptor so a spec reading +the file concurrently never sees a half-written or spliced line. + +The C++ equivalent used by TDiscordModeTest is +test/functional_tests/DiscordIpcServerStub.cpp - keep the two in step. + +Usage: + discord-ipc-fixture.py --ready-file <path> [--runtime-dir <dir>] + [--capture-file <path>] [--username <name>] + +It prints (and, with --ready-file, writes) shell-style KEY=VALUE lines naming +the runtime directory and capture file once it is listening. Point +XDG_RUNTIME_DIR at the former before Mudlet starts: discord-rpc's reconnect +backoff is process-global and survives Discord_Shutdown, so a server that only +appears after the first failed connection attempt can cost up to ~120s. +""" + +import argparse +import json +import os +import socket +import struct +import sys +import tempfile +import threading + +SOCKET_FILE_NAME = "discord-ipc-0" + +OP_HANDSHAKE = 0 +OP_FRAME = 1 +OP_CLOSE = 2 +OP_PING = 3 +OP_PONG = 4 + +# discord-rpc's own send buffer is 16KB, so anything near this is nonsense: +MAXIMUM_FRAME_BYTES = 1024 * 1024 + + +def ready_payload(username): + return { + "cmd": "DISPATCH", + "evt": "READY", + "data": { + "v": 1, + "config": { + "cdn_host": "cdn.discordapp.com", + "api_endpoint": "//discord.com/api", + "environment": "production", + }, + "user": { + "id": "111111111111111111", + "username": username, + "discriminator": "0", + "global_name": username, + "avatar": None, + "bot": False, + "flags": 0, + "premium_type": 0, + }, + }, + } + + +class CaptureLog: + def __init__(self, path): + self._fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + self._lock = threading.Lock() + + def append(self, opcode, payload_bytes): + try: + payload = json.loads(payload_bytes.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + payload = {"raw": payload_bytes.decode("utf-8", "replace")} + line = (json.dumps({"op": opcode, "payload": payload}, sort_keys=True) + "\n").encode("utf-8") + # os.write() may write less than it was given, and discord-rpc's + # reconnects overlap connections, so two serve_connection() threads can + # be here at once. Without the lock one record's remainder could land + # after another record's first write, splicing both into one malformed + # line that framesAfter() would silently drop. + with self._lock: + while line: + line = line[os.write(self._fd, line):] + + +def frame(opcode, payload_bytes): + return struct.pack("<II", opcode, len(payload_bytes)) + payload_bytes + + +def read_exact(conn, count): + buf = b"" + while len(buf) < count: + chunk = conn.recv(count - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +def serve_connection(conn, capture, username): + # discord-rpc drops the socket without a CLOSE frame when it shuts down or + # switches application ID, so an aborted connection is routine here. + with conn: + try: + while True: + header = read_exact(conn, 8) + if not header: + return + opcode, length = struct.unpack("<II", header) + if length > MAXIMUM_FRAME_BYTES: + # A desynchronised stream would otherwise have us block on a + # length that never arrives, with the specs polling a capture + # file that has quietly stopped growing. + sys.stdout.write("discord-ipc-fixture: refusing a %d byte frame, closing the connection\n" % length) + sys.stdout.flush() + return + payload = read_exact(conn, length) if length else b"" + if payload is None: + return + capture.append(opcode, payload) + if opcode == OP_HANDSHAKE: + conn.sendall(frame(OP_FRAME, json.dumps(ready_payload(username)).encode("utf-8"))) + elif opcode == OP_PING: + conn.sendall(frame(OP_PONG, payload)) + elif opcode == OP_CLOSE: + return + # opcode 1 (FRAME, e.g. SET_ACTIVITY) needs no reply - the real + # Discord client answers it, but discord-rpc ignores the answer. + except OSError as error: + sys.stdout.write("discord-ipc-fixture: connection ended: %s\n" % error) + sys.stdout.flush() + + +def main(): + parser = argparse.ArgumentParser(description="Fake Discord IPC server for Mudlet's Lua self-tests") + parser.add_argument("--runtime-dir", help="directory to create the discord-ipc-0 socket in (default: a fresh short temporary directory)") + parser.add_argument("--capture-file", help="file to append captured frames to (default: discord-frames.jsonl inside the runtime directory)") + parser.add_argument("--username", default="MudletSelfTest", help="username the READY dispatch reports as logged in") + parser.add_argument("--ready-file", help="file to write the KEY=VALUE handover lines to once listening") + args = parser.parse_args() + + runtime_dir = args.runtime_dir + if runtime_dir: + os.makedirs(runtime_dir, exist_ok=True) + # Qt refuses to use an XDG_RUNTIME_DIR that anyone else can read, and + # falls back with a warning: + os.chmod(runtime_dir, 0o700) + else: + # Deliberately short: sizeof(sockaddr_un::sun_path) is 108 on Linux and + # 104 on macOS, and the whole socket path has to fit. + runtime_dir = tempfile.mkdtemp(prefix="mdxdg-") + + socket_path = os.path.join(runtime_dir, SOCKET_FILE_NAME) + if len(socket_path) >= 100: + sys.stderr.write("discord-ipc-fixture: socket path %s is too long for AF_UNIX\n" % socket_path) + return 1 + + capture_file = args.capture_file or os.path.join(runtime_dir, "discord-frames.jsonl") + # Truncate any capture left over from an earlier run so the specs never see + # frames from a previous suite: + with open(capture_file, "w"): + pass + capture = CaptureLog(capture_file) + + if os.path.exists(socket_path): + os.unlink(socket_path) + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(socket_path) + server.listen(8) + + handover = "MUDLET_TEST_DISCORD_RUNTIME_DIR=%s\nMUDLET_TEST_DISCORD_CAPTURE_FILE=%s\n" % (runtime_dir, capture_file) + if args.ready_file: + with open(args.ready_file, "w") as handle: + handle.write(handover) + sys.stdout.write(handover) + sys.stdout.write("discord-ipc-fixture: listening on %s as Discord user %s\n" % (socket_path, args.username)) + sys.stdout.flush() + + while True: + try: + conn, _ = server.accept() + except OSError as error: + # Staying up matters: every spec still to run would otherwise wait + # out its timeout against a capture file nobody is writing to. + sys.stdout.write("discord-ipc-fixture: accept failed: %s\n" % error) + sys.stdout.flush() + continue + threading.Thread(target=serve_connection, args=(conn, capture, args.username), daemon=True).start() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CI/http-fixture-server.py b/CI/http-fixture-server.py new file mode 100644 index 000000000..148daa10e --- /dev/null +++ b/CI/http-fixture-server.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Minimal fixture HTTP server for Mudlet's busted networking specs. + +Serves the sibling ``http-fixtures/`` directory over localhost so the Lua test +suite can exercise getHTTP/downloadFile against a real, local endpoint instead +of the public internet. + +Requests below ``/echo`` are answered by an echo endpoint instead of from disk: +it accepts GET and every verb this handler has no method of its own for +(postHTTP/putHTTP/deleteHTTP/customHTTP all need one) and reports the method, +path, request headers and body it received back in the response body, which is +what lets a spec prove that what Mudlet put on the wire is what the caller +asked for. HEAD is the one exception: it keeps serving files from disk. + +An OS-assigned (ephemeral) port is used rather than a fixed one: Mudlet CI may +run several jobs on the same machine, and a hard-coded port would risk +collisions there. The chosen port is written to the file named by the +``MUDLET_TEST_HTTP_PORT_FILE`` environment variable so the launching CI step can +forward it to Mudlet as ``MUDLET_TEST_HTTP_PORT``. +""" + +import http.server +import os +import socketserver + +FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "http-fixtures") + +ECHO_PATH = "/echo" + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=FIXTURES_DIR, **kwargs) + + def log_message(self, *args): + # Keep CI logs quiet; the tests assert on effects, not on server chatter. + pass + + def end_headers(self): + # Both are sent on every response, including the static file ones, so a + # spec can assert that the response headers and cookies tables Mudlet + # builds reach Lua. + self.send_header("X-Mudlet-Fixture", "1") + self.send_header("Set-Cookie", "mudlet-fixture=1; Path=/") + super().end_headers() + + def do_GET(self): + if self.echo_requested(): + self.echo() + return + super().do_GET() + + def __getattr__(self, name): + # BaseHTTPRequestHandler dispatches "VERB /path" to a do_VERB method and + # answers 501 when there is none. Only GET and HEAD have one, so every + # other verb - POST/PUT/DELETE plus whatever customHTTP() invents - is + # routed here and handled by the echo endpoint. Matching against the + # verb being dispatched keeps a mistyped attribute elsewhere in this + # class an AttributeError instead of silently becoming an echo. + # __dict__ rather than self.command: the attribute only exists once a + # request line has been parsed, and reading it through the instance + # would come straight back here. + if name.startswith("do_") and name == "do_%s" % self.__dict__.get("command"): + return self.echo + raise AttributeError(name) + + def echo_requested(self): + return self.path == ECHO_PATH or self.path.startswith(ECHO_PATH + "/") or self.path.startswith(ECHO_PATH + "?") + + def echo(self): + if not self.echo_requested(): + self.send_error(404, "Not Found", "only %s answers this method" % ECHO_PATH) + return + + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + length = 0 + body = self.rfile.read(length) if length > 0 else b"" + + lines = ["method=%s" % self.command, "path=%s" % self.path] + for name, value in self.headers.items(): + lines.append("header:%s=%s" % (name.lower(), value)) + # Body last: it is the only part that may itself contain newlines. + lines.append("body=%s" % body.decode("utf-8", "replace")) + payload = "\n".join(lines).encode("utf-8") + + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def main(): + # Single-threaded on purpose: the specs issue one request at a time, and + # HTTP/1.0 (the default here) closes each connection, so no request can + # block another. + with socketserver.TCPServer(("127.0.0.1", 0), QuietHandler) as httpd: + port = httpd.server_address[1] + port_file = os.environ.get("MUDLET_TEST_HTTP_PORT_FILE") + if port_file: + # Write then rename so the launcher never reads a torn/empty port. + tmp_file = port_file + ".tmp" + with open(tmp_file, "w", encoding="utf-8") as handle: + handle.write(str(port)) + os.replace(tmp_file, port_file) + print(f"Serving Mudlet test fixtures on http://127.0.0.1:{port}", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/CI/http-fixtures/fixture.txt b/CI/http-fixtures/fixture.txt new file mode 100644 index 000000000..d7bd1f2f6 --- /dev/null +++ b/CI/http-fixtures/fixture.txt @@ -0,0 +1 @@ +Mudlet self-test HTTP fixture. diff --git a/CI/mmcp-peer.py b/CI/mmcp-peer.py new file mode 100644 index 000000000..43c9773d2 --- /dev/null +++ b/CI/mmcp-peer.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +"""Scripted MMCP chat peer for Mudlet's busted networking specs. + +Mudlet's mmcp.* Lua API only has observable effects when a chat peer is on the +other end of a socket, so the specs need a real peer rather than a mock. This is +that peer: it accepts the call Mudlet places with mmcp.call(), completes the +MudMaster handshake, records every protocol command Mudlet sends, and sends +commands back when the specs ask it to. + +It lives in its own file rather than inside http-fixture-server.py because the +two share nothing: that one is a stock static file server, this one is a +stateful binary protocol peer. A single process would also mean one fixture +failing takes the other down with it. + +Three channels, all inside the directory named by ``MUDLET_TEST_MMCP_DIR`` (one +environment variable, read by both this process and Mudlet): + + port the OS-assigned listening port, written once the socket is + accepting. Ephemeral rather than MMCP's default 4050: CI jobs + and parallel local worktrees would collide on a fixed port. + capture.json everything this peer has seen, rewritten atomically after every + change so a reader never gets a torn file. + commands/ one JSON file per instruction from the specs, picked up in + numeric order and deleted once carried out. + +Only one call is held at a time: a new one replaces the old. Effects that need +two peers at once (mmcp.setPrivate's filtering, mmcp.serve's forwarding, a +non-empty connection or peek list) are out of reach until this grows a second +listening port. + +Wire format, from src/MMCP.h and src/MMCPClient.cpp: + + Mudlet -> peer on connect: "CHAT:<name>\\n<address><port padded to width 5>" + peer -> Mudlet, accepting: "YES:<name>\\n" (or "NO:<name>\\n" to refuse) + either direction after: <command byte><payload><0xff> +""" + +import json +import os +import selectors +import socket +import sys + +# Command bytes, from the MMCPChatCommand enum in src/MMCP.h. Commands not +# listed here are still recorded, by their numeric code. +COMMAND_NAMES = { + 1: "NameChange", + 2: "RequestConnections", + 3: "ConnectionList", + 4: "TextEveryone", + 5: "TextPersonal", + 6: "TextGroup", + 7: "Message", + 8: "DoNotDisturb", + 19: "Version", + 26: "PingRequest", + 27: "PingResponse", + 28: "PeekConnections", + 29: "PeekList", + 30: "Snoop", + 31: "SnoopData", + 32: "SnoopColor", + 40: "SideChannel", +} + +END = 0xFF +PING_REQUEST = 26 +PING_RESPONSE = 27 +VERSION = 19 + +# Mudlet only sends side channel data to peers whose version string says they +# are Mudlet (MMCPServer::sendSideChannel), so claim to be one. +PEER_VERSION = "Mudlet 0.0.0-busted-peer" +PEER_NAME = "BustedPeer" + +# Enough history for a spec to look back over a few steps without the capture +# file growing without bound over a whole suite run. +MAX_EVENTS = 200 +POLL_SECONDS = 0.01 + + +class MMCPPeer: + def __init__(self, directory): + self.directory = directory + self.commands_dir = os.path.join(directory, "commands") + self.capture_path = os.path.join(directory, "capture.json") + self.port_path = os.path.join(directory, "port") + + self.events = [] + self.seq = 0 + self.connections = 0 + self.caller = None + self.name = PEER_NAME + self.version = PEER_VERSION + self.accept_calls = True + + self.selector = selectors.DefaultSelector() + self.connection = None + self.state = "idle" + self.buffer = bytearray() + + self.listener = self.listen() + self.port = self.listener.getsockname()[1] + # A second address that only ever records who dialled it and hangs up. + # It is how a spec sees that Mudlet acted on an address a peer handed it + # (a connection list), which is otherwise reported to the console alone. + self.sink = self.listen() + self.dial_port = self.sink.getsockname()[1] + + @staticmethod + def listen(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen(4) + server.setblocking(False) + return server + + # -- capture ------------------------------------------------------------ + + def record(self, event): + self.seq += 1 + event["seq"] = self.seq + self.events.append(event) + del self.events[:-MAX_EVENTS] + self.write_capture() + + def write_capture(self): + payload = { + "seq": self.seq, + "port": self.port, + "dial_port": self.dial_port, + "connections": self.connections, + "connected": self.connection is not None, + "accepting": self.accept_calls, + "name": self.name, + "version": self.version, + "caller": self.caller, + "events": self.events, + } + # Write then rename so a spec reading mid-update sees the old file + # rather than half of the new one. + tmp_path = self.capture_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + os.replace(tmp_path, self.capture_path) + + # -- connection --------------------------------------------------------- + + def accept(self): + connection, address = self.listener.accept() + connection.setblocking(False) + connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + if self.connection is not None: + # Only one call at a time: an earlier caller that never went away + # would otherwise keep receiving what the specs meant for this one. + self.close_connection("replaced by a new call") + self.connection = connection + self.selector.register(connection, selectors.EVENT_READ) + self.state = "handshake" + self.buffer.clear() + self.caller = None + self.connections += 1 + self.record({"type": "connect", "from": "%s:%d" % address}) + + def accept_sink(self): + connection, address = self.sink.accept() + connection.close() + self.record({"type": "dialled", "from": "%s:%d" % address}) + + def close_connection(self, reason): + if self.connection is None: + return + try: + self.selector.unregister(self.connection) + except (KeyError, ValueError): + pass + try: + self.connection.close() + except OSError: + pass + self.connection = None + self.state = "idle" + self.buffer.clear() + self.record({"type": "disconnect", "reason": reason}) + + def send(self, data): + if self.connection is None: + self.record({"type": "send_failed", "reason": "no connection"}) + return False + try: + self.connection.sendall(data) + except OSError as error: + self.record({"type": "send_failed", "reason": str(error)}) + return False + return True + + def send_command(self, code, payload): + return self.send(bytes([code]) + payload + bytes([END])) + + def read(self): + try: + chunk = self.connection.recv(4096) + except BlockingIOError: + # BlockingIOError is an OSError, so it has to be let through before + # the catch-all below turns a socket error into an end of call. + return + except OSError: + chunk = b"" + if not chunk: + self.close_connection("closed by Mudlet") + return + self.buffer.extend(chunk) + if self.state == "handshake": + self.handle_handshake() + if self.state == "connected": + self.handle_commands() + + def handle_handshake(self): + newline = self.buffer.find(b"\n") + if newline == -1: + return + if not self.buffer.startswith(b"CHAT:"): + self.record({"type": "bad_handshake", "raw": self.buffer.decode("latin-1")}) + self.close_connection("handshake did not start with CHAT:") + return + # The address and port that follow the newline have no terminator of + # their own; Mudlet writes them in the same call as the name and parses + # an incoming call the same way, taking the last 5 bytes as the port. + rest = bytes(self.buffer[newline + 1:]) + if len(rest) < 5: + return + + caller_name = bytes(self.buffer[5:newline]).decode("latin-1") + raw = bytes(self.buffer).decode("latin-1") + self.buffer.clear() + self.caller = { + "name": caller_name, + "address": rest[:-5].decode("latin-1"), + "port": rest[-5:].decode("latin-1").strip(), + "raw": raw, + } + self.record({"type": "handshake", "caller": self.caller}) + + if not self.accept_calls: + self.send(("NO:%s\n" % self.name).encode("latin-1")) + self.close_connection("call refused") + return + + self.state = "connected" + # One write: Mudlet reads whatever has arrived when it handles the + # acceptance, and only understands a command tacked onto the end of it + # if the command is complete. + self.send(("YES:%s\n" % self.name).encode("latin-1") + + bytes([VERSION]) + self.version.encode("latin-1") + bytes([END])) + + def handle_commands(self): + while True: + end = self.buffer.find(bytes([END])) + if end == -1: + return + code = self.buffer[0] + payload = bytes(self.buffer[1:end]) + del self.buffer[:end + 1] + self.on_command(code, payload) + + def on_command(self, code, payload): + self.record({ + "type": "command", + "code": code, + "name": COMMAND_NAMES.get(code, "Unknown"), + "text": payload.decode("latin-1"), + "hex": payload.hex(), + }) + if code == PING_REQUEST: + # What a real peer does, and what lets a spec watch a full ping + # round trip rather than only the outgoing half. + self.send_command(PING_RESPONSE, payload) + + # -- commands from the specs ------------------------------------------- + + def poll_commands(self): + try: + names = os.listdir(self.commands_dir) + except OSError: + return + queued = [] + for name in names: + stem, extension = os.path.splitext(name) + # Specs write "<n>.json.tmp" and rename it into place, so a partly + # written command is never picked up. + if extension == ".json" and stem.isdigit(): + queued.append((int(stem), name)) + for _, name in sorted(queued): + path = os.path.join(self.commands_dir, name) + try: + with open(path, encoding="utf-8") as handle: + command = json.load(handle) + except (OSError, ValueError) as error: + command = None + self.record({"type": "command_error", "file": name, "error": str(error)}) + try: + os.remove(path) + except OSError: + pass + if command is not None: + self.run_command(command) + + def run_command(self, command): + try: + self.dispatch_command(command) + except (OSError, TypeError, ValueError) as error: + # A malformed command is one spec's problem. Dying over it would + # leave every later spec waiting on a peer that is no longer there. + self.record({"type": "command_error", "error": str(error)}) + + def dispatch_command(self, command): + action = command.get("action") + if action == "send": + text = command.get("text", "") + self.send_command(int(command.get("code", 0)), text.encode("latin-1", "replace")) + elif action == "send_hex": + self.send(bytes.fromhex(command.get("hex", ""))) + elif action == "close": + self.close_connection("closed on request") + elif action == "accept": + self.accept_calls = bool(command.get("accept", True)) + else: + self.record({"type": "command_error", "error": "unknown action: %r" % (action,)}) + return + self.record({"type": "command_done", "action": action}) + + # -- main loop ---------------------------------------------------------- + + def run(self): + os.makedirs(self.commands_dir, exist_ok=True) + self.selector.register(self.listener, selectors.EVENT_READ) + self.selector.register(self.sink, selectors.EVENT_READ) + self.write_capture() + self.write_port() + print("MMCP peer '%s' listening on 127.0.0.1:%d, sink on %d" + % (self.name, self.port, self.dial_port), flush=True) + while True: + for key, _ in self.selector.select(POLL_SECONDS): + if key.fileobj is self.listener: + self.accept() + elif key.fileobj is self.sink: + self.accept_sink() + elif key.fileobj is self.connection: + self.read() + self.poll_commands() + + def write_port(self): + # Written last, and atomically, so its presence means the peer is + # already accepting connections. + tmp_path = self.port_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as handle: + handle.write(str(self.port)) + os.replace(tmp_path, self.port_path) + + def forget_port(self): + # The specs read the port file to decide whether there is a peer worth + # talking to, so a peer that is going away has to take it with it: + # otherwise every spec waits out its handshake timeout against a socket + # nobody is listening on. + try: + os.remove(self.port_path) + except OSError: + pass + + +def main(): + directory = os.environ.get("MUDLET_TEST_MMCP_DIR") + if not directory: + print("MUDLET_TEST_MMCP_DIR is not set", file=sys.stderr) + return 1 + os.makedirs(directory, exist_ok=True) + peer = MMCPPeer(directory) + try: + peer.run() + finally: + peer.forget_port() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CI/prepare-release-assets.sh b/CI/prepare-release-assets.sh new file mode 100755 index 000000000..3fb24055f --- /dev/null +++ b/CI/prepare-release-assets.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# Validates the assets downloaded from the platform build runs before they are +# published to a GitHub Release, and sets aside any that do not belong. +# +# A PTB tag is "Mudlet-<VERSION><-ptb-DATE>-<COMMIT>" and its asset filenames are +# built from the same values, so they share that prefix; a stable release tag is +# the pushed git tag, which spells "Mudlet-<VERSION>" and so shares the prefix +# too. Anything else came from a different build: the two platform build workflows +# can be re-run independently, a re-run recomputes the PTB date and so carries a +# newer prefix than the tag, and create-github-release.yml always pairs the newest +# successful run of each platform. Publishing such a file would put a binary from +# build B into the release for build A, whose in-app version does not match the +# release tag - and would leave the release holding a binary that no SHA256SUMS.txt +# line can cover, because its checksum sidecar belongs to the other build. +# +# Setting an asset aside does not remove anything already published: a binary from +# another build that an earlier run uploaded stays on the release, and stays +# covered because assemble-release-checksums.sh merges its published entry +# forward. +# +# Usage: prepare-release-assets.sh <assets-dir> <release-tag> <release-type> + +set -euo pipefail + +ASSETS_DIR="${1:?assets directory required}" +RELEASE_TAG="${2:?release tag required}" +RELEASE_TYPE="${3:?release type required}" + +mkdir -p "${ASSETS_DIR}" + +echo "Downloaded assets:" +find "${ASSETS_DIR}" -type f | sort + +# Everything published alongside the binaries, rather than a list of binary +# suffixes: an unrecognised suffix has to be treated as a binary that needs +# checking, or adding an asset type would silently exempt it +is_release_binary() { + local name="$1" + [[ "${name}" != "SHA256SUMS.txt" && "${name}" != *.sha256 ]] +} + +# Whether any file matching the pattern exists. `find | grep -q .` would be +# simpler but exits non-zero under `set -o pipefail` when find is still writing as +# grep leaves, which would report a present asset as missing. +have_asset() { + [[ -n "$(find "${ASSETS_DIR}" -name "$1" -type f -print -quit)" ]] +} + +# Set aside assets belonging to a different build. A .sha256 sidecar is judged by +# the binary it describes, so a rejected binary takes its sidecar with it. +# CI/set-build-info.sh lowercases VERSION while a git tag keeps its case, so the +# prefix has to be compared case-insensitively. +REJECTED_DIR="${ASSETS_DIR%/}-rejected" +REJECTED=() +shopt -s nocasematch +while IFS= read -r asset_path; do + asset_name="$(basename "${asset_path}")" + binary_name="${asset_name%.sha256}" + if ! is_release_binary "${binary_name}"; then + continue + fi + if [[ "${binary_name}" == "${RELEASE_TAG}"* ]]; then + continue + fi + mkdir -p "${REJECTED_DIR}" + mv "${asset_path}" "${REJECTED_DIR}/" + REJECTED+=("${asset_name}") +done < <(find "${ASSETS_DIR}" -type f | sort) +shopt -u nocasematch + +if [[ ${#REJECTED[@]} -gt 0 ]]; then + echo "::warning::Ignoring ${#REJECTED[@]} asset(s) from a different build than ${RELEASE_TAG}: ${REJECTED[*]}" +fi + +MISSING=() +if ! have_asset '*.AppImage.tar'; then + MISSING+=("Linux (.AppImage.tar)") +fi +if ! have_asset '*-arm64.dmg'; then + MISSING+=("macOS (arm64 .dmg)") +fi +if ! have_asset '*-x86_64.dmg'; then + MISSING+=("macOS (x86_64 .dmg)") +fi +if ! have_asset '*.exe'; then + MISSING+=("Windows (.exe)") +fi + +if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "::warning::Missing release assets for: ${MISSING[*]}" +fi + +# Stable releases must have all platforms; PTB tolerates partial +if [[ "${RELEASE_TYPE}" == "release" && ${#MISSING[@]} -gt 0 ]]; then + echo "::error::Stable release is missing assets for: ${MISSING[*]}" + exit 1 +fi + +if ! have_asset '*.AppImage.tar' && ! have_asset '*.dmg' && ! have_asset '*.exe'; then + echo "::error::No release assets found for any platform" + exit 1 +fi + +echo "Assets to publish for ${RELEASE_TAG}:" +find "${ASSETS_DIR}" -type f | sort diff --git a/CI/setup-windows-sdk.sh b/CI/setup-windows-sdk.sh index d0e453d26..93ae425ef 100644 --- a/CI/setup-windows-sdk.sh +++ b/CI/setup-windows-sdk.sh @@ -19,7 +19,9 @@ # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # ########################################################################### -# Version: 2.3.0 Switch from MINGW64 to CLANG64 +# Version: 2.4.0 Add Python, needed by the fixture HTTP server the Lua +# tests run against +# 2.3.0 Switch from MINGW64 to CLANG64 # 2.2.0 Add CMake package for CMake-based builds # 2.1.0 Remove MINGW32 since upstream no longer supports it # 2.0.0 Rework to build on an MSYS2 MINGW64 Github workflow @@ -128,6 +130,7 @@ while true; do "${MINGW_PACKAGE_PREFIX}-ninja" \ "${MINGW_PACKAGE_PREFIX}-assimp" \ "${MINGW_PACKAGE_PREFIX}-curl" \ + "${MINGW_PACKAGE_PREFIX}-python" \ "${MINGW_PACKAGE_PREFIX}-uasm" \ "${MINGW_PACKAGE_PREFIX}-cmake" \ "${MINGW_PACKAGE_PREFIX}-jq"; then diff --git a/CI/validate-deployment-for-windows.sh b/CI/validate-deployment-for-windows.sh index ff7d634c4..c14dec951 100644 --- a/CI/validate-deployment-for-windows.sh +++ b/CI/validate-deployment-for-windows.sh @@ -25,12 +25,31 @@ function validate_cmake() { # there is no static "APP_BUILD" line left to validate here. } +function validate_release_tag() { + local TAG_NAME="" + if [[ "${GITHUB_REF:-}" =~ ^refs/tags/ ]]; then + TAG_NAME="${GITHUB_REF#refs/tags/}" + fi + if [ -z "${TAG_NAME}" ]; then + error "This is a release build, but the tag being built could not be determined from GITHUB_REF." + fi + + local APP_VERSION + APP_VERSION=$(pcre2grep --only-matching=1 "set\(APP_VERSION (.+)\)$" < CMakeLists.txt) + if [ -z "${APP_VERSION}" ]; then + error "No set(APP_VERSION ...) line could be read out of CMakeLists.txt." + fi + + bash "$(dirname "${BASH_SOURCE[0]}")/check-release-tag.sh" "${APP_VERSION}" "${TAG_NAME}" || exit $? +} + function validate_updater_environment_variable() { if [ "$WITH_UPDATER" == "NO" ]; then error "Updater is disabled in a release build." fi } +validate_release_tag validate_cmake validate_updater_environment_variable diff --git a/CI/validate_deployment.sh b/CI/validate_deployment.sh index b0d9aaa46..4ea901b74 100755 --- a/CI/validate_deployment.sh +++ b/CI/validate_deployment.sh @@ -26,12 +26,31 @@ else # there is no static "APP_BUILD" line left to validate here. } + function validate_release_tag() { + local TAG_NAME="${TRAVIS_TAG:-}" + if [[ "${GITHUB_REF:-}" =~ ^refs/tags/ ]]; then + TAG_NAME="${GITHUB_REF#refs/tags/}" + fi + if [ -z "${TAG_NAME}" ]; then + error "This is a release build, but the tag being built could not be determined from GITHUB_REF or TRAVIS_TAG." + fi + + local APP_VERSION + APP_VERSION=$(pcre2grep --only-matching=1 "set\(APP_VERSION (.+)\)$" < CMakeLists.txt) + if [ -z "${APP_VERSION}" ]; then + error "No set(APP_VERSION ...) line could be read out of CMakeLists.txt." + fi + + bash "$(dirname "${BASH_SOURCE[0]}")/check-release-tag.sh" "${APP_VERSION}" "${TAG_NAME}" || exit $? + } + function validate_updater_environment_variable() { if [ "$WITH_UPDATER" == "NO" ]; then error "Updater is disabled in a release build." fi } + validate_release_tag validate_cmake validate_updater_environment_variable fi diff --git a/CI/verify-release-checksums.sh b/CI/verify-release-checksums.sh new file mode 100755 index 000000000..fcc6cf21f --- /dev/null +++ b/CI/verify-release-checksums.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Fails if any release binary lacks a SHA256SUMS.txt entry, or if a binary we still +# have on disk does not hash to the value listed for it. +# +# The updater's download path refuses a download it cannot verify (see +# UpdateDialog::startDownload, which passes requireChecksums), so a release binary +# without a checksum line cannot be installed through the update dialog - see +# assemble-release-checksums.sh for how that used to happen. This is the gate that +# keeps it from being published. +# +# The check covers assets already on the release, not just the ones being uploaded, +# because those are what a user's updater will see. A release that is *already* +# missing a checksum therefore fails every later run of the publishing job and +# cannot be repaired by re-running it: the sidecar for an older run's binary is no +# longer downloadable, so the stale asset has to be deleted from the release (or +# its platform build re-run) by hand. +# +# Usage: verify-release-checksums.sh <sums-file> <asset-names-file> [assets-dir] +# asset-names-file lists one asset filename per line: every release binary among +# them must have an entry in sums-file. +# assets-dir, when given, is searched for each of those binaries, and any that is +# found has its actual SHA256 compared against the listed one. + +set -euo pipefail + +SUMS_FILE="${1:?SHA256SUMS.txt path required}" +ASSET_NAMES_FILE="${2:?asset names file required}" +ASSETS_DIR="${3:-}" + +if [[ ! -f "${SUMS_FILE}" ]]; then + echo "::error::${SUMS_FILE} does not exist - cannot verify release checksum coverage" + exit 1 +fi + +# Everything published alongside the binaries, rather than a list of binary +# suffixes: an unrecognised suffix has to be treated as a binary that needs +# checking, or adding an asset type would silently exempt it +is_release_binary() { + local name="$1" + [[ "${name}" != "SHA256SUMS.txt" && "${name}" != *.sha256 ]] +} + +sha256_of() { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +# "<filename>\t<hash>" for every entry in the checksum file +COVERED="$(mktemp)" +trap 'rm -f "${COVERED}"' EXIT +while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + if [[ "${line}" =~ ^([0-9a-fA-F]{64})[[:space:]]+\*?(.+)$ ]]; then + printf '%s\t%s\n' "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" >> "${COVERED}" + fi +done < "${SUMS_FILE}" + +if [[ ! -s "${COVERED}" ]]; then + echo "::error::${SUMS_FILE} contains no usable checksum entries - it is empty or was not downloaded correctly" + exit 1 +fi + +UNCOVERED=() +MISMATCHED=() +CHECKED=0 +while IFS= read -r asset_name || [[ -n "${asset_name}" ]]; do + asset_name="${asset_name%$'\r'}" + if [[ -z "${asset_name}" ]] || ! is_release_binary "${asset_name}"; then + continue + fi + CHECKED=$((CHECKED + 1)) + + expected="$(awk -F '\t' -v name="${asset_name}" '$1 == name { print $2; exit }' "${COVERED}")" + if [[ -z "${expected}" ]]; then + UNCOVERED+=("${asset_name}") + continue + fi + + if [[ -z "${ASSETS_DIR}" ]]; then + continue + fi + asset_path="$(find "${ASSETS_DIR}" -name "${asset_name}" -type f -print -quit 2> /dev/null || true)" + if [[ -z "${asset_path}" ]]; then + continue + fi + actual="$(sha256_of "${asset_path}")" + if [[ "${actual}" != "${expected}" ]]; then + MISMATCHED+=("${asset_name} (listed ${expected}, actual ${actual})") + fi +done < "${ASSET_NAMES_FILE}" + +if [[ ${#UNCOVERED[@]} -gt 0 ]]; then + echo "::error::${#UNCOVERED[@]} release binary/binaries have no SHA256SUMS.txt entry, so Mudlet's updater cannot install them: ${UNCOVERED[*]}" + echo "Delete the stale asset from the release, or re-run the platform build that produced it, then re-run this job." + echo "SHA256SUMS.txt covers:" + cut -f 1 "${COVERED}" | sort + exit 1 +fi + +if [[ ${#MISMATCHED[@]} -gt 0 ]]; then + echo "::error::${#MISMATCHED[@]} release binary/binaries do not match their SHA256SUMS.txt entry, so Mudlet's updater would reject the download: ${MISMATCHED[*]}" + exit 1 +fi + +if [[ ${CHECKED} -eq 0 ]]; then + echo "::error::No release binaries found to verify - expected at least one" + exit 1 +fi + +echo "All ${CHECKED} release binary/binaries have a SHA256SUMS.txt entry" diff --git a/asan-suppressions.txt b/asan-suppressions.txt index 21256367f..bce68409b 100644 --- a/asan-suppressions.txt +++ b/asan-suppressions.txt @@ -15,6 +15,27 @@ # Qt modules) - a suppression matches if ANY frame does, so those can hide # genuine Mudlet leaks too. # +# A leak: line can only match a frame that still resolves to a module path or a +# symbol name. Leaks reported as "<unknown module>" with no symbols cannot be +# matched by any leak: line: the allocating library was dlclose()d (or the code +# was JITed) before LeakSanitizer runs at exit, so there is no name left to +# compare against. Those have to be handled at their source, not here - see the +# GPU driver note below for the recurring example. +# +# Do not suppress a Qt allocation primitive to quieten a report. By default +# AddressSanitizer captures allocation stacks with the frame-pointer unwinder, +# and the Qt binaries CI installs are built without frame pointers, so the walk +# stops at the first Qt frame. Every leaked QString in the program then reports +# as the same three-frame stack +# malloc / allocateHelper / QArrayData::allocate2 +# with no caller to identify it, and every leaked QByteArray as the allocate1 +# equivalent. Suppressing those symbols hides the whole class - which is what +# `leak:QArrayData::allocate2` did here until it was removed. To get the real +# callers of such a report, re-run with the accurate unwinder: +# ASAN_OPTIONS=fast_unwind_on_malloc=0:malloc_context_size=30 +# It costs roughly 3x the runtime of the test run, which is why CI does not use +# it by default, but the stacks it produces name the leaking function. +# # See: https://github.com/google/sanitizers/wiki/AddressSanitizerLeakSanitizer # ============================================================================== @@ -26,6 +47,34 @@ leak:libcuda.so leak:nvidia leak:_dri.so +# leak:_dri.so above can only match when the driver is still mapped at process +# exit, where LeakSanitizer runs its check. Mesa dlopen()s a DRI driver on first +# GL use and dlclose()s it when the last GL context - e.g. the 3D map view's +# QOpenGLWidget - is torn down. A driver-init allocation that leaks and is then +# unloaded before exit is reported as "<unknown module>" (the .so is gone by the +# time LeakSanitizer runs), so NO leak: line can match it. On the CI leak job +# (Ubuntu 22.04) this recurs as a small (~240 byte) realloc+calloc leak during +# mudlet teardown and flakes across unrelated PRs; it does not reproduce on newer +# Mesa. Do not try to silence it with a leak: line here (there is no name to +# match) - keep the GL context from being created/destroyed under the sanitizer +# test-side instead, as PR #9534 did for the show3dMapView test. +# +# A second un-suppressible flake lives on the same CI job: LeakSanitizer's +# stop-the-world tracer - the helper process that ptrace-attaches at exit to +# scan memory for live pointers - itself segfaults during exit-time teardown, +# apparently racing driver/X cleanup. The job log then ends with +# Tracer caught signal 11: addr=0x... pc=0x... sp=0x... +# ==NNNNN==LeakSanitizer has encountered a fatal error. +# right after "mudlet::~mudlet() INFO - uninstalling translation...", with no +# leak report at all, and the busted summary line goes missing too (LSan's +# Die() skips the stdio flush - the Lua tests themselves passed). Since +# exitcode=1 the job fails. Suppressions filter leak *reports*, and this +# aborts before reporting begins, so nothing in this file can help; it is +# also unrelated to the change under test (the same faulting instruction was +# observed on development-branch runs 26705214438 and 28641460889 and on PR +# run 30744002320, at a rate of roughly 1 in 250-500 leak-job runs). If a +# leak job fails with this signature, just re-run it. + # ============================================================================== # Fontconfig library leaks # These are typically one-time initialization leaks in the font system @@ -68,6 +117,15 @@ leak:libgobject-2.0.so # ============================================================================== leak:libexpat.so +# ============================================================================== +# speech-dispatcher client library leak +# One-time allocation in the speech-dispatcher client (libspeechd) when its +# connection is first opened, reached via QtTextToSpeech's speechd engine. +# Only surfaces when a speech-dispatcher daemon is reachable and something +# touches the tts* API; no Mudlet frames are involved. +# ============================================================================== +leak:libspeechd.so + # ============================================================================== # Qt internal leaks (Qt's integration with system libraries) # These are typically initialization leaks in Qt's platform integration @@ -83,9 +141,6 @@ leak:QFontEngineMultiFontConfig # Qt translation system (one-time initialization) leak:QTranslatorPrivate::do_load -# Qt array data allocation (internal caching) -leak:QArrayData::allocate2 - # ============================================================================== # OpenSSL 3 provider initialization leaks # One-time allocations made when Qt's OpenSSL TLS backend loads the default diff --git a/docs/libmudlet-perf-baseline.md b/docs/libmudlet-perf-baseline.md index 5082c34be..dd5fa4a22 100644 --- a/docs/libmudlet-perf-baseline.md +++ b/docs/libmudlet-perf-baseline.md @@ -78,9 +78,56 @@ METRIC text_mb_per_sec 0.41 METRIC trigger_lines_per_sec 3323.25 METRIC trigger_overhead_ms 1683.64 METRIC peak_rss_kb 1402384 +METRIC defaults_root_triggers ... +METRIC defaults_text_lines_per_sec ... +METRIC defaults_text_best_pass_ms ... +METRIC defaults_peak_rss_kb ... ... ``` +### Two profile configurations, and why the split matters + +The benchmark feeds the corpus under two profile configurations (one slot per +phase, so four profiles are created in all): + +- **`text_*`, `trigger_*`, `peak_rss_kb`** come from a profile with the default + packages suppressed. They describe the pipeline itself, which is what the + libmudlet gate is about. +- **`defaults_*`** comes from a profile carrying the shipped default packages, + the way a new user's profile does. `defaults_root_triggers` records how many + root triggers those packages left armed. + +Keeping them separate means a package regression moves `defaults_*` while the +pipeline numbers stay flat, instead of the two being indistinguishable. + +`defaults_peak_rss_kb` is read after `peak_rss_kb`, and VmHWM is process-wide +and monotonic, so the two are not independent: read `defaults_peak_rss_kb` as +the whole-run high-water mark and its **excess** over `peak_rss_kb` as what the +default packages cost. + +**Run the benchmark under a fresh `HOME` and `XDG_CONFIG_HOME`.** Part of what a +new profile gets - the starter UI - is gated on +`mudlet::experiencedMudletPlayer()`, which answers from the machine's own Mudlet +history, so on a developer machine the `defaults_*` profile would quietly not +get it and `defaults_text_lines_per_sec` would become a second copy of +`text_lines_per_sec`. `benchDefaultPackages` checks the starter UI is installed +and fails the run rather than report that, and `defaults_root_triggers` records +how many root triggers the packages between them armed: + +```bash +scratch=$(mktemp -d) +HOME=$scratch XDG_CONFIG_HOME=$scratch/.config QT_QPA_PLATFORM=offscreen \ + ./test/functional_tests/PipelineBenchmark +``` + +Comparing a build from before this split against one from after it will abort +with "gated metric defaults_text_lines_per_sec is missing from the before run". +That is the script working as intended - the two harnesses are not comparable. +Pass `--gate text_lines_per_sec,trigger_lines_per_sec` to compare across the +change, bearing in mind the older run's `text_lines_per_sec` includes whichever +default packages that machine's `experiencedMudletPlayer()` allowed it - the +older harness had no guard - while the newer one includes none. + ## The before/after workflow (the 10% gate) The gate is a **relative, same-machine** comparison. Never compare numbers taken @@ -115,8 +162,9 @@ on different hardware, or from an ASan build against a release build - only ever test/compare-perf-baseline.py before.txt after.txt ``` -`compare-perf-baseline.py` gates on `text_lines_per_sec` and -`trigger_lines_per_sec` by default (the two throughput numbers); every other +`compare-perf-baseline.py` gates on `text_lines_per_sec`, +`trigger_lines_per_sec` and `defaults_text_lines_per_sec` by default (pipeline +throughput, plus the shipped default packages on the same corpus); every other metric is reported for context. It exits non-zero if any gated metric regressed by more than the threshold, so it drops straight into a script or CI step. Tune it with `--threshold 0.10` and `--gate metric,metric,...`. A `--threshold` of 1 @@ -153,7 +201,7 @@ ideally over a couple of runs or with a slightly relaxed threshold. `PipelineBenchmark` deliberately stops at the core pipeline: it runs offscreen and never paints a widget, so it does not measure the on-screen rendering and echo path. That path needs a live window and is covered by the **Stressinator -display benchmark** (`src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.xml`), +display benchmark** (`src/packages/StressinatorDisplayBench/`), pre-installed into the `mudlet.org` self-test profile. - Interactively, in a running profile, type `stresstest 100000` to feed that many @@ -176,6 +224,11 @@ shape and rough ratios of the output**. Do not treat any figure here as a target or a committed baseline - capture your own "before" on the machine you are testing on and compare against that. +It predates the two-profile split above, so its `text_lines_per_sec` includes +the default packages and there are no `defaults_*` rows. Read the ratios between +the `text_*` and `trigger_*` rows; do not compare any figure here against a +current run. + | Metric | Example value | | --- | --- | | `text_lines_per_sec` | ~4,270 | diff --git a/src/ActionUnit.cpp b/src/ActionUnit.cpp index c623cbcdf..90b452f41 100644 --- a/src/ActionUnit.cpp +++ b/src/ActionUnit.cpp @@ -30,6 +30,8 @@ #include "TToolBar.h" #include "mudlet.h" +#include <QSet> + #include <functional> /* We need an explicit constructor in this file as the Host class is forward @@ -75,12 +77,48 @@ void ActionUnit::uninstall(const QString& packageName) uninstallList.append(rootAction); } } - for (auto& action : uninstallList) { - delete action; + // Re-entrant uninstall (#9337): a button's own script (e.g. a package + // auto-updater calling uninstallPackage()) is removing its package while + // TAction::execute() is still on the call stack for that button. Deleting + // now would be a use-after-free, so defer to doCleanup() at depth 0. + // Deactivating stops the buttons from firing again in the meantime. + if (mProcessingDepth > 0) { + for (auto action : uninstallList) { + action->setIsActive(false); + } + return; + } + // Not inside a button script - delete now. Route through doCleanup() rather + // than an inline loop so the same seen-set guards against a double free if a + // re-entrant uninstall of the same package queued any action twice. + doCleanup(); +} + +void ActionUnit::doCleanup() +{ + if (mProcessingDepth > 0) { + return; + } + // Flush the deletes uninstall() deferred (#9337). uninstallList is ordered + // children-before-parents and each ~Tree unlinks from its parent, so deleting + // children first empties the parent's child list (no double free); the seen + // set guards a node queued twice by re-entrant uninstalls. + QSet<TAction*> deletedActions; + for (auto action : uninstallList) { + if (!deletedActions.contains(action)) { + deletedActions.insert(action); + delete action; + } } uninstallList.clear(); } +void ActionUnit::endProcessing() +{ + --mProcessingDepth; + Q_ASSERT(mProcessingDepth >= 0); +} + void ActionUnit::compileAll() { for (auto action : mActionRootNodeList) { @@ -92,13 +130,11 @@ void ActionUnit::compileAll() TAction* ActionUnit::findAction(const QString& name) { - //QMap<int, TAction *> mActionMap; - QMapIterator<int, TAction*> it(mActionMap); while (it.hasNext()) { it.next(); if (it.value()->getName() == name) { - qDebug() << it.value()->getName(); + // qDebug().nospace().noquote() << "ActionUnit::findAction(const QString&) INFO - found: \"" << it.value()->getName() << "\"."; TAction* pT = it.value(); return pT; } @@ -531,8 +567,9 @@ void ActionUnit::constructToolbar(TAction* pAction, TToolBar* pToolBar) pToolBar->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); if (pAction->mLocation == 4) { if (pAction->mToolbarLastDockArea == Qt::NoDockWidgetArea) { - qWarning() << "ActionUnit::constructToolbar(TAction*, TToolBar*) WARNING - no last dockarea was set for the TAction (\"" << pAction->getName() - << "\"), for this toolbar forcing it to the Left one!"; + qWarning().nospace().noquote() << "ActionUnit::constructToolbar(TAction*, TToolBar*) WARNING - no last dockarea was set for the TAction (\"" + << pAction->getName() + << "\"), for this toolbar forcing it to the Left one!"; } mudlet::self()->addDockWidget(((pAction->mToolbarLastDockArea != Qt::NoDockWidgetArea) ? pAction->mToolbarLastDockArea : Qt::LeftDockWidgetArea), pToolBar); if (pAction->mToolbarLastFloatingState) { diff --git a/src/ActionUnit.h b/src/ActionUnit.h index 8328e1b4c..435894fb6 100644 --- a/src/ActionUnit.h +++ b/src/ActionUnit.h @@ -67,6 +67,15 @@ public: int getNewID(); void uninstall(const QString&); void _uninstall(TAction* pChild, const QString& packageName); + void doCleanup(); + void beginProcessing() { ++mProcessingDepth; } + // Only decrements the depth - deliberately no doCleanup() here: that would + // delete `this` (and other deferred actions) while a caller of + // TAction::execute() may still hold the pointer. Deferred deletes are + // flushed once no button script is executing - by the dispatchers right + // after execute() returns and by Host's catch-all doCleanup() calls. + void endProcessing(); + int processingDepth() const { return mProcessingDepth; } void updateAllToolbars(); std::list<QPointer<TToolBar>> getToolBarList() { return mToolBarList; } TAction* getHeadAction(TToolBar*); @@ -92,6 +101,9 @@ private: QMap<int, TAction*> mActionMap; std::list<TAction*> mActionRootNodeList; int mMaxID = 0; + // > 0 whilst a TAction::execute() is on the call stack; uninstall() and + // doCleanup() must not delete actions then - see ActionUnit::uninstall(): + int mProcessingDepth = 0; bool mModuleMember = false; std::list<QPointer<TToolBar>> mToolBarList; std::list<QPointer<TEasyButtonBar>> mEasyButtonBarList; diff --git a/src/AliasUnit.cpp b/src/AliasUnit.cpp index 4fc960117..3ef4aeb7a 100644 --- a/src/AliasUnit.cpp +++ b/src/AliasUnit.cpp @@ -26,6 +26,8 @@ #include "Host.h" #include "TAlias.h" +#include <QScopeGuard> + #include <functional> /* We need an explicit constructor in this file as the Host class is forward @@ -89,6 +91,9 @@ void AliasUnit::uninstall(const QString& packageName) return; } for (auto& alias : uninstallList) { + // in case the alias was also queued for the markCleanup()/doCleanup() + // path - deleting it here would otherwise leave a dangling pointer there: + mCleanupSet.remove(alias); delete alias; } uninstallList.clear(); @@ -177,11 +182,12 @@ void AliasUnit::removeAliasRootNode(TAlias* pT) if (!pT) { return; } - if (!pT->isTemporary()) { - mLookupTable.remove(pT->mName, pT); - } else { - mLookupTable.remove(pT->getName()); - } + // Names are not unique - the lookup table is a QMultiMap - so drop this one + // alias' entry rather than every entry filed under the name. The + // single-argument remove() used to be taken for temporary aliases, which + // evicted live same-named aliases and left them unreachable by name for the + // rest of the session + mLookupTable.remove(pT->getName(), pT); mAliasMap.remove(pT->getID()); mAliasRootNodeList.remove(pT); } @@ -257,11 +263,8 @@ void AliasUnit::removeAlias(TAlias* pT) if (!pT) { return; } - if (!pT->isTemporary()) { - mLookupTable.remove(pT->mName, pT); - } else { - mLookupTable.remove(pT->getName()); - } + // see removeAliasRootNode(): one entry, not every same-named one + mLookupTable.remove(pT->getName(), pT); mAliasMap.remove(pT->getID()); } @@ -281,6 +284,13 @@ bool AliasUnit::processDataStream(const QString& data) auto copyOfNodeList = mAliasRootNodeList; mProcessingDepth++; + const auto processingGuard = qScopeGuard([this] { + mProcessingDepth--; + Q_ASSERT(mProcessingDepth >= 0); + if (mProcessingDepth == 0) { + doCleanup(); + } + }); for (auto alias : copyOfNodeList) { if (!alias->isActive() && !alias->shouldBeActive()) { @@ -292,12 +302,6 @@ bool AliasUnit::processDataStream(const QString& data) } } - mProcessingDepth--; - Q_ASSERT(mProcessingDepth >= 0); - if (mProcessingDepth == 0) { - doCleanup(); - } - // the idea to get "command" after alias processing is finished and send its value // was too difficult for users because if multiple alias change the value of command it becomes too difficult to handle for many users // it's easier if we simply intercepts the command and hand responsibility for @@ -380,15 +384,27 @@ bool AliasUnit::disableAlias(const QString& name) bool AliasUnit::killAlias(const QString& name) { for (auto alias : mAliasRootNodeList) { - if (alias->getName() == name) { - // only temporary Aliases can be killed - if (!alias->isTemporary()) { - return false; - } - alias->setIsActive(false); - markCleanup(alias); - return true; + if (alias->getName() != name) { + continue; } + // Names are not unique, so keep looking rather than give up on the first + // same-named alias that cannot be killed - a permanent alias loaded from + // the profile precedes this session's temporaries in this list, and + // reporting a failure over it would strand a killable alias + if (!alias->isTemporary()) { + // only temporary Aliases can be killed + continue; + } + // An already killed alias is only unlinked from this list once doCleanup() + // gets to free it, which cannot happen while an alias script is on the + // call stack - so until then it is still findable by name. Killing it a + // second time achieves nothing: + if (mCleanupSet.contains(alias)) { + continue; + } + alias->setIsActive(false); + markCleanup(alias); + return true; } return false; } @@ -433,17 +449,22 @@ void AliasUnit::doCleanup() return; } + QSet<TAlias*> deletedAliases; QMutableSetIterator<TAlias*> itAlias(mCleanupSet); while (itAlias.hasNext()) { auto pAlias = itAlias.next(); itAlias.remove(); + deletedAliases.insert(pAlias); delete pAlias; } // Flush the deletes uninstall() deferred (#9337). uninstallList is ordered // children-before-parents and each ~Tree unlinks from its parent, so deleting // children first empties the parent's child list (no double free); the seen - // set guards a node queued twice by re-entrant uninstalls. - QSet<TAlias*> deletedAliases; + // set guards a node queued twice by re-entrant uninstalls and is shared with + // the mCleanupSet loop above so an object that ended up in both containers is + // freed once. It matches on pointer identity only: a node freed indirectly, as + // a child of a queued parent, is not in the set (not reachable today - only + // temporary root nodes are ever queued, and those have no children). for (auto alias : uninstallList) { if (!deletedAliases.contains(alias)) { deletedAliases.insert(alias); diff --git a/src/AliasUnit.h b/src/AliasUnit.h index 6f5b4df15..c4e1ca006 100644 --- a/src/AliasUnit.h +++ b/src/AliasUnit.h @@ -67,6 +67,7 @@ public: int getNewID(); void markCleanup(TAlias* pT); void doCleanup(); + int processingDepth() const { return mProcessingDepth; } QMultiMap<QString, TAlias*> mLookupTable; QSet<TAlias*> mCleanupSet; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0abf192b6..ecf5b394c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -87,6 +87,7 @@ set(mudlet_SRCS EditorMoveItemCommand.cpp EditorToggleActiveCommand.cpp EditorUndoStack.cpp + EventLoopPump.cpp exitstreewidget.cpp FileOpenHandler.cpp FontManager.cpp @@ -98,6 +99,7 @@ set(mudlet_SRCS KeyUnit.cpp LabelInteractionHandler.cpp LuaInterface.cpp + LuaLiteral.cpp main.cpp mapInfoContributorManager.cpp MiddleMousePanHandler.cpp @@ -211,6 +213,7 @@ set(mudlet_SRCS TTrigger.cpp TUiTour.cpp TVar.cpp + UntrustedText.cpp VarUnit.cpp WideComboBox.cpp XMLexport.cpp @@ -315,6 +318,7 @@ set(mudlet_HDRS EditorToggleActiveCommand.h EditorUndoStack.h enums.h + EventLoopPump.h exitstreewidget.h FileOpenHandler.h FontManager.h @@ -326,6 +330,7 @@ set(mudlet_HDRS KeyUnit.h LabelInteractionHandler.h LuaInterface.h + LuaLiteral.h mapInfoContributorManager.h MiddleMousePanHandler.h MMCP.h @@ -444,6 +449,7 @@ set(mudlet_HDRS TTrigger.h TUiTour.h TVar.h + UntrustedText.h utils.h VarUnit.h widechar_width.h @@ -766,7 +772,7 @@ endif(USE_UPDATER) # Embed the LeakSanitizer suppression list into the binary so that # sanitizer-enabled builds (PTBs, testing AppImages) filter third-party noise # out of leak reports without needing LSAN_OPTIONS set at runtime; picked up -# by __lsan_default_suppressions() in main.cpp +# by __lsan_default_suppressions() in LsanHooks.cpp set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mudlet_SOURCE_DIR}/asan-suppressions.txt") file(READ "${mudlet_SOURCE_DIR}/asan-suppressions.txt" MUDLET_LSAN_SUPPRESSIONS_CONTENT) string(REPLACE "\\" "\\\\" MUDLET_LSAN_SUPPRESSIONS_CONTENT "${MUDLET_LSAN_SUPPRESSIONS_CONTENT}") @@ -775,6 +781,15 @@ string(REPLACE "\n" "\\n" MUDLET_LSAN_SUPPRESSIONS_CONTENT "${MUDLET_LSAN_SUPPRE configure_file("${CMAKE_CURRENT_SOURCE_DIR}/LsanSuppressions.h.in" "${CMAKE_CURRENT_BINARY_DIR}/LsanSuppressions.h" @ONLY) target_include_directories(${LIB_MUDLET_TARGET} PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +# LeakSanitizer resolves its hooks as weak symbols and a weak reference never +# pulls a member out of a static archive, so they cannot live in +# ${LIB_MUDLET_TARGET}: binaries that bring their own main() (the Qt Test ones, +# via QTEST_MAIN) would link no suppressions at all. An OBJECT library puts the +# definitions on the link line of every target that links it. +add_library(mudlet_lsan_hooks OBJECT LsanHooks.cpp) +target_include_directories(mudlet_lsan_hooks PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) +set_target_properties(mudlet_lsan_hooks PROPERTIES AUTOMOC OFF POSITION_INDEPENDENT_CODE ON) + if(USE_3DMAPPER) target_link_libraries(${LIB_MUDLET_TARGET} OpenGL::GLU) target_link_libraries(${LIB_MUDLET_TARGET} assimp::assimp) @@ -790,7 +805,7 @@ target_compile_options(${LIB_MUDLET_TARGET} PUBLIC -Wno-deprecated) add_executable(${EXE_MUDLET_TARGET} emptyFile.cpp) set_target_properties(${EXE_MUDLET_TARGET} PROPERTIES OUTPUT_NAME ${EXE_MUDLET_NAME}) -target_link_libraries(${EXE_MUDLET_TARGET} ${LIB_MUDLET_TARGET}) +target_link_libraries(${EXE_MUDLET_TARGET} ${LIB_MUDLET_TARGET} mudlet_lsan_hooks) # Enable symbol exports from the main executable so that dynamically loaded Lua modules # can find and use Lua API symbols (like lua_gettop) at runtime. Without this, modules @@ -887,8 +902,11 @@ if(APPLE) endif() endif() - file(GLOB_RECURSE MUDLET_LUA_FILES LIST_DIRECTORIES true "mudlet-lua/*") - file(GLOB_RECURSE LUA_TRANSLATIONS LIST_DIRECTORIES true "../translations/lua/*") + # LIST_DIRECTORIES must stay false: globbing directories too makes CMake emit a + # copy_directory rule that races the per-file bundling rules under Ninja, + # failing with "Error copying ...: No such file or directory" + file(GLOB_RECURSE MUDLET_LUA_FILES LIST_DIRECTORIES false "mudlet-lua/*") + file(GLOB_RECURSE LUA_TRANSLATIONS LIST_DIRECTORIES false "../translations/lua/*") file(GLOB DIC_FILES "*.dic") file(GLOB AFF_FILES "*.aff") target_sources(${EXE_MUDLET_TARGET} PUBLIC ${MUDLET_LUA_FILES} ${LUA_TRANSLATIONS} ${DIC_FILES} ${AFF_FILES} ${ICON_FILE}) @@ -921,7 +939,12 @@ if(UNIX AND NOT APPLE) DIRECTORY "mudlet-lua/tests" DESTINATION "share/mudlet" FILES_MATCHING - PATTERN "*.lua" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ) + PATTERN "*.lua" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ + # the package specs install these fixtures, so they have to travel with them + PATTERN "*.mpackage" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ + WORLD_READ + PATTERN "*.xml" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ + PATTERN "*.txt" PERMISSIONS OWNER_READ OWNER_WRITE GROUP_READ WORLD_READ) install( DIRECTORY "../3rdparty/lcf" DESTINATION "share/mudlet/lua" diff --git a/src/EAction.cpp b/src/EAction.cpp index 265303974..fa0a64ec4 100644 --- a/src/EAction.cpp +++ b/src/EAction.cpp @@ -42,4 +42,9 @@ void EAction::slot_execute(bool checked) { mpHost->getActionUnit()->getAction(mID)->mButtonState = checked; mpHost->getActionUnit()->getAction(mID)->execute(); + // Deliberately no doCleanup() here: a menu item runs nested inside a bar's + // slot_pressed() (TEasyButtonBar::showMenu() spins a modal event loop), which + // dereferences its own button after this returns - flushing here could free + // an action that ancestor frame still holds. The deferred deletes are cleared + // by that ancestor bar dispatcher and by Host's catch-all doCleanup() calls. } diff --git a/src/EditorItemXMLHelpers.cpp b/src/EditorItemXMLHelpers.cpp index 41d0994ff..1a677341f 100644 --- a/src/EditorItemXMLHelpers.cpp +++ b/src/EditorItemXMLHelpers.cpp @@ -1179,8 +1179,8 @@ TAction* importActionFromXML(const QString& xmlSnapshot, TAction* pParent, Host* // Read attributes pA->setIsActive(QString::fromStdString(actionNode.attribute("isActive").value()) == "yes"); pA->setIsFolder(QString::fromStdString(actionNode.attribute("isFolder").value()) == "yes"); - pA->mIsPushDownButton = QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes"; - pA->mButtonFlat = QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes"; + pA->setIsPushDownButton(QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes"); + pA->setButtonFlat(QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes"); pA->mUseCustomLayout = QString::fromStdString(actionNode.attribute("useCustomLayout").value()) == "yes"; // Read child elements @@ -1207,13 +1207,15 @@ TAction* importActionFromXML(const QString& xmlSnapshot, TAction* pParent, Host* } else if (nodeName == "location") { pA->mLocation = nodeValue.toInt(); } else if (nodeName == "buttonRotation") { - pA->mButtonRotation = nodeValue.toInt(); + pA->setButtonRotation(nodeValue.toInt()); } else if (nodeName == "sizeX") { - pA->mSizeX = nodeValue.toInt(); + pA->setSizeX(nodeValue.toInt()); } else if (nodeName == "sizeY") { - pA->mSizeY = nodeValue.toInt(); + pA->setSizeY(nodeValue.toInt()); } else if (nodeName == "buttonColumn") { - pA->mButtonColumns = nodeValue.toInt(); + pA->setButtonColumns(nodeValue.toInt()); + } else if (nodeName == "buttonFillerOffset") { + pA->setButtonFillerOffset(nodeValue.toInt()); } else if (nodeName == "buttonColor") { // Deprecated - skip this element } else if (nodeName == "posX") { @@ -1280,8 +1282,8 @@ bool updateActionFromXML(TAction* pA, const QString& xmlSnapshot) // Update attributes pA->setIsActive(QString::fromStdString(actionNode.attribute("isActive").value()) == "yes"); pA->setIsFolder(QString::fromStdString(actionNode.attribute("isFolder").value()) == "yes"); - pA->mIsPushDownButton = QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes"; - pA->mButtonFlat = QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes"; + pA->setIsPushDownButton(QString::fromStdString(actionNode.attribute("isPushButton").value()) == "yes"); + pA->setButtonFlat(QString::fromStdString(actionNode.attribute("isFlatButton").value()) == "yes"); pA->mUseCustomLayout = QString::fromStdString(actionNode.attribute("useCustomLayout").value()) == "yes"; // Update child elements @@ -1306,13 +1308,15 @@ bool updateActionFromXML(TAction* pA, const QString& xmlSnapshot) } else if (nodeName == "location") { pA->mLocation = nodeValue.toInt(); } else if (nodeName == "buttonRotation") { - pA->mButtonRotation = nodeValue.toInt(); + pA->setButtonRotation(nodeValue.toInt()); } else if (nodeName == "sizeX") { - pA->mSizeX = nodeValue.toInt(); + pA->setSizeX(nodeValue.toInt()); } else if (nodeName == "sizeY") { - pA->mSizeY = nodeValue.toInt(); + pA->setSizeY(nodeValue.toInt()); } else if (nodeName == "buttonColumn") { - pA->mButtonColumns = nodeValue.toInt(); + pA->setButtonColumns(nodeValue.toInt()); + } else if (nodeName == "buttonFillerOffset") { + pA->setButtonFillerOffset(nodeValue.toInt()); } else if (nodeName == "buttonColor") { // Deprecated - skip this element } else if (nodeName == "posX") { diff --git a/src/EditorModifyPropertyCommand.cpp b/src/EditorModifyPropertyCommand.cpp index a31e61736..1ed908c60 100644 --- a/src/EditorModifyPropertyCommand.cpp +++ b/src/EditorModifyPropertyCommand.cpp @@ -281,7 +281,7 @@ QString EditorModifyPropertyCommand::generateText(EditorViewType viewType, const return QObject::tr("modify key \"%1\"").arg(itemName); case EditorViewType::cmActionView: //: Undo/redo menu text for modifying a button's properties - return QObject::tr("modify button \"%1\"").arg(itemName); + return QObject::tr("modify button/menu/toolbar \"%1\"").arg(itemName); default: //: Undo/redo menu text for modifying an unknown item's properties return QObject::tr("modify item \"%1\"").arg(itemName); diff --git a/src/EventLoopPump.cpp b/src/EventLoopPump.cpp new file mode 100644 index 000000000..886f191ff --- /dev/null +++ b/src/EventLoopPump.cpp @@ -0,0 +1,60 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "EventLoopPump.h" + +#include <QCoreApplication> +#include <QDeadlineTimer> +#include <QDebug> +#include <QEventLoop> +#include <QThread> + +// A nested QEventLoop::exec() cannot be used here. exec() sets +// QEventLoop::EventLoopExec, which QCocoaEventDispatcher answers by re-entering +// -[NSApplication run] and leaving Qt's timers to the platform run loop; nested +// inside a Qt timer callback that run loop never wakes it again, so not even +// the wait's own timeout fires (issue #9670). processEvents() instead takes the +// branch that drives the dispatcher's processTimers() on every pass. +bool EventLoopPump::pumpFor(const int timeoutMs, const std::function<bool()>& stopCondition) +{ + // processEvents() returns silently without a dispatcher, which would make + // this a plain sleep that then reports a timeout as though it had waited. + if (!QThread::currentThread()->eventDispatcher()) { + qWarning() << "EventLoopPump::pumpFor() called with no event dispatcher on this thread, no events can be delivered"; + return false; + } + + QDeadlineTimer deadline(qMax(timeoutMs, 0)); + if (stopCondition && stopCondition()) { + return true; + } + + while (true) { + QCoreApplication::processEvents(QEventLoop::AllEvents); + if (stopCondition && stopCondition()) { + return true; + } + if (deadline.hasExpired()) { + return false; + } + // A pass returns as soon as nothing is pending, so without this the loop + // spins a core flat for the whole timeout. + QThread::msleep(1); + } +} diff --git a/src/EventLoopPump.h b/src/EventLoopPump.h new file mode 100644 index 000000000..be305c9b4 --- /dev/null +++ b/src/EventLoopPump.h @@ -0,0 +1,34 @@ +#ifndef MUDLET_EVENTLOOPPUMP_H +#define MUDLET_EVENTLOOPPUMP_H + +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <functional> + +class EventLoopPump +{ +public: + // Delivers Qt events for up to timeoutMs. True means stopCondition became + // true, false means the time ran out. Not a nested QEventLoop::exec(): see + // EventLoopPump.cpp for why exec() cannot be used here. + [[nodiscard]] static bool pumpFor(int timeoutMs, const std::function<bool()>& stopCondition = {}); +}; + +#endif // MUDLET_EVENTLOOPPUMP_H diff --git a/src/GMCPAuthenticator.cpp b/src/GMCPAuthenticator.cpp index eb63c6043..9297059de 100644 --- a/src/GMCPAuthenticator.cpp +++ b/src/GMCPAuthenticator.cpp @@ -24,6 +24,7 @@ #include "CredentialManager.h" #include "OAuthClientFlow.h" #include "SecureStringUtils.h" +#include "UntrustedText.h" #include "ctelnet.h" #include "mudlet.h" #include <QAccessible> @@ -70,6 +71,25 @@ GMCPAuthenticator::GMCPAuthenticator(Host* pHost) : mpHost(pHost) { resetPerConnectionState(); + // mTelnet is declared before this authenticator in Host, so it is already constructed here. + QObject::connect(&pHost->mTelnet, &cTelnet::signal_connected, pHost, [this]() { + resetForNewConnection(); + }); + QObject::connect(&pHost->mTelnet, &cTelnet::signal_disconnected, pHost, [this]() { + resetForNewConnection(); + }); +} + +void GMCPAuthenticator::resetForNewConnection() +{ + // Anything still in flight belongs to the connection that started it: a deferred attempt would + // cancel the new connection's login timers and sign in with capabilities the new server never + // advertised, and a credential read landing there would replay a token nobody asked for. + ++mSignInScheduleGeneration; + ++mAuthAttemptGeneration; + mSignInAttemptPending = false; + mLastSignInAttempt.invalidate(); + mUnpromptedBrowserOpenAvailable = true; } void GMCPAuthenticator::resetPerConnectionState() @@ -224,8 +244,19 @@ void GMCPAuthenticator::sendCredentials(bool interactiveHandoff) #endif } -void GMCPAuthenticator::sendReconnect(const QString& account, QString token) +bool GMCPAuthenticator::sendReconnect(const QString& account, QString token) { + // The token signs in to the account without the player's password, and is replayed on whatever + // transport is live now rather than the one it was earned on: in the clear that hands the account + // to anyone on the path. + if (!mpHost->mTelnet.currentlySecure()) { + SecureStringUtils::secureStringClear(token); + qWarning().noquote() << "GMCP Char.Login.Reconnect - refusing to replay the saved sign-in token over an unencrypted connection."; + //: Shown when a saved password-less sign-in cannot be reused because this connection to the game is not encrypted. + mpHost->postMessage(tr("[ WARN ] - Not using your saved sign-in because this connection is not encrypted; please sign in again.")); + return false; + } + QJsonObject payload; payload[qsl("account")] = account; payload[qsl("token")] = token; @@ -263,6 +294,7 @@ void GMCPAuthenticator::sendReconnect(const QString& account, QString token) #if defined(DEBUG_GMCP_AUTHENTICATION) qDebug() << "Sent GMCP reconnect for account:" << account; #endif + return true; } void GMCPAuthenticator::storeReconnectToken(const QString& account, QString token) @@ -421,26 +453,34 @@ void GMCPAuthenticator::handleAuthUrl(const QString& packageMessage, const QStri return; } - // This message can arrive unsolicited, so only auto-open the browser when the player has sent input - // this connection (evidence they acted on the game's sign-in screen); otherwise offer the link to - // open deliberately, so a misbehaving server cannot pop a browser at an idle player. - if (mpHost->userSentInputThisConnection()) { - openSignInUrl(parsedUrl, provider); + // Char.Login.URL can arrive at any moment in a session, so it only auto-opens against user input. + offerOrOpenSignInUrl(parsedUrl, provider, false); +} + +void GMCPAuthenticator::offerOrOpenSignInUrl(const QUrl& url, const QString& provider, bool answersTheGamesSignInOffer) +{ + const bool mayOpen = mpHost->userSentInputThisConnection() || (answersTheGamesSignInOffer && mUnpromptedBrowserOpenAvailable); + if (mayOpen) { + if (openSignInUrl(url, provider)) { + mUnpromptedBrowserOpenAvailable = false; + mpHost->setUserSentInputThisConnection(false); + } return; } //: %1 is the sign-in web address the user should open in their browser to sign in. - mpHost->postMessage(tr("[ INFO ] - To sign in, open this link in your browser: %1").arg(url)); + mpHost->postMessage(tr("[ INFO ] - To sign in, open this link in your browser: %1").arg(UntrustedText::forTarget(url.toString()))); } -void GMCPAuthenticator::openSignInUrl(const QUrl& url, const QString& provider) +bool GMCPAuthenticator::openSignInUrl(const QUrl& url, const QString& provider) { if (!QDesktopServices::openUrl(url)) { //: %1 is the sign-in web address the user should open manually in their browser. - mpHost->postMessage(tr("[ WARN ] - Could not open your browser. Open this link manually to sign in: %1").arg(url.toString())); - return; + mpHost->postMessage(tr("[ WARN ] - Could not open your browser. Open this link manually to sign in: %1").arg(UntrustedText::forTarget(url.toString()))); + return false; } announceBrowserHandoff(provider); + return true; } void GMCPAuthenticator::announceBrowserHandoff(const QString& provider) @@ -465,15 +505,12 @@ void GMCPAuthenticator::startClientDrivenOAuth() // Parented to the Host so the flow (and its loopback listener) cannot outlive the profile. mpOAuthFlow = new OAuthClientFlow(mpHost); - QObject::connect(mpOAuthFlow, &OAuthClientFlow::authorizationCaptured, mpHost, [this](const QString& code, const QString& codeVerifier, const QString& redirectUri) { - sendAuthCode(code, codeVerifier, redirectUri); + QObject::connect(mpOAuthFlow, &OAuthClientFlow::authorizationCaptured, mpHost, [this](const QString& code, const QString& codeVerifier, const QString& redirectUri, const QString& nonce) { + sendAuthCode(code, codeVerifier, redirectUri, nonce); }); - QObject::connect(mpOAuthFlow, &OAuthClientFlow::browserOpened, mpHost, [this]() { - announceBrowserHandoff(QString()); - }); - QObject::connect(mpOAuthFlow, &OAuthClientFlow::browserOpenFailed, mpHost, [this](const QString& url) { - //: %1 is the sign-in web address the user should open manually in their browser. - mpHost->postMessage(tr("[ WARN ] - Could not open your browser. Open this link manually to sign in: %1").arg(url)); + // This flow only starts from the game's own sign-in offer, so connecting is itself the request. + QObject::connect(mpOAuthFlow, &OAuthClientFlow::authorizationUrlReady, mpHost, [this](const QUrl& authorizationUrl) { + offerOrOpenSignInUrl(authorizationUrl, QString(), true); }); QObject::connect(mpOAuthFlow, &OAuthClientFlow::flowFailed, mpHost, [this](const QString& logDetail) { qWarning().noquote() << "GMCP Char.Login client-driven OAuth failed:" << logDetail; @@ -494,7 +531,7 @@ void GMCPAuthenticator::cancelClientDrivenOAuth() } } -void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri) +void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri, QString nonce) { // The spec forbids Char.Login.AuthCode on a cleartext connection: the authorization code and PKCE // verifier together would let an eavesdropper redeem the code at the provider. The flow only starts @@ -502,6 +539,7 @@ void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const Q if (!mpHost->mTelnet.currentlySecure()) { SecureStringUtils::secureStringClear(code); SecureStringUtils::secureStringClear(codeVerifier); + SecureStringUtils::secureStringClear(nonce); qWarning().noquote() << "GMCP Char.Login.AuthCode - refusing to send the authorization code over an unencrypted connection."; mpHost->mTelnet.setDontReconnect(true); // Tear down the in-flight flow and its loopback listener immediately: the sign-in is doomed, so @@ -516,6 +554,13 @@ void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const Q payload[qsl("code")] = code; payload[qsl("code_verifier")] = codeVerifier; payload[qsl("redirect_uri")] = redirectUri; + // The server, not this client, receives and validates the ID token, so it is the only party that + // can check the nonce claim - and only against the value chosen here. + if (!nonce.isEmpty()) { + payload[qsl("nonce")] = nonce; + } else if (mOAuthNonceRequired) { + qWarning().noquote() << "GMCP Char.Login.AuthCode - the server asked for a nonce but none was generated, so it cannot verify the ID token's nonce claim."; + } payload[qsl("version")] = mNegotiatedVersion; QByteArray json = QJsonDocument(payload).toJson(QJsonDocument::Compact); QString gmcpMessage = QString::fromUtf8(json); @@ -543,6 +588,7 @@ void GMCPAuthenticator::sendAuthCode(QString code, QString codeVerifier, const Q // payloads, and the assembled telnet frame. SecureStringUtils::secureStringClear(code); SecureStringUtils::secureStringClear(codeVerifier); + SecureStringUtils::secureStringClear(nonce); SecureStringUtils::secureStringClear(gmcpMessage); SecureStringUtils::secureStdStringClear(plaintext); SecureStringUtils::secureStdStringClear(encoded); @@ -625,91 +671,149 @@ void GMCPAuthenticator::retryOrDropRejectedToken() QPointer<Host> safeHost = mpHost; QPointer<CredentialManager> credentialManager = new CredentialManager(); const auto attemptGeneration = mAuthAttemptGeneration; - credentialManager->retrievePassword(mpHost->getName(), qsl("reconnect"), [this, safeHost, credentialManager, attemptGeneration](bool success, QString value, const QString& errorMessage) { - if (credentialManager) { - credentialManager->deleteLater(); - } - if (!safeHost) { - return; - } - if (attemptGeneration != mAuthAttemptGeneration) { - return; - } - // A read failure (locked/denied/timed-out keychain) is not "no token": log it so a dropped - // token that was actually unreadable can be told apart from a genuinely dead one. The recovery - // below still proceeds - a fresh sign-in is the safe outcome either way. - if (!success) { - qWarning().noquote() << "GMCP Char.Login - could not read the stored sign-in while recovering a rejected token:" << errorMessage; - } + // Capture the per-connection facts this recovery needs before awaiting the keychain: a + // Char.Login.Default arriving while the read is in flight resets mConn, and the callback would then + // have no hash to recognise the rejected token by and no account or provider to keep a resume hint + // from - silently downgrading the drop below into discarding the whole entry. + const auto sentTokenHash = mConn.sentReconnectTokenHash; + const auto retriedRotatedToken = mConn.retriedRotatedToken; + const auto reconnectAccount = mConn.reconnectAccount; + const auto accountProvider = mConn.accountProvider; + // Latch synchronously rather than when the read returns; see mReconnectRejected's declaration. + mReconnectRejected = true; + credentialManager->retrievePassword( + mpHost->getName(), + qsl("reconnect"), + [this, safeHost, credentialManager, attemptGeneration, sentTokenHash, retriedRotatedToken, reconnectAccount, accountProvider](bool success, QString value, const QString& errorMessage) { + if (credentialManager) { + credentialManager->deleteLater(); + } + if (!safeHost) { + return; + } + // A newer sign-in attempt began while the read was in flight; it owns the connection now, so + // this recovery must not send anything or reconnect. It may still rewrite the stored entry, + // but only on positive evidence that the rejected token is the one stored - see the drop below. + const bool superseded = (attemptGeneration != mAuthAttemptGeneration); + // A read failure (locked/denied/timed-out keychain) is not "no token": log it so a dropped + // token that was actually unreadable can be told apart from a genuinely dead one. The recovery + // below still proceeds - a fresh sign-in is the safe outcome either way. + if (!success) { + qWarning().noquote() << "GMCP Char.Login - could not read the stored sign-in while recovering a rejected token:" << errorMessage; + } - // Shared-store rotation check: if the stored token no longer hashes to what this connection - // sent, another running instance rotated it (single-use) - replay the fresh one once, rather - // than discarding its token. A rejection whose stored token still matches (a genuinely dead - // token, or a non-rotation rejection) falls through to drop-and-re-sign-in below. - if (!mConn.retriedRotatedToken && success && !value.isEmpty()) { - // The stored JSON may hold a bearer token; parse from an owned buffer and scrub every owned - // copy - the QByteArray and the QString value (retrievePassword moved it in) - on all paths, - // including a corrupt entry that never parses. - QByteArray valueBytes = value.toUtf8(); - const auto doc = QJsonDocument::fromJson(valueBytes); - SecureStringUtils::secureByteArrayClear(valueBytes); - SecureStringUtils::secureStringClear(value); - if (doc.isObject()) { - const auto account = doc.object()[qsl("account")].toString(); - auto token = doc.object()[qsl("token")].toString(); - if (!account.isEmpty() && !token.isEmpty()) { - QByteArray tokenBytes = token.toUtf8(); - const QByteArray storedHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256); - SecureStringUtils::secureByteArrayClear(tokenBytes); - if (!mConn.sentReconnectTokenHash.isEmpty() && storedHash != mConn.sentReconnectTokenHash) { + bool rejectedTokenStillStored = false; + + // Shared-store rotation check: if the stored token no longer hashes to what this connection + // sent, another running instance rotated it (single-use) - replay the fresh one once, rather + // than discarding its token. A rejection whose stored token still matches (a genuinely dead + // token, or a non-rotation rejection) falls through to drop-and-re-sign-in below. + if (!retriedRotatedToken && success && !value.isEmpty()) { + // The stored JSON may hold a bearer token; parse from an owned buffer and scrub every owned + // copy - the QByteArray and the QString value (retrievePassword moved it in) - on all paths, + // including a corrupt entry that never parses. + QByteArray valueBytes = value.toUtf8(); + const auto doc = QJsonDocument::fromJson(valueBytes); + SecureStringUtils::secureByteArrayClear(valueBytes); + SecureStringUtils::secureStringClear(value); + if (doc.isObject()) { + const auto account = doc.object()[qsl("account")].toString(); + auto token = doc.object()[qsl("token")].toString(); + if (!account.isEmpty() && !token.isEmpty()) { + QByteArray tokenBytes = token.toUtf8(); + const QByteArray storedHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256); + SecureStringUtils::secureByteArrayClear(tokenBytes); + if (!sentTokenHash.isEmpty() && storedHash != sentTokenHash) { + if (superseded) { + // The newer attempt reads the store for itself, so leave the other + // instance's fresh token in place and let it decide. + SecureStringUtils::secureStringClear(token); + return; + } #if defined(DEBUG_GMCP_AUTHENTICATION) - qDebug() << "GMCP reconnect token was rotated by another instance; replaying the fresh token"; + qDebug() << "GMCP reconnect token was rotated by another instance; replaying the fresh token"; #endif - mConn.retriedRotatedToken = true; - mConn.sentReconnectTokenHash = storedHash; - mConn.reconnectingWithToken = true; - mConn.awaitingReconnectResult = true; - mConn.reconnectAccount = account; - sendReconnect(account, std::move(token)); - return; + if (sendReconnect(account, std::move(token))) { + mConn.retriedRotatedToken = true; + mConn.sentReconnectTokenHash = storedHash; + mConn.reconnectingWithToken = true; + mConn.awaitingReconnectResult = true; + mConn.reconnectAccount = account; + // This attempt is replaying a live token rather than recovering from a dead + // one, so release the latch: the next Char.Login.Default is an ordinary + // sign-in again and may use a stored token. + mReconnectRejected = false; + return; + } + // The other instance's token is live, so leave the stored entry alone. The + // rejection latch stays armed: this connection cannot use a token at all. + selectAuthMethod(); + return; + } + // Both branches above return, so reaching here means the stored token is not a + // rotation. That only counts as evidence the rejected token is still stored when + // this connection recorded what it sent; with no hash there is nothing to match. + rejectedTokenStillStored = !sentTokenHash.isEmpty(); + } + // Catch-all: scrub the parsed token on every path that did not move it into + // sendReconnect - including a stored entry with a token but an empty account - so a + // bearer secret is never dropped un-zeroed. Mirrors readStoredSignIn. + SecureStringUtils::secureStringClear(token); } } - // Catch-all: scrub the parsed token on every path that did not move it into - // sendReconnect - including a stored entry with a token but an empty account - so a - // bearer secret is never dropped un-zeroed. Mirrors readStoredSignIn. - SecureStringUtils::secureStringClear(token); - } - } - // Scrub the retrieved store copy on the fall-through too: when the rotation block was skipped - // (a second rejection, or an empty read) value may still hold token JSON. Idempotent when the - // block above already cleared it. - SecureStringUtils::secureStringClear(value); + // Scrub the retrieved store copy on the fall-through too: when the rotation block was skipped + // (a second rejection, or an empty read) value may still hold token JSON. Idempotent when the + // block above already cleared it. + SecureStringUtils::secureStringClear(value); - // The token really is dead. Keep the account+provider resume hint (dropping only the token) so - // the next attempt restarts the same provider's browser sign-in with no menu, then reconnect: - // servers commonly drop the connection right after rejecting a reconnect, so the fresh sign-in - // needs a fresh, stable connection. mReconnectRejected makes that next connection read the entry - // without replaying a possibly not-yet-rewritten token. - dropTokenKeepResumeHint(); - mReconnectRejected = true; - //: Shown when a saved password-less sign-in is no longer accepted; Mudlet reconnects so the user can sign in again. - mpHost->postMessage(tr("[ INFO ] - Your saved sign-in has expired; reconnecting so you can sign in again.")); - QTimer::singleShot(0ms, mpHost, [safeHost]() { - if (safeHost) { - safeHost->mTelnet.reconnect(); - } - }); - }); + // A superseded recovery rewrites the entry only when this read positively saw the rejected + // token still stored. Without that evidence - a failed read, an entry that never parsed, or a + // second rejection after a rotation replay - the newer attempt may already have saved its own + // fresh token (its Char.Login.Token can land while this read is in flight), and a token-less + // rewrite here would erase it and force another browser sign-in. Leaving the entry alone is + // self-correcting instead: the latch keeps the newer attempt from replaying a dead token, and + // if that attempt never completes, the next connection's rejection runs this recovery again + // un-superseded and drops it then. + if (superseded && !rejectedTokenStillStored) { + // Leaving the rejected token stored means re-arming the latch. The Char.Login.Default + // that superseded this recovery already consumed it in attemptReconnect(), so without + // this the *next* Char.Login.Default would be free to replay a token the server has + // already rejected - the very thing this whole path exists to prevent. Costs at most one + // extra resume on the connection after that. + mReconnectRejected = true; + return; + } + // The token really is dead. Keep the account+provider resume hint (dropping only the token) so + // the next attempt restarts the same provider's browser sign-in with no menu. The captured + // account and provider are used rather than mConn's, which a newer Char.Login.Default may have + // cleared - that would silently downgrade this to discarding the whole entry. + dropTokenKeepResumeHint(reconnectAccount, accountProvider); + if (superseded) { + // A newer attempt is already driving the sign-in; it consumes the latch set above and + // resumes (or hands off) without the dead token, so there is nothing left to do here. + return; + } + // Reconnect: servers commonly drop the connection right after rejecting a reconnect, so the + // fresh sign-in needs a fresh, stable connection. The latch set above makes that next + // connection read the entry without replaying a possibly not-yet-rewritten token. + //: Shown when a saved password-less sign-in is no longer accepted; Mudlet reconnects so the user can sign in again. + mpHost->postMessage(tr("[ INFO ] - Your saved sign-in has expired; reconnecting so you can sign in again.")); + QTimer::singleShot(0ms, mpHost, [safeHost]() { + if (safeHost) { + safeHost->mTelnet.reconnect(); + } + }); + }); } -void GMCPAuthenticator::dropTokenKeepResumeHint() +void GMCPAuthenticator::dropTokenKeepResumeHint(const QString& account, const QString& provider) { // Without a remembered provider there is nothing to resume, so remove the whole entry. - if (mConn.reconnectAccount.isEmpty() || mConn.accountProvider.isEmpty()) { + if (account.isEmpty() || provider.isEmpty()) { discardReconnectToken(); return; } - storeResumeHint(mConn.reconnectAccount, mConn.accountProvider); + storeResumeHint(account, provider); } // controller for GMCP authentication @@ -722,7 +826,7 @@ void GMCPAuthenticator::handleAuthGMCP(const QString& packageMessage, const QStr // deciding how to authenticate. resetPerConnectionState(); - attemptReconnect(); + scheduleSignInAttempt(); return; } @@ -796,6 +900,34 @@ void GMCPAuthenticator::handleAuthToken(const QString& packageMessage, const QSt #endif } +void GMCPAuthenticator::scheduleSignInAttempt() +{ + if (mSignInAttemptPending) { +#if defined(DEBUG_GMCP_AUTHENTICATION) + qDebug() << "GMCP Char.Login.Default arrived while a sign-in attempt was already scheduled; folding it into that attempt"; +#endif + return; + } + if (!mLastSignInAttempt.isValid() || mLastSignInAttempt.durationElapsed() >= scmSignInAttemptInterval) { + mLastSignInAttempt.start(); + attemptReconnect(); + return; + } + + // Inside the throttle window: serve the whole burst with one attempt when it closes, using the + // capabilities the last frame left behind, and drop it if that connection has gone by then. + mSignInAttemptPending = true; + const auto scheduleGeneration = mSignInScheduleGeneration; + QTimer::singleShot(scmSignInAttemptInterval - mLastSignInAttempt.durationElapsed(), mpHost, [this, scheduleGeneration]() { + if (scheduleGeneration != mSignInScheduleGeneration) { + return; + } + mSignInAttemptPending = false; + mLastSignInAttempt.start(); + attemptReconnect(); + }); +} + void GMCPAuthenticator::attemptReconnect() { mpHost->mTelnet.cancelLoginTimers(); @@ -874,20 +1006,23 @@ void GMCPAuthenticator::readStoredSignIn(bool allowToken) mConn.accountProvider = provider; } if (allowToken && !account.isEmpty() && !token.isEmpty()) { - // This connection is logging in by replaying a saved token, so a Char.Login.Token - // that comes back is a silent rotation rather than a first-time save to announce. - mConn.reconnectingWithToken = true; - mConn.awaitingReconnectResult = true; - mConn.reconnectAccount = account; // Remember only a hash of what we send: if the reconnect is rejected, comparing it // against a fresh read tells a dead token apart from one another running instance // (sharing this profile's keychain) rotated while ours was in flight. QByteArray tokenBytes = token.toUtf8(); - mConn.sentReconnectTokenHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256); + const QByteArray sentHash = QCryptographicHash::hash(tokenBytes, QCryptographicHash::Sha256); SecureStringUtils::secureByteArrayClear(tokenBytes); - // Move the token in so sendReconnect owns the sole copy and can scrub it after sending. - sendReconnect(account, std::move(token)); - return; + // Move the token in so sendReconnect owns the sole copy and can scrub it either way. + if (sendReconnect(account, std::move(token))) { + // This connection is logging in by replaying a saved token, so a Char.Login.Token + // that comes back is a silent rotation rather than a first-time save to announce. + mConn.reconnectingWithToken = true; + mConn.awaitingReconnectResult = true; + mConn.reconnectAccount = account; + mConn.sentReconnectTokenHash = sentHash; + return; + } + // Not sent, so nothing awaits a result on it; fall through to resume or hand-off. } if (!account.isEmpty() && !provider.isEmpty()) { // No usable token, but we remember how this account signs in: ask the game to diff --git a/src/GMCPAuthenticator.h b/src/GMCPAuthenticator.h index 9ffcf2ccb..4a74578ff 100644 --- a/src/GMCPAuthenticator.h +++ b/src/GMCPAuthenticator.h @@ -23,6 +23,7 @@ #include "Host.h" #include "utils.h" +#include <QElapsedTimer> #include <QJsonArray> #include <QJsonDocument> #include <QJsonObject> @@ -30,6 +31,7 @@ #include <QString> #include <QVariantMap> +#include <chrono> #include <functional> class OAuthClientFlow; @@ -58,19 +60,28 @@ public: private: void handleAuthUrl(const QString& packageMessage, const QString& data); - void openSignInUrl(const QUrl& url, const QString& provider); + // The single place where a sign-in web address may reach the system browser, for both the + // server-driven (Char.Login.URL) and the client-driven flow. Auto-opens only against evidence that + // the player wants to sign in and consumes it, otherwise offers the address as a link. + // answersTheGamesSignInOffer marks an address reached from Char.Login.Default, where connecting is + // itself that evidence - once per connection. + void offerOrOpenSignInUrl(const QUrl& url, const QString& provider, bool answersTheGamesSignInOffer); + bool openSignInUrl(const QUrl& url, const QString& provider); void startClientDrivenOAuth(); void cancelClientDrivenOAuth(); void announceBrowserHandoff(const QString& provider); - void sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri); + void sendAuthCode(QString code, QString codeVerifier, const QString& redirectUri, QString nonce); void selectAuthMethod(); + void scheduleSignInAttempt(); void attemptReconnect(); // Reads the stored sign-in entry ({account, provider?, token?}) and acts on it: replay the token // (when allowToken), else send the resume form for a remembered provider, else fall through to // selectAuthMethod(). allowToken is false on the connection straight after a rejection, so a // not-yet-rewritten entry cannot loop us back into another rejected reconnect. void readStoredSignIn(bool allowToken); - void sendReconnect(const QString& account, QString token); + // Returns false when it refused to send: the token is a bearer secret and never goes out over a + // cleartext transport. It is scrubbed either way, and only a true return means a result is awaited. + bool sendReconnect(const QString& account, QString token); // Sends the resume form of Char.Login.Credentials: {account, provider, version}, no password - // asking the game to restart the browser sign-in for the provider remembered from an earlier // Char.Login.URL. The absence of a password (not the presence of provider) is what distinguishes it. @@ -82,12 +93,16 @@ private: // replayed once instead of destroyed. Only a genuinely dead token is dropped, keeping the // account+provider resume hint so the next sign-in needs no provider menu. void retryOrDropRejectedToken(); - void dropTokenKeepResumeHint(); + // Takes the account and provider explicitly: the caller captures them before its keychain read, so + // a Char.Login.Default arriving mid-read cannot clear mConn and turn this into a full discard. + void dropTokenKeepResumeHint(const QString& account, const QString& provider); // Rewrites the stored entry as {account, provider} with no token: enough to resume later, nothing // any longer a bearer secret. void storeResumeHint(const QString& account, const QString& provider); void discardReconnectToken(std::function<void(bool success)> callback = {}); void resetPerConnectionState(); + // Per socket connection, unlike resetPerConnectionState() which runs per Char.Login.Default. + void resetForNewConnection(); bool clientDrivenOAuthAvailable() const; @@ -144,17 +159,37 @@ private: }; PerConnectionState mConn; - // Set when a reconnect token is rejected and we reconnect for a clean sign-in. Deliberately NOT part - // of mConn: it is a one-shot latch consumed by attemptReconnect() on the very next connection, so it - // must survive the per-connection reset that the reconnect it triggers performs. The saved token is - // cleared asynchronously, so this makes that next connection skip a token replay rather than racing - // the keychain rewrite and looping back into another rejected reconnect. + // Set when a reconnect token is rejected, before the keychain read that decides what to do about it. + // Deliberately NOT part of mConn: attemptReconnect() consumes it on the next Char.Login.Default, so it + // must survive the per-connection reset that Default performs. The saved token is cleared + // asynchronously, so this makes the next attempt skip a token replay rather than racing the keychain + // rewrite and looping back into another rejected reconnect. That next Default usually arrives on the + // connection we reconnect to, but a server is also permitted to re-offer one on this connection + // instead, and Char.Login 2 forbids replaying a token rejected on it - hence latching synchronously at + // the rejection rather than when the read returns. + // + // Consumed in one place (attemptReconnect()) but cleared or re-armed in two others, so audit all three + // together: retryOrDropRejectedToken() clears it when it replays a live rotated token, and re-arms it + // when a superseded recovery leaves the rejected token stored - by then the superseding Default has + // already consumed the latch, so without re-arming the Default after that could replay the dead token. bool mReconnectRejected = false; // Incremented on every per-connection auth reset (each Char.Login.Default). The asynchronous // reconnect-token keychain read captures the value current when it started and re-checks it in its // callback, so a result arriving after a newer connection began is discarded instead of driving a // sign-in on the wrong attempt. Not part of mConn: it must monotonically increase, never reset. unsigned int mAuthAttemptGeneration = 0; + + // A server can pack thousands of Char.Login.Default frames into one packet and every sign-in + // attempt reads the credential store. Throttling bounds that cost by wall clock rather than by how + // much the server sent, and unlike a hard per-connection cap never refuses a legitimate re-offer. + inline static constexpr std::chrono::milliseconds scmSignInAttemptInterval = std::chrono::seconds(1); + QElapsedTimer mLastSignInAttempt; + bool mSignInAttemptPending = false; + // Bumped whenever a connection begins or ends, so a deferred attempt from a previous one is dropped. + unsigned int mSignInScheduleGeneration = 0; + // One automatic browser hand-off per connection for an address reached from the game's sign-in + // offer, so a server cannot turn a burst of frames into a burst of tabs. + bool mUnpromptedBrowserOpenAvailable = true; }; #endif // MUDLET_AUTHENTICATOR_H diff --git a/src/Host.cpp b/src/Host.cpp index 3929e71c2..5a23f9c8a 100644 --- a/src/Host.cpp +++ b/src/Host.cpp @@ -60,18 +60,22 @@ #include <chrono> #include <QtConcurrentRun> +#include <QApplication> #include <QCoreApplication> #include <QDataStream> -#include <QDialog> +#include <QDirIterator> #include <QJsonDocument> #include <QJsonObject> #include <QJsonValue> #include <QKeyEvent> -#include <QtUiTools> +#include <QMovie> #include <QNetworkProxy> +#include <QRandomGenerator> #include <QRegularExpression> #include <QSaveFile> +#include <QScopeGuard> #include <QSettings> +#include <QTemporaryFile> #include <QTextStream> #include <zip.h> #include <memory> @@ -242,7 +246,6 @@ Host::Host(int port, const QString& hostname, const QString& login, const QStrin , mpMap(new TMap(this, hostname)) , mpMedia(new TMedia(this, hostname)) , mpAuth(new GMCPAuthenticator(this)) -, mpDockableMapWidget() , mTimerDebugOutputSuppressionInterval(QTime()) , mSearchOptions(dlgTriggerEditor::SearchOption::SearchOptionNone) , mBufferSearchOptions(TConsole::SearchOption::SearchOptionNone) @@ -409,6 +412,8 @@ Host::Host(int port, const QString& hostname, const QString& login, const QStrin } }); connect(&purgeTimer, &QTimer::timeout, this, &Host::slot_purgeTemps); + mDeferredSaveTimer.setSingleShot(true); + connect(&mDeferredSaveTimer, &QTimer::timeout, this, &Host::slot_saveProfileAfterPackageChange); connect(this, &Host::signal_forceMXPProcessorOnChanged, this, [this](bool enabled) { if (enabled) { if (!mMxpProcessor.isEnabled()) { @@ -446,6 +451,38 @@ Host::~Host() // Mark the host as closing down to prevent keybinding processing during destruction mIsClosingDown = true; + // closeChildren() normally does this, but a Host whose console has already + // gone never gets there - and a package save left to fire from a Host that + // is being taken apart runs against freed members (#9653): + mDeferredSaveTimer.stop(); + + // The editor is a parentless top-level window, so delete it here while the + // units it references are still alive. Null the QPointer first: it only + // clears itself once ~QObject is reached, so anything looking at + // mpEditorDialog mid-teardown would find a half-destroyed widget: + if (auto* pEditor = mpEditorDialog.data()) { + mpEditorDialog = nullptr; + delete pEditor; + } + + if (auto* pNotePad = mpNotePad.data()) { + if (mudlet::self()) { + pNotePad->save(); + pNotePad->close(); + } + mpNotePad = nullptr; + delete pNotePad; + } + + if (auto* pDlgIRC = mpDlgIRC.data()) { + mpDlgIRC = nullptr; + delete pDlgIRC; + } + + for (const auto& pToolBar : mActionUnit.getToolBarList()) { + delete pToolBar.data(); + } + // This needs to be cleared here while the Host object is still valid, // otherwise it'll be cleared when the Host object is being destroyed, // which can lead to a crash when closing multiple profiles at once. @@ -453,11 +490,6 @@ Host::~Host() mStopWatchMap.clear(); - if (mpDockableMapWidget) { - mpDockableMapWidget->deleteLater(); - } - - mErrorLogStream.flush(); mErrorLogFile.close(); // Since this is a destructor, it's risky to rely on member variables within the destructor itself. @@ -516,6 +548,14 @@ bool Host::requestClose() void Host::closeChildren() { mIsClosingDown = true; + // Drop the profile save a package install/uninstall put off: the close path + // has already saved the profile with that change in it (or the user declined + // to save at all), and a save that outlives the profile runs on a destroyed + // Host (#9653). + if (mDeferredSaveTimer.isActive()) { + qDebug().nospace().noquote() << "Host::closeChildren() INFO - dropping the profile save that a package change owed \"" << getName() << "\": the close saves the profile itself."; + mDeferredSaveTimer.stop(); + } const auto hostToolBarMap = getActionUnit()->getToolBarList(); // disconnect before removing objects from memory as sysDisconnectionEvent needs that stuff. mTelnet.terminateConnection(); @@ -632,58 +672,95 @@ void Host::createModuleBackup(const QString& filename, const QString& saveName) QFile::copy(filename, saveName + time); } -void Host::writeModule(const QString& moduleName, const QString& filename) +QList<QFuture<bool>> Host::pendingXmlSaveFutures() const { - QString xml_filename = filename; - if (filename.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || filename.endsWith(qsl("zip"), Qt::CaseInsensitive)) { - xml_filename = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName); + QList<QFuture<bool>> futures; + for (const auto& writer : writers) { + futures += writer->saveFutures; } - auto writer = std::make_shared<XMLexport>(this); - writers.insert(xml_filename, writer); - writer->writeModuleXML(moduleName, xml_filename); - updateModuleZips(filename, moduleName); + return futures; } void Host::waitForAsyncXmlSave() { - // writers and futures are copied to prevent deletion during for loop (which would mean crash) - auto myWriters = writers; - for (auto& writer : myWriters) { - auto myFutures = writer->saveFutures; - for (auto& future : myFutures) { - future.waitForFinished(); - } + // Snapshot on the main thread - see pendingXmlSaveFutures() + const auto futures = pendingXmlSaveFutures(); + for (auto future : futures) { + future.waitForFinished(); } } -void Host::saveModules(bool backup) +QList<Host::ModuleWriteJob> Host::prepareModuleSaves(bool backup) { - QMapIterator<QString, QStringList> it(modulesToWrite); + // Runs on the main thread so it can safely read the live trigger/timer/... lists + // (via writeModuleXML) and mutate the `writers`/`mModulesToSync`/`modulesToWrite` + // bookkeeping. The returned jobs carry everything writeModuleFiles() needs - the + // document and every path - so the background task touches neither that shared + // state nor the Host, which may well be destroyed before the task even starts. + QList<ModuleWriteJob> jobs; mModulesToSync.clear(); - const QString savePath = mudlet::getMudletPath(enums::moduleBackupsPath); - auto savePathDir = QDir(savePath); - if (!savePathDir.exists()) { - savePathDir.mkpath(savePath); - } + const QString backupPath = backup ? mudlet::getMudletPath(enums::moduleBackupsPath) : QString(); + QMapIterator<QString, QStringList> it(modulesToWrite); while (it.hasNext()) { it.next(); - QStringList entry = it.value(); + const QStringList entry = it.value(); const QString moduleName = it.key(); - const QString filename = entry[0]; + const QString filename = entry.at(0); if (!mModulesLoadedOk.contains(moduleName)) { continue; } - if (backup) { - createModuleBackup(filename, savePath + moduleName); + QString xmlFilename = filename; + if (filename.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || filename.endsWith(qsl("zip"), Qt::CaseInsensitive)) { + xmlFilename = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName); + // The write below goes into this folder, so it has to exist before the + // write and not - as it used to - after it: a module whose unpacked folder + // the user has removed would otherwise fail to write, and then have its + // now-stale XML dropped from its archive without a replacement going in. + const QString packagePath = mudlet::getMudletPath(enums::profilePackagePath, mHostName, moduleName); + if (auto packageDir = QDir(packagePath); !packageDir.exists()) { + packageDir.mkpath(packagePath); + } } - writeModule(moduleName, filename); - if (entry[1].toInt()) { + + auto writer = std::make_shared<XMLexport>(this); + writer->writeModuleXML(moduleName); + // The writer stays in `writers` purely as the save-in-progress token that + // xmlSaved() retires on the main thread, so the XMLexport - a QObject with + // main-thread affinity - is only ever destroyed there. + writers.insert(xmlFilename, writer); + jobs.append({writer->takeExportDocument(), moduleName, filename, xmlFilename, backup ? backupPath + moduleName : QString()}); + + if (entry.at(1).toInt()) { mModulesToSync << moduleName; } } modulesToWrite.clear(); + if (!backupPath.isEmpty() && !jobs.isEmpty()) { + auto backupDir = QDir(backupPath); + if (!backupDir.exists()) { + backupDir.mkpath(backupPath); + } + } + return jobs; +} + +void Host::writeModuleFiles(const QList<ModuleWriteJob>& jobs) +{ + // Pure file I/O over self-contained jobs, usually on a thread pool thread. It is + // static because a close that answers "No" to "Save profile?", and one that finds + // the main console already gone, wait for nothing: the Host that ordered these + // writes can be, and regularly is, destroyed while they are still queued. + for (const auto& job : jobs) { + if (!job.backupName.isEmpty()) { + createModuleBackup(job.filename, job.backupName); + } + if (!XMLexport::saveXmlDocToFile(job.xmlFilename, *job.document)) { + qWarning().noquote().nospace() << "Host::writeModuleFiles() WARNING - failed to write module \"" << job.moduleName << "\" to \"" << job.xmlFilename << "\"."; + } + updateModuleZip(job); + } } void Host::reloadModules() @@ -716,14 +793,17 @@ void Host::reloadModules() mModulesToSync.clear(); } -void Host::updateModuleZips(const QString& zipName, const QString& moduleName) +void Host::updateModuleZip(const ModuleWriteJob& job) { + // Static for the same reason writeModuleFiles() is: every path it needs was + // resolved, and every folder it needs created, on the main thread beforehand. + const QString zipName = job.filename; + const QString moduleName = job.moduleName; if (!(zipName.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || zipName.endsWith(qsl("zip"), Qt::CaseInsensitive))) { return; } zip* zipFile = nullptr; - const QString packagePathName = mudlet::getMudletPath(enums::profilePackagePath, mHostName, moduleName); - const QString filename_xml = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName); + const QString filename_xml = job.xmlFilename; int err = 0; zipFile = zip_open(zipName.toStdString().c_str(), ZIP_CREATE, &err); if (!zipFile) { @@ -736,15 +816,11 @@ void Host::updateModuleZips(const QString& zipName, const QString& moduleName) existing file that is to be overwritten may be a source of problems here. */ - qWarning().noquote().nospace() << "Host::updateModuleZips(\"" << zipName << "\", \"" << moduleName << "\") WARNING - failed to open module to update it, error: \"" + qWarning().noquote().nospace() << "Host::updateModuleZip(\"" << zipName << "\", \"" << moduleName << "\") WARNING - failed to open module to update it, error: \"" << zip_error_strerror(&zipError) << "\""; zip_error_fini(&zipError); return; } - const QDir packageDir = QDir(packagePathName); - if (!packageDir.exists()) { - packageDir.mkpath(packagePathName); - } const int xmlIndex = zip_name_locate(zipFile, qsl("%1.xml").arg(moduleName).toUtf8().constData(), ZIP_FL_ENC_GUESS); zip_delete(zipFile, xmlIndex); struct zip_source* s = zip_source_file(zipFile, filename_xml.toUtf8().constData(), 0, -1); @@ -850,6 +926,13 @@ bool Host::resetProfile_phase1() return false; } + // Phase 2 lua_close()s the very state the pump is running Lua code on, so + // refuse rather than reset into a use-after-free. + if (mLuaInterpreter.pumpingEvents()) { + qWarning() << "Host::resetProfile_phase1() called while the test-mode event pump is running, ignoring"; + return false; + } + mAliasUnit.stopAllTriggers(); mTriggerUnit.stopAllTriggers(); mTimerUnit.stopAllTriggers(); @@ -875,6 +958,7 @@ void Host::resetProfile_phase2() mTimerUnit.doCleanup(); mTriggerUnit.doCleanup(); mKeyUnit.doCleanup(); + mActionUnit.doCleanup(); mpConsole->resetMainConsole(); // Drain queued DeferredDelete events so old TLabel destructors run their // luaL_unref against the still-live Lua state. Without this, those unrefs @@ -990,6 +1074,15 @@ std::tuple<bool, QString, QString> Host::saveProfile(const QString& saveFolder, writers.remove(qsl("profile")); return {false, filename_xml, tr("the profile is no longer available")}; } + + // Build the module XML documents and register their writers here, on the main + // thread, while the live profile data is quiescent: the background task below then + // only serializes the prepared documents and never touches `writers`, + // `modulesToWrite` or `mModulesToSync` (doing so from a pool thread was a + // heap-corrupting data race). + const bool backupModules = saveName != qsl("autosave"); + const QList<ModuleWriteJob> moduleJobs = prepareModuleSaves(backupModules); + mWritingHostAndModules = true; // emit signal to notify the UI that the save button should get disabled momentarily @@ -1003,20 +1096,49 @@ std::tuple<bool, QString, QString> Host::saveProfile(const QString& saveFolder, qApp->processEvents(); } - auto watcher = new QFutureWatcher<void>; - mModuleFuture = QtConcurrent::run([=, this]() { - // wait for the host xml to be ready before starting to sync modules - waitForAsyncXmlSave(); - saveModules(saveName != qsl("autosave")); + // Snapshot the pending profile-save futures on the main thread - see + // pendingXmlSaveFutures(): the background task must not read `writers`/`saveFutures` + // itself, as the main thread mutates them whenever a save starts or finishes. + const QList<QFuture<bool>> xmlSaveFutures = pendingXmlSaveFutures(); + // Parented so the profile owns the watcher outright. The deleteLater() below only + // arrives if the event loop lives long enough to deliver `finished` and then the + // deferred delete, which on the way out it does not - and a Host destroyed while + // the write is still queued would leave the watcher with no owner at all. + auto watcher = new QFutureWatcher<void>(this); + // Captures values only, never `this`: no wait for this task is guaranteed, so the + // Host can be destroyed while it is still queued or running. + mModuleFuture = QtConcurrent::run([xmlSaveFutures, moduleJobs]() { + // wait for the host xml to be ready before writing the modules out + for (auto future : xmlSaveFutures) { + future.waitForFinished(); + } + Host::writeModuleFiles(moduleJobs); }); - connect(watcher, &QFutureWatcher<void>::finished, this, [=, this]() { - // reload, or queue module reload for when xml is ready + // Only the names: holding the whole jobs would keep every module's document in + // memory until this runs, and all it needs is what to retire from `writers`. + QStringList savedModuleXmlNames; + savedModuleXmlNames.reserve(moduleJobs.size()); + for (const auto& job : moduleJobs) { + savedModuleXmlNames << job.xmlFilename; + } + connect(watcher, &QFutureWatcher<void>::finished, this, [this, savedModuleXmlNames, syncModules]() { + // Finish on the main thread: the module documents are now on disk. Consume + // mModulesToSync via reloadModules() *before* the xmlSaved() loop below empties + // `writers` and emits profileSaveFinished(): that signal fires synchronously, + // and a deferred handler (e.g. a queued package install) could start another + // save that clears/replaces mModulesToSync, making this save skip the module + // sync it owes to other profiles. if (syncModules) { reloadModules(); } mWritingHostAndModules = false; - watcher->deleteLater(); + // Drop each module writer from `writers`; the last removal emits + // profileSaveFinished() once the profile writer is gone too. + for (const auto& xmlFilename : savedModuleXmlNames) { + xmlSaved(xmlFilename); + } }); + connect(watcher, &QFutureWatcher<void>::finished, watcher, &QObject::deleteLater); watcher->setFuture(mModuleFuture); return {true, filename_xml, QString()}; } @@ -1316,26 +1438,7 @@ bool Host::checkForMappingScript() void Host::check_for_mappingscript() { if (!checkForMappingScript()) { - QUiLoader loader; - - QFile file(":/ui/lacking_mapper_script.ui"); - if (!file.open(QFile::ReadOnly)) { - qWarning() << "Host: failed to open lacking_mapper_script.ui for reading:" << file.errorString(); - return; - } - - auto dialog = dynamic_cast<QDialog*>(loader.load(&file, mudlet::self())); - file.close(); - if (!dialog) { - // could not load / not a QDialog - return; - } - - connect(dialog, &QDialog::accepted, mudlet::self(), &mudlet::slot_openMappingScriptsPage); - - dialog->show(); - dialog->raise(); - dialog->activateWindow(); + emit signal_showMapperScriptReminder(); } } @@ -1788,6 +1891,10 @@ void Host::incomingStreamProcessor(const QString& data, int line) mTimerUnit.doCleanup(); mTriggerUnit.doCleanup(); mKeyUnit.doCleanup(); + mActionUnit.doCleanup(); + // ScriptUnit defers deletes too (a package script uninstalling its own package + // mid-compile or mid-event-dispatch), so flush it here alongside the others: + mScriptUnit.doCleanup(); } // When Mudlet is running in online mode, deleted temp* objects are cleaned up in bulk @@ -1799,6 +1906,32 @@ void Host::slot_purgeTemps() mTimerUnit.doCleanup(); mTriggerUnit.doCleanup(); mKeyUnit.doCleanup(); + mActionUnit.doCleanup(); + mScriptUnit.doCleanup(); +} + +// The profile save that installPackage()/uninstallPackage() put off to the next +// event loop pass - see mDeferredSaveTimer. +void Host::slot_saveProfileAfterPackageChange() +{ + if (currentlySavingProfile()) { + // saveProfile() would refuse outright, and this is the only save the + // package change has coming: ask again once the one in flight is out of + // the way rather than leaving the change unwritten until something else + // happens to save. The profile close stops this timer, so the retries + // cannot outlive the profile. + mDeferredSaveTimer.start(100ms); + return; + } + // If a package's own script uninstalled it mid-compile (from a script + // reached outside the compileAll()/editor/raiseEvent flush points, e.g. a + // permScript() run from an alias or key), the script deletes were deferred + // and are still registered. Flush them now, at depth 0, before saving so + // the save below does not serialize the just-uninstalled scripts back in: + mScriptUnit.doCleanup(); + if (auto [ok, filename, error] = saveProfile(); !ok) { + qWarning() << qsl("Host::slot_saveProfileAfterPackageChange() WARNING - couldn't save '%1' to '%2' because: %3").arg(getName(), filename, error); + } } void Host::registerEventHandler(const QString& name, TScript* pScript) @@ -1833,7 +1966,9 @@ void Host::unregisterEventHandler(const QString& name, TScript* pScript) } } -// If a handler matches the event, the Lua stack will be cleared after this function +// Handlers run on this profile's shared lua_State, but each unwinds it back to +// the level it found, so a C function raising an event mid-flight keeps its own +// arguments and any return values it has already pushed void Host::raiseEvent(const TEvent& pE) { if (Q_UNLIKELY(mEmergencyStop)) { @@ -1846,6 +1981,18 @@ void Host::raiseEvent(const TEvent& pE) static const QString star = qsl("*"); + // A handler can uninstall its own package mid-dispatch (a common package + // auto-updater pattern): whilst this frame is on the stack + // ScriptUnit::uninstall() defers its deletes so neither the executing + // handler nor the other TScript pointers in the lists copied below get + // freed under us; the deferred deletes are flushed once the outermost + // dispatch finishes: + mScriptUnit.beginProcessing(); + const auto processingGuard = qScopeGuard([this] { + mScriptUnit.endProcessing(); + mScriptUnit.doCleanup(); + }); + if (mEventHandlerMap.contains(pE.mArgumentList.at(0))) { QList<TScript*> scriptList = mEventHandlerMap.value(pE.mArgumentList.at(0)); for (auto& script : scriptList) { @@ -1872,6 +2019,10 @@ void Host::raiseEvent(const TEvent& pE) } } + // Let any test-mode waitForEvent() call blocked on this event capture its + // arguments and unblock. Cheap (an empty-list check) when nothing is waiting. + mLuaInterpreter.captureEventForWaits(pE); + // After the event has been raised but before 'event' goes out of scope, // we need to safely dereference the members of 'event' that point to // values in the Lua registry @@ -1949,10 +2100,7 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa return {true, QString()}; } - // As the pointer to dialog is only used now WITHIN this method and this - // method can be re-entered, it is best to use a local rather than a class - // pointer just in case we accidentally re-enter this method in the future. - QDialog* pUnzipDialog = nullptr; + bool showedUnpackingDialog = false; QString actualFileName = fileName; std::unique_ptr<QTemporaryFile> tempFile; @@ -2035,51 +2183,33 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa // home directory for the PROFILE const QDir _tmpDir(_home); // directory to store the expanded archive file contents + // Noted before it is made: the only folder this install may ever delete + // again is one it made itself. The package name is the archive's own file + // name, and then whatever its config.lua says, so it can just as well name + // a folder of the profile's that was already here ("map", "log", + // "current") - see the refusal further down. + const bool destinationAlreadyExisted = QDir(_dest).exists(); const bool mkpathSuccessful = _tmpDir.mkpath(_dest); if (!mkpathSuccessful) { return {false, qsl("could not create destination folder")}; } + QString folderThisInstallMade = destinationAlreadyExisted ? QString() : QDir(_dest).absolutePath(); // Skip the unpacking dialog for modules created from UI, and for // script-initiated installs (passed via quiet) to avoid stealing // window-manager focus from the user's other applications - see // issue #9170. if (thing != enums::PackageModuleType::ModuleFromUI && !quiet) { - QUiLoader loader(this); - QFile uiFile(qsl(":/ui/package_manager_unpack.ui")); - if (!uiFile.open(QFile::ReadOnly)) { - qWarning() << "Host: failed to open package_manager_unpack.ui for reading:" << uiFile.errorString(); - return {false, qsl("could not open unpacking progress dialog UI file")}; - } - pUnzipDialog = dynamic_cast<QDialog*>(loader.load(&uiFile, nullptr)); - uiFile.close(); - if (!pUnzipDialog) { - return {false, qsl("could not load unpacking progress dialog")}; - } - - auto* pLabel = pUnzipDialog->findChild<QLabel*>(qsl("label")); - if (pLabel) { - if (thing != enums::PackageModuleType::Package) { - pLabel->setText(tr("Unpacking module:\n\"%1\"\nplease wait...").arg(packageName)); - } else { - pLabel->setText(tr("Unpacking package:\n\"%1\"\nplease wait...").arg(packageName)); - } - } - pUnzipDialog->hide(); // Must hide to change WindowModality - pUnzipDialog->setWindowTitle(tr("Unpacking")); - pUnzipDialog->setWindowModality(Qt::ApplicationModal); - pUnzipDialog->show(); - qApp->processEvents(); - pUnzipDialog->raise(); - pUnzipDialog->repaint(); // Force a redraw - qApp->processEvents(); // Try to ensure we are on top of any other dialogs and freshly drawn + const QString message = + (thing != enums::PackageModuleType::Package) ? tr("Unpacking module:\n\"%1\"\nplease wait...").arg(packageName) : tr("Unpacking package:\n\"%1\"\nplease wait...").arg(packageName); + emit signal_showUnpackingProgress(message, tr("Unpacking")); + showedUnpackingDialog = true; } auto unzipSuccessful = mudlet::unzip(actualFileName, _dest, _tmpDir); - if (pUnzipDialog) { - pUnzipDialog->deleteLater(); - pUnzipDialog = nullptr; + if (showedUnpackingDialog) { + emit signal_hideUnpackingProgress(); } if (!unzipSuccessful) { return {false, qsl("could not unzip package")}; @@ -2111,18 +2241,26 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa } // continuing, so update the folder name on disk const QString newpath(qsl("%1/%2").arg(_home, packageName)); - _dir.rename(_dir.absolutePath(), newpath); + // A rename onto a folder that is already there fails, and then the + // folder this install made is still at its old name while _dir goes + // on to the folder that was already here - which is not ours to + // delete, whatever the archive would like. + if (_dir.rename(_dir.absolutePath(), newpath) && !folderThisInstallMade.isEmpty()) { + folderThisInstallMade = QDir(newpath).absolutePath(); + } _dir = QDir(newpath); } QStringList _filterList; _filterList << qsl("*.xml") << qsl("*.trigger"); const QFileInfoList entries = _dir.entryInfoList(_filterList, QDir::Files); + bool registeredFromArchive = false; for (auto& entry : entries) { file2.setFileName(entry.absoluteFilePath()); if (!file2.open(QFile::ReadOnly | QFile::Text)) { qWarning() << "Host: failed to open file for reading:" << entry.absoluteFilePath() << file2.errorString(); continue; } + registeredFromArchive = true; XMLimport reader(this); if (thing != enums::PackageModuleType::Package) { QStringList moduleEntry; @@ -2144,6 +2282,31 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa } file2.close(); } + + // Registering the package is this loop's job, so an archive that holds no + // package XML that could be read is not installed anywhere: it would be + // missing from getPackages()/getModules(), uninstallPackage() would refuse + // it, and the folder it was just unpacked into would stay in the profile + // for good (#9654). Take that folder away again and say so. Asking the + // loop whether it registered anything, rather than asking + // mInstalledPackages/mInstalledModules afterwards, is what makes this hold + // for a module whose name is already in mInstalledModules on the way in + // (profile loading, and installModule() over a stale entry, both do that). + if (!registeredFromArchive) { + // Only ever remove the folder this install made, and only if it is + // inside the profile: the package name can come out empty (a file + // called ".mpackage"), name a folder of the user's ("map"), or be + // whatever an untrusted archive's config.lua says (".." - the folder + // holding every profile), and removeDir() takes everything below what + // it is given. + const QString profileHome = QDir(mudlet::getMudletPath(enums::profileHomePath, getName())).absolutePath(); + if (!folderThisInstallMade.isEmpty() && folderThisInstallMade.startsWith(profileHome + QLatin1Char('/'))) { + removeDir(folderThisInstallMade, folderThisInstallMade); + } else { + qWarning() << "Host::installPackage() WARNING - refused" << fileName << "as package" << packageName << "but leaving" << _dir.absolutePath() << "alone: this install did not make it"; + } + return {false, qsl("no package found in %1 - no Mudlet package file in it could be read").arg(fileName)}; + } } else { file2.setFileName(fileName); if (!file2.open(QFile::ReadOnly | QFile::Text)) { @@ -2188,7 +2351,15 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa // Defer raising install events until the next event loop iteration // This ensures all package installation is complete (including variable loading) // before event handlers execute, preventing Lua state corruption - QTimer::singleShot(0ms, this, [this, thing, packageName, fileName]() { + QTimer::singleShot(0ms, this, [this, guard = QPointer<Host>(this), thing, packageName, fileName]() { + // The queued call can still be delivered once this Host has been + // destroyed - the profile save queued the same way was #9653 - and the + // isClosingDown() check below would then be read off freed memory. The + // guard lives in the queued call rather than in the Host, so it is safe + // to ask and is null by then: + if (!guard) { + return; + } // Don't raise events if Host is shutting down to avoid handlers executing during teardown if (isClosingDown()) { return; @@ -2242,9 +2413,7 @@ std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::Pa // Save profile to ensure modules persist and appear in module manager if (thing != enums::PackageModuleType::Package) { // Use a timer to save profile after module installation completes - QTimer::singleShot(100ms, this, [this]() { - saveProfile(); - }); + mDeferredSaveTimer.start(100ms); } return {true, QString()}; @@ -2407,18 +2576,9 @@ bool Host::uninstallPackage(const QString& packageName, enums::PackageModuleType const QString dest = mudlet::getMudletPath(enums::profilePackagePath, getName(), packageName); removeDir(dest, dest); - // ensure only one timer is running in case multiple modules are uninstalled at once - if (!mSaveTimer.has_value() || !mSaveTimer.value()) { - mSaveTimer = true; - // save the profile on the next Qt main loop cycle in order for the asyncronous save mechanism - // not to try to write to disk a package/module that just got uninstalled and removed from memory - QTimer::singleShot(0ms, this, [this]() { - mSaveTimer = false; - if (auto [ok, filename, error] = saveProfile(); !ok) { - qDebug() << qsl("Host::uninstallPackage: Couldn't save '%1' to '%2' because: %3").arg(getName(), filename, error); - } - }); - } + // save the profile on the next Qt main loop cycle in order for the asyncronous save mechanism + // not to try to write to disk a package/module that just got uninstalled and removed from memory + mDeferredSaveTimer.start(0ms); //NOW we reset if we're uninstalling a module if (mpEditorDialog && thing == enums::PackageModuleType::ModuleFromScript) { @@ -3430,21 +3590,51 @@ void Host::setBufferSearchOptions(const TConsole::SearchOptions optionsState) mBufferSearchOptions = optionsState; } +// The single answer to "does this profile have a map widget on screen right +// now" - null both for a profile that has never opened one and for one that put +// it away again, which a script cannot tell apart and does not need to. +// +// isHidden() rather than a flag of our own, because the dock gets hidden by +// paths that would never think to update one: its own title bar close button, +// mudlet::slot_showMapperDialog() handing the map over to a main window dock, +// and QMainWindow::restoreState() replaying a saved layout. It is also not +// !isVisible(), which would additionally answer "no map widget" whenever the +// main window itself is hidden, e.g. minimised to the system tray. +QDockWidget* Host::mapWidget() const +{ + if (!mpConsole || !mpConsole->mpDockableMapWidget || mpConsole->mpDockableMapWidget->isHidden()) { + return nullptr; + } + + return mpConsole->mpDockableMapWidget; +} + std::pair<bool, QString> Host::setMapperTitle(const QString& title) { - if (!mpDockableMapWidget) { - return {false, "no floating/dockable type map window found"}; + auto pM = mapWidget(); + if (!pM) { + return {false, qsl("no floating/dockable type map window found")}; } if (title.isEmpty()) { - mpDockableMapWidget->setWindowTitle(tr("Map - %1").arg(mHostName)); + pM->setWindowTitle(tr("Map - %1").arg(mHostName)); } else { - mpDockableMapWidget->setWindowTitle(title); + pM->setWindowTitle(title); } return {true, QString()}; } +std::optional<QString> Host::getMapperTitle() const +{ + auto pM = mapWidget(); + if (!pM) { + return {}; + } + + return {pM->windowTitle()}; +} + std::pair<int, QString> Host::createMapView(int areaId) { if (!mpMap) { @@ -3587,7 +3777,6 @@ std::pair<bool, QString> Host::openWindow(const QString& name, bool loadLayout, dockwidget = new TDockWidget(this, name); dockwidget->setObjectName(qsl("dockWindow_%1_%2").arg(hostName, name)); dockwidget->setContentsMargins(0, 0, 0, 0); - dockwidget->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); dockwidget->setWindowTitle(name); mpConsole->mDockWidgetMap.insert(name, dockwidget); // It wasn't obvious but the parent passed to the TConsole constructor @@ -3597,7 +3786,7 @@ std::pair<bool, QString> Host::openWindow(const QString& name, bool loadLayout, console->setContentsMargins(0, 0, 0, 0); dockwidget->setTConsole(console); console->layerCommandLine->hide(); - console->mpScrollBar->hide(); + console->setScrollBarVisible(false); mpConsole->mSubConsoleMap.insert(name, console); dockwidget->setStyleSheet(mProfileStyleSheet); mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, dockwidget); @@ -4054,7 +4243,7 @@ std::pair<bool, QString> Host::setWindow(const QString& windowname, const QStrin if (pDCheck) { return {false, qsl("element '%1' is the base of a floating/dockable user window and may not be moved").arg(name)}; } - if (mpDockableMapWidget) { + if (mpConsole->mpDockableMapWidget) { if (!name.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) { return {false, qsl("element '%1' is the map in a floating/dockable window and may not be moved").arg(name)}; } @@ -4151,14 +4340,14 @@ std::pair<bool, QString> Host::setWindow(const QString& windowname, const QStrin std::pair<bool, QString> Host::openMapWidget(const QString& area, int x, int y, int width, int height) { if (!mpConsole) { - return {false, QString()}; + return {false, qsl("no console for this profile - it may be closing")}; } - auto pM = mpDockableMapWidget; + auto pM = mpConsole->mpDockableMapWidget; auto pMapper = mpMap.data()->mpMapper; if (!pM && !pMapper) { showHideOrCreateMapper(true); - pM = mpDockableMapWidget; + pM = mpConsole->mpDockableMapWidget; } if (!pM) { return {false, qsl("cannot create map widget. Do you already use an embedded mapper?")}; @@ -4210,20 +4399,35 @@ std::pair<bool, QString> Host::openMapWidget(const QString& area, int x, int y, return {false, qsl(R"("docking option "%1" not available. available docking options are "t" top, "b" bottom, "r" right, "l" left and "f" floating")").arg(area)}; } +// The inverse of moveMapWidget()/resizeMapWidget(), which reach the dock widget +// through openMapWidget(). pos()/size() rather than geometry() for the same +// reason as Host::windowGeometry(): they are what move()/resize() were given, +// while a floating dock's geometry() reports the client area instead. +std::optional<QRect> Host::mapWidgetGeometry() const +{ + auto pM = mapWidget(); + if (!pM) { + return {}; + } + + return {QRect(pM->pos(), pM->size())}; +} + std::pair<bool, QString> Host::closeMapWidget() { if (!mpConsole) { - return {false, QString()}; + return {false, qsl("no console for this profile - it may be closing")}; } - auto pM = mpDockableMapWidget; - if (!pM) { + // Test the raw pointer first so that a profile which never made a map widget + // is told apart from one that has put its widget away. + if (!mpConsole->mpDockableMapWidget) { return {false, qsl("no map widget found to close")}; } - if (!pM->isVisible()) { + if (!mapWidget()) { return {false, qsl("map widget already closed")}; } - pM->hide(); + mpConsole->mpDockableMapWidget->hide(); return {true, QString()}; } @@ -4475,8 +4679,8 @@ bool Host::setProfileStyleSheet(const QString& styleSheet) mpNotePad->setStyleSheet(styleSheet); mpNotePad->setTabsStyleSheet(styleSheet); } - if (mpDockableMapWidget) { - mpDockableMapWidget->setStyleSheet(styleSheet); + if (mpConsole->mpDockableMapWidget) { + mpConsole->mpDockableMapWidget->setStyleSheet(styleSheet); } for (auto& dockWidget : mpConsole->mDockWidgetMap) { @@ -4660,7 +4864,7 @@ void Host::toggleMapperVisibility() if (pMap->mpMapper->isFloatAndDockable()) { // If we are using a floating/dockable widget we must show/hide that // only and not the mapper widget (otherwise it messes up {shrinks - // to a minimal size} the mapper inside the container QDockWidget). This + // to a minimal size} the mapper inside the container dock widget). This // is the same as the case for a TConsole inside a TDockWidget in // (void) TDockWidget::setVisible(bool). // When in a dock widget, check the parent's visibility, not the child's, @@ -4681,17 +4885,21 @@ void Host::toggleMapperVisibility() void Host::createMapper(const bool loadDefaultMap) { + // The console owns the map dock; bail if the profile has no console yet or is + // already being torn down. + if (!mpConsole) { + return; + } auto pMap = mpMap.data(); auto hostName(getName()); - mpDockableMapWidget = new QDockWidget(tr("Map - %1").arg(hostName)); - mpDockableMapWidget->setObjectName(qsl("dockMap_%1").arg(hostName)); + mpConsole->createMapperDock(tr("Map - %1").arg(hostName), qsl("dockMap_%1").arg(hostName)); // Arrange for TMap member values to be copied from the Host masters so they // are in place when the 2D mapper is created: getPlayerRoomStyleDetails(pMap->mPlayerRoomStyle, pMap->mPlayerRoomOuterDiameterPercentage, pMap->mPlayerRoomInnerDiameterPercentage, pMap->mPlayerRoomOuterColor, pMap->mPlayerRoomInnerColor); - pMap->mpMapper = new dlgMapper(mpDockableMapWidget, this, pMap); //FIXME: mpHost definieren + pMap->mpMapper = new dlgMapper(mpConsole->mpDockableMapWidget, this, pMap); //FIXME: mpHost definieren pMap->mpMapper->setStyleSheet(mProfileStyleSheet); - mpDockableMapWidget->setWidget(pMap->mpMapper); + mpConsole->mpDockableMapWidget->setWidget(pMap->mpMapper); if (loadDefaultMap && pMap->mpRoomDB->isEmpty()) { qDebug() << "Host::create_mapper() - restore map case 3."; @@ -4716,7 +4924,7 @@ void Host::createMapper(const bool loadDefaultMap) pMap->mpMapper->show(); } } - mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, mpDockableMapWidget); + mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, mpConsole->mpDockableMapWidget); // XXX: should this be called multiple times? mudlet::self()->loadWindowLayout(); @@ -4725,7 +4933,7 @@ void Host::createMapper(const bool loadDefaultMap) // restored a previous hidden state, but when first creating the mapper, we // always want it to be visible. pMap->mpMapper->show(); - mpDockableMapWidget->show(); + mpConsole->mpDockableMapWidget->show(); pMap->mpMapper->updateEmptyStateOverlay(); check_for_mappingscript(); @@ -4849,6 +5057,10 @@ std::optional<QString> Host::windowType(const QString& name) const } } + if (mpConsole->mScrollBoxMap.contains(name)) { + return {qsl("scrollbox")}; + } + if (mpConsole->mSubCommandLineMap.contains(name)) { return {qsl("commandline")}; } @@ -4860,6 +5072,83 @@ std::optional<QString> Host::windowType(const QString& name) const return {}; } +// Returns the position and size of a window element, matching what +// moveWindow()/resizeWindow() set and mirroring their widget dispatch, so user +// windows are read from their dock widget rather than their console. +// pos()/size() rather than geometry(): for a floating dock move() targets the +// frame origin while geometry() would report the client area. +std::optional<QRect> Host::windowGeometry(const QString& name) const +{ + if (!mpConsole) { + return {}; + } + + if (name.isEmpty() || name == QLatin1String("main")) { + // 0,0 rather than the console's pos(), which under multi-view is an + // offset within the split; the size is getMainWindowSize()'s so the two + // functions cannot disagree. + return {QRect(QPoint(0, 0), mpConsole->getMainWindowSize())}; + } + if (auto pL = mpConsole->mLabelMap.value(name)) { + return {QRect(pL->pos(), pL->size())}; + } + if (auto pC = mpConsole->mSubConsoleMap.value(name)) { + if (auto pD = mpConsole->mDockWidgetMap.value(name)) { + return {QRect(pD->pos(), pD->size())}; + } + return {QRect(pC->pos(), pC->size())}; + } + if (auto pS = mpConsole->mScrollBoxMap.value(name)) { + return {QRect(pS->pos(), pS->size())}; + } + if (auto pN = mpConsole->mSubCommandLineMap.value(name)) { + return {QRect(pN->pos(), pN->size())}; + } + if (auto pT = mpConsole->mTextBoxMap.value(name)) { + return {QRect(pT->pos(), pT->size())}; + } + + return {}; +} + +// Returns whether a window element is currently visible, mirroring the widget +// dispatch of hideWindow()/showWindow() - user windows report their dock's +// visibility, which is what those toggle. Answered relative to the profile's +// own console: a child of a hidden user window still reads hidden, but a +// profile that is merely not the front tab does not. +std::optional<bool> Host::windowVisible(const QString& name) const +{ + if (!mpConsole) { + return {}; + } + + if (name.isEmpty() || name == QLatin1String("main")) { + // only the tab machinery hides the main console, and that is the hiding + // this function looks past + return {true}; + } + if (auto pL = mpConsole->mLabelMap.value(name)) { + return {pL->isVisibleTo(mpConsole)}; + } + if (auto pC = mpConsole->mSubConsoleMap.value(name)) { + if (auto pD = mpConsole->mDockWidgetMap.value(name)) { + return {pD->isVisibleTo(mpConsole)}; + } + return {pC->isVisibleTo(mpConsole)}; + } + if (auto pS = mpConsole->mScrollBoxMap.value(name)) { + return {pS->isVisibleTo(mpConsole)}; + } + if (auto pN = mpConsole->mSubCommandLineMap.value(name)) { + return {pN->isVisibleTo(mpConsole)}; + } + if (auto pT = mpConsole->mTextBoxMap.value(name)) { + return {pT->isVisibleTo(mpConsole)}; + } + + return {}; +} + void Host::setLargeAreaExitArrows(const bool state) { if (mLargeAreaExitArrows != state) { @@ -5030,7 +5319,7 @@ void Host::setBorders(QMargins borders) auto y = mpConsole->height(); const QSize s = QSize(x, y); QResizeEvent event(s, s); - QApplication::sendEvent(mpConsole, &event); + QCoreApplication::sendEvent(mpConsole, &event); mpConsole->raiseMudletSysWindowResizeEvent(x, y); } diff --git a/src/Host.h b/src/Host.h index d4fad976a..8c49f6b09 100644 --- a/src/Host.h +++ b/src/Host.h @@ -47,6 +47,7 @@ #include <QList> #include <QMargins> #include <QPointer> +#include <QRect> #include <QStack> #include <QTextStream> @@ -56,12 +57,13 @@ #include "TMxpProcessor.h" #include "TMxpFrameManager.h" -class QDialog; +namespace pugi { +class xml_document; +} + class QDockWidget; class QJsonObject; class QKeyEvent; -class QPushButton; -class QListWidget; class TEvent; class TArea; @@ -195,9 +197,11 @@ public: void setPass(const QString& password) { mPass = password; } bool hasAutoLoginCredentials() const { return !mLogin.isEmpty() && !mPass.isEmpty(); } // True once the user has sent any command to the game on the current connection. It gates whether - // an unsolicited GMCP Char.Login.URL may auto-open the browser: a URL that arrives only after the + // an unsolicited GMCP sign-in address may auto-open the browser: one that arrives only after the // player acted (e.g. chose a provider on the game's own sign-in screen) is a consequence of their // input, whereas one at an untouched connection is not and must not silently launch a browser. + // GMCPAuthenticator clears it again when it auto-opens, so one player action can launch at most + // one browser hand-off however many sign-in addresses the game pushes. bool userSentInputThisConnection() const { return mUserSentInputThisConnection; } void setUserSentInputThisConnection(const bool b) { mUserSentInputThisConnection = b; } int getRetries() { return mRetries; } @@ -243,6 +247,7 @@ public: QStringList getValidExperiments() const; void forceClose(); + bool profileResetInProgress() const { return mResetProfile; } bool isClosingDown() const { return mIsClosingDown; } bool isClosingForced() const { return mForcedClose; } bool requestClose(); @@ -353,6 +358,9 @@ public: QString readProfileIniData(const QString& item); void xmlSaved(const QString& xmlName); bool currentlySavingProfile(); + // Whether a package install or uninstall still owes the profile a save - see + // mDeferredSaveTimer. + bool hasPendingProfileSave() const { return mDeferredSaveTimer.isActive(); } void processDiscordGMCP(const QString& packageMessage, const QString& data); void waitForProfileSave(); void clearDiscordData(); @@ -397,6 +405,8 @@ public: void setSearchOptions(const dlgTriggerEditor::SearchOptions); void setBufferSearchOptions(const TConsole::SearchOptions); std::pair<bool, QString> setMapperTitle(const QString&); + std::optional<QString> getMapperTitle() const; + QDockWidget* mapWidget() const; // Multiple map views support std::pair<int, QString> createMapView(int areaId = 0); @@ -431,6 +441,7 @@ public: std::pair<bool, QString> setWindow(const QString& windowname, const QString& name, int x1, int y1, bool show); std::pair<bool, QString> openMapWidget(const QString& area, int x, int y, int width, int height); std::pair<bool, QString> closeMapWidget(); + std::optional<QRect> mapWidgetGeometry() const; bool closeWindow(const QString&); bool echoWindow(const QString&, const QString&); bool pasteWindow(const QString& name); @@ -465,6 +476,8 @@ public: mScreenHeight = height; } std::optional<QString> windowType(const QString& name) const; + std::optional<QRect> windowGeometry(const QString& name) const; + std::optional<bool> windowVisible(const QString& name) const; bool getEditorShowBidi() const { return mEditorShowBidi; } void setEditorShowBidi(const bool); bool caretEnabled() const; @@ -814,7 +827,6 @@ public: bool mMapperCenterSmallAreas = false; bool mVersionInTTYPE = false; QSet<QChar> mDoubleClickIgnore; - QPointer<QDockWidget> mpDockableMapWidget; bool mEnableTextAnalyzer = false; bool mWritingHostAndModules = false; // Set from profile preferences, if the timer interval is less @@ -845,6 +857,12 @@ public: bool mAdvertiseScreenReader = false; bool mEnableClosedCaption = false; + // Turning this off both ignores incoming OSC 8 sequences and advertises 0 + // for them, so a server can fall back to MXP or plain text rather than + // sending links Mudlet will not render. It is checked as sequences are + // decoded, so links already drawn in the buffer stay clickable. + bool mEnableOSC8Hyperlinks = true; + enum class BlankLineBehaviour { Show, Hide, ReplaceWithSpace }; Q_ENUM(BlankLineBehaviour) BlankLineBehaviour mBlankLineBehaviour = BlankLineBehaviour::Show; @@ -879,9 +897,15 @@ signals: void signal_editorThemeChanged(); void signal_remoteEchoChanged(bool enabled); void signal_forceMXPProcessorOnChanged(bool enabled); + // The frontend (TMainConsole) owns the dialogs these drive; the strings are + // built here so they stay in Host's translation context. + void signal_showMapperScriptReminder(); + void signal_showUnpackingProgress(const QString& message, const QString& title); + void signal_hideUnpackingProgress(); private slots: void slot_purgeTemps(); + void slot_saveProfileAfterPackageChange(); private: void setBorders(const QMargins); @@ -896,10 +920,37 @@ private: void createMapper(const bool); void removePackageInfo(const QString& packageName, const bool); static void createModuleBackup(const QString& filename, const QString& saveName); - void writeModule(const QString& moduleName, const QString& filename); + // A single module queued to be written out during a profile save. Its XML document + // is built on the main thread (XMLexport::writeModuleXML()); serializing it to disk + // is deferred to a background task. The job is a complete, self-contained order: + // its own copy of the document plus every path the write needs, so it holds nothing + // whose lifetime the Host controls. It has to: a close that answers "No" to "Save + // profile?", and one that finds the main console already gone, wait for nothing, so + // the Host can be destroyed while the write is still queued. + struct ModuleWriteJob + { + std::shared_ptr<pugi::xml_document> document; + QString moduleName; + QString filename; + QString xmlFilename; + // Empty when this save is not to be backed up first. + QString backupName; + }; + // Main thread only: builds every to-be-synced module's XML document, registers its + // writer in `writers`, resolves every path and creates the directories the write + // needs, returning the jobs a background task should serialize. + QList<ModuleWriteJob> prepareModuleSaves(bool backup); + // Writes the prepared module documents (and updates their zips) to disk. Static on + // purpose: it must keep working after the Host that ordered it has been destroyed, + // so it may not reach for any member. Usually a thread pool task, but a waiter in + // waitForProfileSave() can steal it onto the main thread, so it must suit either. + static void writeModuleFiles(const QList<ModuleWriteJob>& jobs); + static void updateModuleZip(const ModuleWriteJob& job); + // Main thread only: snapshot of the still-pending profile-save futures, so a + // background task never reads `writers`/`saveFutures` while the main thread mutates + // them (that concurrent access is a heap-corrupting data race). + QList<QFuture<bool>> pendingXmlSaveFutures() const; void waitForAsyncXmlSave(); - void saveModules(bool backup = true); - void updateModuleZips(const QString& zipName, const QString& moduleName); void reloadModules(); void startMapAutosave(const int interval); void timerEvent(QTimerEvent* event) override; @@ -925,8 +976,15 @@ private: ActionUnit mActionUnit; KeyUnit mKeyUnit; GifTracker mGifTracker; - // ensures that only one saveProfile call is active when multiple modules are being uninstalled in one go - std::optional<bool> mSaveTimer; + // The profile save that a package/module install or uninstall owes is put off + // to the next event loop pass, so that the asynchronous save mechanism is not + // asked to write out something that was just taken out of memory. Restarting + // this timer also folds a batch of installs/uninstalls into a single save. + // It has to be a member timer rather than a QTimer::singleShot(): a call + // queued on the Host is still delivered after the Host has been destroyed, + // and the save then reads freed members - closeChildren() and ~Host() stop + // this one instead (#9653). + QTimer mDeferredSaveTimer; QFile mErrorLogFile; diff --git a/src/KeyUnit.cpp b/src/KeyUnit.cpp index cabcf81ae..4a7fde13e 100644 --- a/src/KeyUnit.cpp +++ b/src/KeyUnit.cpp @@ -27,6 +27,8 @@ #include "Host.h" #include "TKey.h" +#include <QScopeGuard> + #include <functional> KeyUnit::KeyUnit(Host* pHost) @@ -98,6 +100,9 @@ void KeyUnit::uninstall(const QString& packageName) return; } for (auto& key : uninstallList) { + // in case the key was also queued for the markCleanup()/doCleanup() + // path - deleting it here would otherwise leave a dangling pointer there: + mCleanupSet.remove(key); delete key; } uninstallList.clear(); @@ -108,6 +113,13 @@ bool KeyUnit::processDataStream(const Qt::Key key, const Qt::KeyboardModifiers m bool isMatchFound = false; mProcessingDepth++; + const auto processingGuard = qScopeGuard([this] { + mProcessingDepth--; + Q_ASSERT(mProcessingDepth >= 0); + if (mProcessingDepth == 0) { + doCleanup(); + } + }); for (auto keyObject : mKeyRootNodeList) { // Skip null or invalid key objects during profile closing/destruction @@ -117,24 +129,28 @@ bool KeyUnit::processDataStream(const Qt::Key key, const Qt::KeyboardModifiers m if (keyObject->match(key, modifiers, mRunAllKeyMatches)) { if (!mRunAllKeyMatches) { - mProcessingDepth--; - Q_ASSERT(mProcessingDepth >= 0); - if (mProcessingDepth == 0) { - doCleanup(); - } return true; } isMatchFound = true; } } - mProcessingDepth--; - Q_ASSERT(mProcessingDepth >= 0); - if (mProcessingDepth == 0) { - doCleanup(); + return isMatchFound; +} + +bool KeyUnit::wouldMatch(const Qt::Key key, const Qt::KeyboardModifiers modifiers) const +{ + for (auto keyObject : mKeyRootNodeList) { + if (!keyObject || !keyObject->isActive() || (keyObject->mpHost && keyObject->mpHost->isClosingDown())) { + continue; + } + + if (keyObject->wouldMatch(key, modifiers)) { + return true; + } } - return isMatchFound; + return false; } void KeyUnit::compileAll() @@ -228,15 +244,27 @@ bool KeyUnit::disableKey(const QString& name) bool KeyUnit::killKey(QString& name) { for (auto pChild : mKeyRootNodeList) { - if (pChild->getName() == name) { - // only temporary Keys can be killed - if (!pChild->isTemporary()) { - return false; - } - pChild->setIsActive(false); - markCleanup(pChild); - return true; + if (pChild->getName() != name) { + continue; } + // Names are not unique, so keep looking rather than give up on the first + // same-named key that cannot be killed - a permanent key loaded from the + // profile precedes this session's temporaries in this list, and reporting + // a failure over it would strand a killable key + if (!pChild->isTemporary()) { + // only temporary Keys can be killed + continue; + } + // An already killed key is only unlinked from this list once doCleanup() + // gets to free it, which cannot happen while a key script is on the call + // stack - so until then it is still findable by name. Killing it a second + // time achieves nothing: + if (mCleanupSet.contains(pChild)) { + continue; + } + pChild->setIsActive(false); + markCleanup(pChild); + return true; } return false; } @@ -316,11 +344,12 @@ void KeyUnit::removeKeyRootNode(TKey* pT) if (!pT) { return; } - if (!pT->isTemporary()) { - mLookupTable.remove(pT->getName(), pT); - } else { - mLookupTable.remove(pT->getName()); - } + // Names are not unique - the lookup table is a QMultiMap - so drop this one + // key's entry rather than every entry filed under the name. The + // single-argument remove() used to be taken for temporary keys, which evicted + // live same-named keys and left them unreachable by name for the rest of the + // session + mLookupTable.remove(pT->getName(), pT); mKeyMap.remove(pT->getID()); mKeyRootNodeList.remove(pT); } @@ -382,11 +411,8 @@ void KeyUnit::removeKey(TKey* pT) if (!pT) { return; } - if (!pT->isTemporary()) { - mLookupTable.remove(pT->getName(), pT); - } else { - mLookupTable.remove(pT->getName()); - } + // see removeKeyRootNode(): one entry, not every same-named one + mLookupTable.remove(pT->getName(), pT); mKeyMap.remove(pT->getID()); } @@ -469,17 +495,22 @@ void KeyUnit::doCleanup() return; } + QSet<TKey*> deletedKeys; QMutableSetIterator<TKey*> itKey(mCleanupSet); while (itKey.hasNext()) { auto pKey = itKey.next(); itKey.remove(); + deletedKeys.insert(pKey); delete pKey; } // Flush the deletes uninstall() deferred (#9337). uninstallList is ordered // children-before-parents and each ~Tree unlinks from its parent, so deleting // children first empties the parent's child list (no double free); the seen - // set guards a node queued twice by re-entrant uninstalls. - QSet<TKey*> deletedKeys; + // set guards a node queued twice by re-entrant uninstalls and is shared with + // the mCleanupSet loop above so an object that ended up in both containers is + // freed once. It matches on pointer identity only: a node freed indirectly, as + // a child of a queued parent, is not in the set (not reachable today - only + // temporary root nodes are ever queued, and those have no children). for (auto key : uninstallList) { if (!deletedKeys.contains(key)) { deletedKeys.insert(key); diff --git a/src/KeyUnit.h b/src/KeyUnit.h index 0c2302583..c3f46dae4 100644 --- a/src/KeyUnit.h +++ b/src/KeyUnit.h @@ -69,8 +69,11 @@ public: void uninstall(const QString&); void _uninstall(TKey* pChild, const QString& packageName); bool processDataStream(const Qt::Key, const Qt::KeyboardModifiers); + // Query-only counterpart to processDataStream(), which executes what it matches + bool wouldMatch(const Qt::Key, const Qt::KeyboardModifiers) const; void markCleanup(TKey* pT); void doCleanup(); + int processingDepth() const { return mProcessingDepth; } void stopAllTriggers(); void reenableAllTriggers(); diff --git a/src/LsanHooks.cpp b/src/LsanHooks.cpp new file mode 100644 index 000000000..3a15b5362 --- /dev/null +++ b/src/LsanHooks.cpp @@ -0,0 +1,40 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "LsanSuppressions.h" + +// Inert unless the LeakSanitizer runtime is linked in. They cannot be guarded +// with an "is ASAN on" macro check: Mudlet only applies -fsanitize=address at +// link time, so no compiler macro is set, and Qt's qcompilerdetection.h shims +// __has_feature to 0 on GCC anyway. Built as their own OBJECT library, see +// src/CMakeLists.txt. + +// Keeps third-party noise (GPU drivers, font stack) out of the leak reports +// that testing/PTB builds show users, with no LSAN_OPTIONS set at runtime +extern "C" const char* __lsan_default_suppressions() +{ + return mudletLsanSuppressions; +} + +// Without this, LeakSanitizer appends a "Suppressions used" summary to every +// clean exit, which reads like an error to users: +extern "C" const char* __lsan_default_options() +{ + return "print_suppressions=0"; +} diff --git a/src/LuaInterface.cpp b/src/LuaInterface.cpp index 7cc969ed4..564728731 100644 --- a/src/LuaInterface.cpp +++ b/src/LuaInterface.cpp @@ -49,6 +49,8 @@ LuaInterface::LuaInterface(lua_State* L) lua_atpanic(L, &onPanic); } +// Does not release lrefs: a profile reset closes the lua_State before this +// object is replaced, so unref'ing here would write into a freed state. LuaInterface::~LuaInterface() = default; int LuaInterface::onPanic(lua_State* L) @@ -68,6 +70,19 @@ VarUnit* LuaInterface::getVarUnit() return varUnit.data(); } +lua_State* LuaInterface::getState() const +{ + return mL; +} + +void LuaInterface::releaseVariableReferences() +{ + for (const int ref : std::as_const(lrefs)) { + luaL_unref(mL, LUA_REGISTRYINDEX, ref); + } + lrefs.clear(); +} + QStringList LuaInterface::varName(TVar* var) { QStringList names; @@ -825,17 +840,19 @@ void LuaInterface::getVars(bool hide) //returns the base item // QElapsedTimer t; // t.start(); + // onPanic() longjmp()s to the shared buf, so without a setjmp of our own + // that jump lands in whichever frame set it last - usually one that has + // already returned, taking the caller's scope down with it. + if (setjmp(buf) != 0) { + qWarning() << "LuaInterface::getVars() WARNING - Lua panicked while reading the variables in; the variable tree is incomplete."; + return; + } lua_pushnil(mL); depth = 0; auto global = new TVar(); global->setName("_G", LUA_TSTRING); global->setValue("{}", LUA_TTABLE); - QListIterator<int> it(lrefs); - while (it.hasNext()) { - const int ref = it.next(); - luaL_unref(mL, LUA_REGISTRYINDEX, ref); - } - lrefs.clear(); + releaseVariableReferences(); varUnit->clear(); varUnit->setBase(global); varUnit->addVariable(global); diff --git a/src/LuaInterface.h b/src/LuaInterface.h index c2e531f36..e5f1d93ae 100644 --- a/src/LuaInterface.h +++ b/src/LuaInterface.h @@ -65,12 +65,15 @@ public: void renameVar(TVar*); void createVar(TVar*); VarUnit* getVarUnit(); + // Anything that builds a variable tree and throws it away owes this call: + // ~LuaInterface cannot make it, see there. + void releaseVariableReferences(); bool loadVar(TVar* var); bool reparentCVariable(TVar* from, TVar* to, TVar* curVar); bool reparentVariable(QTreeWidgetItem*, QTreeWidgetItem*, QTreeWidgetItem*); std::pair<bool, QString> validMove(QTreeWidgetItem*); void getAllChildren(TVar* var, QList<TVar*>* list); - lua_State* getState(); + lua_State* getState() const; static int onPanic(lua_State*); private: diff --git a/src/LuaLiteral.cpp b/src/LuaLiteral.cpp new file mode 100644 index 000000000..c34ffcf6c --- /dev/null +++ b/src/LuaLiteral.cpp @@ -0,0 +1,43 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "LuaLiteral.h" + +#include "utils.h" + +QString LuaLiteral::quote(const QString& text) +{ + // Escalate the bracket level until the text can do none of three things: + // close the literal outright; reopen it, which Lua 5.1 rejects under its + // deprecated-nesting rule; or merge with the closing bracket appended after + // it. That last one is why endsWith is here - text ending in ']' followed + // by this level's '=' run is completed into a closing bracket by the first + // character of the closer, shutting the literal one character early. + // Terminates because none of the three patterns fits in text shorter than + // the '=' run it requires. + QString equals; + while (text.contains(qsl("]%1]").arg(equals)) || text.contains(qsl("[%1[").arg(equals)) || text.endsWith(qsl("]%1").arg(equals))) { + equals += QLatin1Char('='); + } + + // Lua discards a newline immediately after the opening bracket, so the + // added one costs nothing and lets text that itself starts with a newline + // survive the round trip. + return qsl("[%1[\n%2]%1]").arg(equals, text); +} diff --git a/src/LuaLiteral.h b/src/LuaLiteral.h new file mode 100644 index 000000000..18c69b770 --- /dev/null +++ b/src/LuaLiteral.h @@ -0,0 +1,34 @@ +#ifndef MUDLET_LUALITERAL_H +#define MUDLET_LUALITERAL_H + +/*************************************************************************** + * Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QString> + +class LuaLiteral +{ +public: + // Quotes arbitrary, possibly hostile text as a Lua long-bracket string + // literal. Callers embedding remote input in generated Lua source must use + // this rather than formatting into "[[%1]]" themselves. + static QString quote(const QString& text); +}; + +#endif // MUDLET_LUALITERAL_H diff --git a/src/OAuthClientFlow.cpp b/src/OAuthClientFlow.cpp index 64ca658a9..c6cdc08eb 100644 --- a/src/OAuthClientFlow.cpp +++ b/src/OAuthClientFlow.cpp @@ -23,7 +23,6 @@ #include "utils.h" #include <QCryptographicHash> -#include <QDesktopServices> #include <QJsonDocument> #include <QJsonObject> #include <QNetworkReply> @@ -192,14 +191,7 @@ void OAuthClientFlow::handleDiscoveryReply(QNetworkReply* reply) mCodeVerifier = generateCodeVerifier(); mState = randomUrlSafeToken(16); - const QUrl authorizationUrl = buildAuthorizationUrl(authorizationEndpoint, mClientId, mScopes, mRedirectUri, mState, codeChallengeS256(mCodeVerifier), mNonce); - - if (QDesktopServices::openUrl(authorizationUrl)) { - emit browserOpened(authorizationUrl.toString()); - } else { - // Keep the listener up: the user can still open the link by hand and complete the sign-in. - emit browserOpenFailed(authorizationUrl.toString()); - } + emit authorizationUrlReady(buildAuthorizationUrl(authorizationEndpoint, mClientId, mScopes, mRedirectUri, mState, codeChallengeS256(mCodeVerifier), mNonce)); } void OAuthClientFlow::handleRedirectConnection() @@ -278,11 +270,15 @@ void OAuthClientFlow::readRedirectRequest(QTcpSocket* socket) respond(socket, "200 OK", tr("You are signed in. You can close this tab and return to Mudlet.")); mCompleted = true; + // Move rather than copy: cleanup() scrubs mNonce next, and clearing a shared QString detaches, + // zeroing a fresh copy while the real value stays in the buffer this one still points at. + QString nonce = std::move(mNonce); cleanup(); - emit authorizationCaptured(code, mCodeVerifier, mRedirectUri); + emit authorizationCaptured(code, mCodeVerifier, mRedirectUri, nonce); // The authorization code and verifier are single-use secrets; the receiver has taken its own copies // (and is responsible for scrubbing them), so drop ours now. SecureStringUtils::secureStringClear(code); + SecureStringUtils::secureStringClear(nonce); SecureStringUtils::secureStringClear(mCodeVerifier); } diff --git a/src/OAuthClientFlow.h b/src/OAuthClientFlow.h index b0230add2..e77c3ef0e 100644 --- a/src/OAuthClientFlow.h +++ b/src/OAuthClientFlow.h @@ -32,10 +32,12 @@ class QNetworkReply; class QTcpSocket; // Runs the client-driven GMCP Char.Login v2 OAuth flow: fetches the server's OpenID Connect -// discovery document, opens the provider's authorization URL in the system browser with a PKCE -// (S256) challenge, and captures the authorization code on a loopback (RFC 8252) redirect -// listener. The token exchange itself stays on the game server - this class only produces the -// {code, code_verifier, redirect_uri} triple that GMCPAuthenticator sends as Char.Login.AuthCode. +// discovery document, builds the provider's authorization URL with a PKCE (S256) challenge, and +// captures the authorization code on a loopback (RFC 8252) redirect listener. The token exchange +// itself stays on the game server - this class only produces the {code, code_verifier, +// redirect_uri, nonce} set that GMCPAuthenticator sends as Char.Login.AuthCode. Handing the +// authorization URL to the system browser is the caller's job, so that the decision to launch a +// browser at the player is made in one place for both this and the server-driven flow. class OAuthClientFlow : public QObject { Q_OBJECT @@ -58,9 +60,8 @@ public: const QString& nonce); signals: - void authorizationCaptured(const QString& code, const QString& codeVerifier, const QString& redirectUri); - void browserOpened(const QString& url); - void browserOpenFailed(const QString& url); + void authorizationCaptured(const QString& code, const QString& codeVerifier, const QString& redirectUri, const QString& nonce); + void authorizationUrlReady(const QUrl& authorizationUrl); void flowFailed(const QString& logDetail); private: diff --git a/src/ScriptUnit.cpp b/src/ScriptUnit.cpp index eca2bbaa1..3810b2981 100644 --- a/src/ScriptUnit.cpp +++ b/src/ScriptUnit.cpp @@ -79,8 +79,44 @@ void ScriptUnit::uninstall(const QString& packageName) uninstallList.append(rootScript); } } - for (auto& script : uninstallList) { - delete script; + // Re-entrant uninstall (#9337): a package's own script (e.g. a package + // auto-updater calling uninstallPackage()) is removing its package while one + // of that package's scripts is still on the call stack - either an event + // handler Host::raiseEvent() is dispatching to, or a top-level body + // TScript::compileScript() is compiling. Deleting now would be a use-after-free + // (of the script still executing, and of the other TScript pointers raiseEvent() + // or ScriptUnit::compileAll() is still iterating), so defer to doCleanup() at + // depth 0. Deactivating is enough to stop the handlers firing for the rest of + // the dispatch: TScript::callEventHandler() checks isActive(). + if (mProcessingDepth > 0) { + for (auto script : uninstallList) { + script->setIsActive(false); + } + return; + } + // At depth 0 delete straight away, but go through doCleanup() rather than a bare + // loop: uninstallList is a member that a prior deferred uninstall may have left + // populated, so a second uninstall of the same still-registered package can queue + // the same pointers twice - doCleanup()'s seen set stops that double-freeing. + doCleanup(); +} + +// Flush the deletes uninstall() deferred (#9337). uninstallList is ordered +// children-before-parents and each ~Tree unlinks from its parent, so deleting +// children first empties the parent's child list (no double free); the seen +// set guards a node queued twice by re-entrant uninstalls. +void ScriptUnit::doCleanup() +{ + if (mProcessingDepth > 0) { + return; + } + + QSet<TScript*> deletedScripts; + for (auto script : uninstallList) { + if (!deletedScripts.contains(script)) { + deletedScripts.insert(script); + delete script; + } } uninstallList.clear(); } @@ -237,11 +273,22 @@ int ScriptUnit::getNewID() void ScriptUnit::compileAll(bool saveLoadingError) { - for (auto script : mScriptRootNodeList) { + // Iterate a snapshot of the root list: a script's top-level body, run by + // compile() below, can uninstall its own package (a package auto-updater + // pattern). uninstall() defers the actual delete whilst compileScript() is on + // the stack, so no node is unlinked mid-loop, but taking a copy keeps the + // iteration safe even against a body that adds or removes root scripts: + const std::vector<TScript*> rootNodes(mScriptRootNodeList.begin(), mScriptRootNodeList.end()); + for (auto script : rootNodes) { if (script->isActive()) { script->compileAll(saveLoadingError); } } + // The loop is now done with the (possibly self-uninstalled) scripts, so flush + // the deletes uninstall() deferred - before the editor tree is rebuilt below and + // before returning to the event loop, where the 0ms save Host::uninstallPackage() + // queues would otherwise serialize the still-live "uninstalled" scripts back in: + doCleanup(); if (mpHost->mpEditorDialog) { mpHost->mpEditorDialog->doCleanReset(); } diff --git a/src/ScriptUnit.h b/src/ScriptUnit.h index e04462793..3f438307d 100644 --- a/src/ScriptUnit.h +++ b/src/ScriptUnit.h @@ -44,15 +44,9 @@ public: explicit ScriptUnit(Host*); ~ScriptUnit(); - std::list<TScript*> getScriptRootNodeList() - { - return mScriptRootNodeList; - } + std::list<TScript*> getScriptRootNodeList() { return mScriptRootNodeList; } - QMap<int, TScript*> getScriptList() - { - return mScriptMap; - } + QMap<int, TScript*> getScriptList() { return mScriptMap; } TScript* getScript(int id); void compileAll(bool saveLoadingError = false); @@ -63,6 +57,17 @@ public: void stopAllTriggers(); void uninstall(const QString&); void _uninstall(TScript* pChild, const QString& packageName); + // Tracks Host::raiseEvent() dispatch nesting so that uninstall() can defer + // deleting a package's scripts while one of their event handlers is still on + // the call stack (e.g. a handler calling uninstallPackage() on its own + // package) - deferred items are flushed by doCleanup() at depth 0: + void beginProcessing() { ++mProcessingDepth; } + void endProcessing() + { + --mProcessingDepth; + Q_ASSERT(mProcessingDepth >= 0); + } + void doCleanup(); int getNewID(); std::vector<int> findItems(const QString& name, const bool exactMatch = true, const bool caseSensitive = true); void resetStats(); @@ -84,6 +89,9 @@ private: QPointer<Host> mpHost; QMap<int, TScript*> mScriptMap; std::list<TScript*> mScriptRootNodeList; + // > 0 whilst Host::raiseEvent() is dispatching to event handlers; uninstall() + // and doCleanup() must not delete scripts then: + int mProcessingDepth = 0; int mMaxID = 0; int statsItemsTotal = 0; int statsTempItems = 0; diff --git a/src/TAction.cpp b/src/TAction.cpp index 2dcd88459..108917d29 100644 --- a/src/TAction.cpp +++ b/src/TAction.cpp @@ -33,6 +33,8 @@ #include "TToolBar.h" #include "mudlet.h" +#include <QScopeGuard> + TAction::TAction(TAction* parent, Host* pHost) : Tree<TAction>(parent) , mpHost(pHost) @@ -161,6 +163,18 @@ void TAction::execute() } } + // Whilst this frame is on the stack ActionUnit::uninstall() must defer + // deleting this profile's actions: the script run below can uninstall its + // own package (e.g. a "reload package" button calling uninstallPackage()) + // and freeing this TAction mid-execute() is a use-after-free - the members + // read after the call would be dangling. The guard defers that delete past + // the last member access here; see ActionUnit::mProcessingDepth. + ActionUnit* pUnit = mpHost->getActionUnit(); + pUnit->beginProcessing(); + const auto processingGuard = qScopeGuard([pUnit] { + pUnit->endProcessing(); + }); + mpHost->mLuaInterpreter.call(mFuncName, mName); // move focus back to the active console / command line: mpHost->setFocusOnHostActiveCommandLine(); @@ -168,6 +182,8 @@ void TAction::execute() void TAction::expandToolbar(TToolBar* pT) { + // The -1 is needed to compensate for the initial pre-increment to TToolBar::mItemCount + pT->resetItemCount(mButtonFillerOffset - 1); for (auto pTAction : *mpMyChildrenList) { if (!pTAction->isActive()) { // This test and conditional loop abort was missing from this method @@ -253,6 +269,8 @@ void TAction::insertActions(TToolBar* pT, QMenu* pMenu) void TAction::expandToolbar(TEasyButtonBar* pT) { + // The -1 is needed to compensate for the initial pre-increment to TEasyButtonBar::mItemCount + pT->resetItemCount(mButtonFillerOffset - 1); for (auto pTAction : *mpMyChildrenList) { if (!pTAction->isActive()) { continue; diff --git a/src/TAction.h b/src/TAction.h index bb730cc1a..b1ff553b4 100644 --- a/src/TAction.h +++ b/src/TAction.h @@ -55,42 +55,111 @@ public: void compileAll(); QString getName() const { return mName; } void setName(const QString& name); - void setButtonRotation(int rotation) { if (rotation != mButtonRotation) { setDataChanged(); mButtonRotation = rotation; } } + void setButtonRotation(int rotation) { + if (rotation != mButtonRotation) { + setDataChanged(); + mButtonRotation = rotation; + } + } int getButtonRotation() const { return mButtonRotation; } - void setButtonColumns(int columns) { if (columns != mButtonColumns) { setDataChanged(); mButtonColumns = columns; } } + void setButtonColumns(int columns) { + if (columns != mButtonColumns) { + setDataChanged(); + mButtonColumns = columns; + } + } int getButtonColumns() const { return mButtonColumns; } bool getButtonFlat() const { return mButtonFlat; } - void setButtonFlat(bool flat) { if (flat != mButtonFlat) { setDataChanged(); mButtonFlat = flat; } } - - void setSizeX(int size) { if (size != mSizeX) { setDataChanged(); mSizeX = size; } } + void setButtonFlat(bool flat) { + if (flat != mButtonFlat) { + setDataChanged(); + mButtonFlat = flat; + } + } + // This should always be called AFTER setButtonColumns! + void setButtonFillerOffset(const int value) + { + const auto newValue = std::max(0, std::min(value, mButtonColumns - 1)); + if (newValue != mButtonFillerOffset) { + setDataChanged(); + mButtonFillerOffset = newValue; + } + } + int getButtonFillerOffset() const { return mButtonFillerOffset; } + void setSizeX(int size) { + if (size != mSizeX) { + setDataChanged(); + mSizeX = size; + } + } int getSizeX() const { return mSizeX; } - void setSizeY(int size) { if (size != mSizeY) { setDataChanged(); mSizeY = size; } } + void setSizeY(int size) { + if (size != mSizeY) { + setDataChanged(); + mSizeY = size; + } + } int getSizeY() const { return mSizeY; } + QSize getSize() const { return {mSizeX, mSizeY}; } void fillMenu(TEasyButtonBar* pT, QMenu* menu); void compile(); bool compileScript(); void execute(); QString getIcon() const { return mIcon; } - void setIcon(const QString& icon) { if (icon != mIcon) { mIcon = icon; } } + void setIcon(const QString& icon) { + if (icon != mIcon) { + mIcon = icon; + } + } QString getScript() const { return mScript; } bool setScript(const QString& script); QString getCommandButtonUp() const { return mCommandButtonUp; } - void setCommandButtonUp(const QString& cmd) { if (cmd != mCommandButtonUp) { setDataChanged(); mCommandButtonUp = cmd; } } - void setCommandButtonDown(const QString& cmd) { if (cmd != mCommandButtonDown) { setDataChanged(); mCommandButtonDown = cmd; } } + void setCommandButtonUp(const QString& cmd) { + if (cmd != mCommandButtonUp) { + setDataChanged(); + mCommandButtonUp = cmd; + } + } + void setCommandButtonDown(const QString& cmd) { + if (cmd != mCommandButtonDown) { + setDataChanged(); + mCommandButtonDown = cmd; + } + } QString getCommandButtonDown() const { return mCommandButtonDown; } - bool isPushDownButton() { return mIsPushDownButton; } - void setIsPushDownButton(bool b) { if (b != mIsPushDownButton) { setDataChanged(); mIsPushDownButton = b; } } + bool isPushDownButton() const { return mIsPushDownButton; } + void setIsPushDownButton(const bool b) { + if (b != mIsPushDownButton) { + setDataChanged(); + mIsPushDownButton = b; + } + } - void setIsFolder(bool b) { if (b != isFolder()) { setDataChanged(); this->Tree::setIsFolder(b);} } + void setIsFolder(bool b) { + if (b != isFolder()) { + setDataChanged(); + this->Tree::setIsFolder(b); + } + } bool registerAction(); void insertActions(TToolBar* pT, QMenu* menu); void expandToolbar(TToolBar* pT); void insertActions(TEasyButtonBar* pT, QMenu* menu); void expandToolbar(TEasyButtonBar* pT); - void setDataSaved() { if (mpParent) { mpParent->setDataSaved(); } mDataChanged = false; } - void setDataChanged() { if (mpParent) { mpParent->setDataChanged(); } mDataChanged = true; } + void setDataSaved() { + if (mpParent) { + mpParent->setDataSaved(); + } + mDataChanged = false; + } + void setDataChanged() { + if (mpParent) { + mpParent->setDataChanged(); + } + mDataChanged = true; + } bool isDataChanged() { return mDataChanged; } QString packageName(TAction* pAction) const; QString moduleName(TAction* pAction) const; @@ -100,42 +169,33 @@ public: QPointer<TEasyButtonBar> mpEasyButtonBar; QPointer<EAction> mpEAction; QPointer<TFlipButton> mpFButton; - // The following was an int but there was confusion over: - // EITHER: "1" = released/unclicked/up & "2" = pressed/clicked/down - // OR: "1" = pressed/clicked/down & "0" = released/unclicked/up - // The Wiki says it should be "1" and "2" but the code sort of did "0"/"1" - // in some places. - // Now uses a boolean: - // "true" = pressed/clicked/down & "false" = released/unclicked/up + /* The following was an int but there was confusion over: + * EITHER: "1" = released/unclicked/up & "2" = pressed/clicked/down, + * OR: "1" = pressed/clicked/down & "0" = released/unclicked/up. + * The Wiki says it should be "1" and "2" but the code sort of did "0"/"1" + * in some places. + * Now uses a boolean: + * "true" = pressed/clicked/down & "false" = released/unclicked/up. + * Only relevant for "push-down" buttons*/ bool mButtonState = false; int mPosX = 0; int mPosY = 0; - // THIS class uses 0 = horizontal, 1 = vertical; c.f. TFlipButton class - // which uses Qt::Orientation enum for the same thing: + /* THIS class uses 0 = horizontal, 1 = vertical. + * c.f. TFlipButton class which uses Qt::Orientation enum + * (1 = Qt::Horizontal, 2 = Qt::Vertical).*/ int mOrientation = 0; - // 0 to 3 are only applicable to the Easy Button Bar buttons/menus (around - // edge of main console: - // 0 = Top "Toolbar" (Easy Button Bar) - // 2 = Left "Toolbar" (Easy Button Bar) - // 3 = Left "Toolbar" (Easy Button Bar) - // 4 = Dockable/floating Toolbar + /* 0, 2, 3 are only applicable to the Easy Button Bar buttons/menus (around + * edge of main console): + * 0 = Top "Toolbar" (Easy Button Bar). + * 1 = Not used since 2009 in commit: c5f404729d46976c6b2c7cf89fd098f5806440c8. + * 2 = Left "Toolbar" (Easy Button Bar). + * 3 = Right "Toolbar" (Easy Button Bar). + * 4 = Dockable/floating Toolbar.*/ int mLocation = 0; - bool mIsPushDownButton = false; bool mNeedsToBeCompiled = true; - QString mIcon; QIcon mIconPix; - // 0 = Horizontal - // 1 = Vertical - // 2 = Vertical + Mirrored - int mButtonRotation = 0; - int mButtonColumns = 1; - // Not currently user accessible but was previously and maintained in game - // saves - and applied to buttons when drawn: - bool mButtonFlat = false; - int mSizeX = 0; - int mSizeY = 0; // Not currently user accessible but was previously and maintained in game // saves - and applied to buttons when drawn: bool mUseCustomLayout = false; @@ -156,6 +216,26 @@ private: QString mFuncName; bool mModuleMember = false; bool mDataChanged = true; + bool mIsPushDownButton = false; // Make private + + QString mIcon; + // 0 = Horizontal + // 1 = Vertical + // 2 = Vertical + Mirrored + int mButtonRotation = 0; + int mButtonColumns = 1; + /* Maximum is one less than the above, and is the number of columns/rows + * the first button/menu in a toolbar must be offset. This replaces the + * prior arrangement that incremented this by one (modulus the + * mButtonColums) each time the toolbar was saved. Since that now happens + * a lot with the undo/redo and auto-save features that is no longer + * sustainable. */ + int mButtonFillerOffset = 0; + /* Not currently user accessible but was previously and maintained in game + * saves - and applied to buttons when drawn: */ + bool mButtonFlat = false; // Make private + int mSizeX = 0; // Make private + int mSizeY = 0; // Make private }; #ifndef QT_NO_DEBUG_STREAM diff --git a/src/TAlias.cpp b/src/TAlias.cpp index d5198f000..708f81267 100644 --- a/src/TAlias.cpp +++ b/src/TAlias.cpp @@ -282,7 +282,7 @@ void TAlias::compileRegex() TDebug(Qt::white, Qt::red) << "REGEX ERROR: failed to compile, reason:\n" << error << "\n" >> mpHost; TDebug(Qt::red, Qt::gray) << TDebug::csmContinue << R"(in: ")" << mRegexCode << "\"\n" >> mpHost; } - setError(qsl("<b><font color='blue'>%1</font></b>").arg(tr(R"(Error: in "Pattern:", faulty regular expression, reason: "%1".)").arg(error))); + setError(qsl("<b>%1</b>").arg(tr(R"(Error: in "Pattern:", faulty regular expression, reason: "%1".)").arg(error))); } else { pcre2_jit_compile(re.data(), PCRE2_JIT_COMPLETE); mOK_init = true; @@ -332,6 +332,17 @@ void TAlias::compile() bool TAlias::setScript(const QString& script) { + // Switching from a registered anonymous Lua function (set up by tempAlias with a + // function argument) to a script string: release the old function from the Lua + // registry and leave callback mode. Otherwise execute() keeps calling the stale + // function so the new script never runs, and the registry entry leaks - the + // destructor would take its mScript-based branch and delete the compiled function. + if (mRegisteredAnonymousLuaFunction) { + if (mpHost) { + mpHost->mLuaInterpreter.delete_luafunction(this); + } + mRegisteredAnonymousLuaFunction = false; + } mScript = script; mNeedsToBeCompiled = true; mOK_code = compileScript(); diff --git a/src/TBuffer.cpp b/src/TBuffer.cpp index ae23df604..8228826c9 100644 --- a/src/TBuffer.cpp +++ b/src/TBuffer.cpp @@ -24,6 +24,7 @@ #include "TBuffer.h" #include "Host.h" +#include "LuaLiteral.h" #include "mudlet.h" #include "TConsole.h" #include "TEvent.h" @@ -32,6 +33,7 @@ #include "THyperlinkSelectionManager.h" #include "TStringUtils.h" #include "TTextEdit.h" +#include "UntrustedText.h" #include "TTextProperties.h" #include "widechar_width.h" #include "TEncodingHelper.h" @@ -226,6 +228,7 @@ TBuffer::TBuffer(const TBuffer& other) , mEchoingText(other.mEchoingText) , mpConsole(other.mpConsole) , mGotESC(other.mGotESC) +, mGotEscCharset(other.mGotEscCharset) , mGotCSI(other.mGotCSI) , mGotOSC(other.mGotOSC) , mGotString(other.mGotString) @@ -271,6 +274,7 @@ TBuffer::TBuffer(const TBuffer& other) , mWrapDetectSamples(other.mWrapDetectSamples) , mIncompleteSequenceBytes(other.mIncompleteSequenceBytes) , mLocalGotESC(other.mLocalGotESC) +, mLocalGotEscCharset(other.mLocalGotEscCharset) , mLocalGotCSI(other.mLocalGotCSI) , mLocalGotOSC(other.mLocalGotOSC) , mLocalGotString(other.mLocalGotString) @@ -321,6 +325,7 @@ TBuffer& TBuffer::operator=(const TBuffer& other) mEchoingText = other.mEchoingText; mpConsole = other.mpConsole; mGotESC = other.mGotESC; + mGotEscCharset = other.mGotEscCharset; mGotCSI = other.mGotCSI; mGotOSC = other.mGotOSC; mGotString = other.mGotString; @@ -366,6 +371,7 @@ TBuffer& TBuffer::operator=(const TBuffer& other) mWrapDetectSamples = other.mWrapDetectSamples; mIncompleteSequenceBytes = other.mIncompleteSequenceBytes; mLocalGotESC = other.mLocalGotESC; + mLocalGotEscCharset = other.mLocalGotEscCharset; mLocalGotCSI = other.mLocalGotCSI; mLocalGotOSC = other.mLocalGotOSC; mLocalGotString = other.mLocalGotString; @@ -580,6 +586,7 @@ void TBuffer::addLink(bool trigMode, const QString& text, QStringList& command, void TBuffer::swapParserSequenceState() { std::swap(mGotESC, mLocalGotESC); + std::swap(mGotEscCharset, mLocalGotEscCharset); std::swap(mGotCSI, mLocalGotCSI); std::swap(mGotOSC, mLocalGotOSC); std::swap(mGotString, mLocalGotString); @@ -588,9 +595,8 @@ void TBuffer::swapParserSequenceState() void TBuffer::translateToPlainText(std::string& incoming, const bool isFromServer) { - // mGotESC/mGotCSI/mGotOSC/mGotString and mIncompleteSequenceBytes persist - // between calls so that a sequence split across Game Server packets still - // parses. + // The mGot... latches and mIncompleteSequenceBytes persist between calls so + // that a sequence split across Game Server packets still parses. // Locally generated text (feedTriggers(), MMCP chat messages, MXP // insertions) runs through the same parser, so swap in a separate set of // that state for the duration of such a feed - otherwise local text @@ -623,6 +629,11 @@ void TBuffer::translateToPlainTextInner(std::string& incoming, const bool isFrom // What can appear in a CSI final byte position - (includes a backslash // which has to be doubled to include it in here): const QByteArray cFinal = QByteArrayLiteral("@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + // The complete two byte escape sequences (DECSC, DECRC, RIS and a stray + // ST) that games do send and that Mudlet has to swallow. Only these: any + // other byte after an ESC is text, and printing it is no worse than what + // Mudlet has always done, whereas eating it loses real output: + const QByteArray cShortEscape = QByteArrayLiteral("78c\\"); // As well as enabling the prepending of left-over bytes from last packet // from the MUD server this may help in high frequency interactions to @@ -749,11 +760,22 @@ void TBuffer::translateToPlainTextInner(std::string& incoming, const bool isFrom } mGotESC = true; + mGotEscCharset = false; ++localBufferPosition; continue; } } + if (mGotEscCharset) { + mGotEscCharset = false; + if (static_cast<unsigned char>(ch) >= 0x30 && static_cast<unsigned char>(ch) <= 0x7E) { + ++localBufferPosition; + continue; + } + // Only a final byte can name a character set, so this was a stray + // ESC after all and the byte is text. + } + if (mGotESC) { mGotESC = false; if (ch == '[' || ch == ']') { @@ -771,16 +793,20 @@ void TBuffer::translateToPlainTextInner(std::string& incoming, const bool isFrom ++localBufferPosition; continue; } - if (static_cast<unsigned char>(ch) >= 0x20) { - // The final byte of some other escape sequence (e.g. ESC 7 - // or ESC M) that Mudlet does not handle - consume it silently - // as a real terminal would instead of showing it as text: + if (ch == '(' || ch == ')' || ch == '*' || ch == '+') { + // An ISO 2022 character set designation such as ESC ( B; the + // byte after this one names the set: + mGotEscCharset = true; ++localBufferPosition; continue; } - // A control character straight after the ESC means the escape - // sequence is malformed - abandon it (the latch was already - // cleared above) and process the character normally: + if (cShortEscape.indexOf(ch) >= 0) { + ++localBufferPosition; + continue; + } + // Any other byte is text: a stray ESC in the game's output must + // not swallow it, and consuming a multibyte character's lead byte + // would orphan its continuation bytes. } if (mGotCSI) { @@ -1599,7 +1625,18 @@ void TBuffer::commitLineData(QString line, std::deque<TChar> chars, const char c const int lineIndex = lineBuffer.size() - 1; mCommitLineIndices.append(lineIndex); if (!mSkipTriggerProcessing) { + // Keep the just-committed formats around so that color triggers + // can match against the colors as received from the game even + // after earlier triggers in this pass have recolored the line; + // save/restore gives nested feedTriggers() passes (which re-enter + // this function) their own snapshot: + std::deque<TChar> savedPassLine = std::move(mPreTriggerPassLine); + const int savedPassLineNumber = mPreTriggerPassLineNumber; + mPreTriggerPassLine = std::move(chars); + mPreTriggerPassLineNumber = lineIndex; mpHost->mpConsole->runTriggers(lineIndex); + mPreTriggerPassLine = std::move(savedPassLine); + mPreTriggerPassLineNumber = savedPassLineNumber; } // Only use of TBuffer::wrap(), breaks up new text @@ -1813,6 +1850,23 @@ void TBuffer::recordLineLengthForWrapDetection(const qsizetype length) }); } +const std::deque<TChar>* TBuffer::preTriggerPassLine(int lineNumber) const +{ + if (lineNumber >= 0 && lineNumber == mPreTriggerPassLineNumber) { + return &mPreTriggerPassLine; + } + return nullptr; +} + +// A structural edit to the trigger-pass line makes the edited text the new +// baseline for color matching, as it was before the snapshot existed: +void TBuffer::syncPreTriggerPassLine(int y) +{ + if (y >= 0 && y == mPreTriggerPassLineNumber && y < static_cast<int>(buffer.size())) { + mPreTriggerPassLine = buffer[y]; + } +} + void TBuffer::processMxpWatchdogCallback() { if (!mpHost) { @@ -2401,6 +2455,10 @@ void TBuffer::decodeSGR(const QString& sequence) qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence << "\") ERROR - failed to detect underline parameter element (the second part) in a SGR...;4:?;..m sequence assuming it is a zero!"; } + // Sub-parameter values follow the widely-adopted kitty/VTE + // convention: 0 none, 1 single, 2 double, 3 curly, 4 dotted, + // 5 dashed. Mudlet has no distinct double-underline style so + // 2 is shown as a plain single underline. switch (value) { case 0: // Underline off mUnderline = false; @@ -2408,29 +2466,35 @@ void TBuffer::decodeSGR(const QString& sequence) mUnderlineDotted = false; mUnderlineDashed = false; break; - case 1: // Underline on (solid) + case 1: // Single (straight) underline mUnderline = true; mUnderlineWavy = false; mUnderlineDotted = false; mUnderlineDashed = false; break; - case 2: // Dashed underline + case 2: // Double underline - unsupported, show as single mUnderline = true; mUnderlineWavy = false; mUnderlineDotted = false; - mUnderlineDashed = true; + mUnderlineDashed = false; break; - case 3: // Dotted underline + case 3: // Curly (wavy) underline + mUnderline = true; + mUnderlineWavy = true; + mUnderlineDotted = false; + mUnderlineDashed = false; + break; + case 4: // Dotted underline mUnderline = true; mUnderlineWavy = false; mUnderlineDotted = true; mUnderlineDashed = false; break; - case 4: // Wavy underline + case 5: // Dashed underline mUnderline = true; - mUnderlineWavy = true; + mUnderlineWavy = false; mUnderlineDotted = false; - mUnderlineDashed = false; + mUnderlineDashed = true; break; default: // Something unexpected qDebug().noquote().nospace() << "TBuffer::decodeSGR(\"" << sequence @@ -3225,8 +3289,16 @@ void TBuffer::decodeOSC(const QString& sequence) break; } + // Deliberately below the terminator branch above: the close is the only + // thing that clears mHyperlinkActive, so refusing it would leave a link + // open forever and mark the rest of the session clickable. Turning the + // preference off mid-link must still let that link finish. + if (!mpHost->mEnableOSC8Hyperlinks) { + return; + } + if (!rawUrl.isEmpty()) { - if (rawUrl.length() > 8192) { + if (rawUrl.length() > static_cast<int>(MAX_OSC_SEQUENCE_LENGTH)) { qWarning() << "TBuffer::decodeOSC(...) - Rejected hyperlink: URL too long:" << rawUrl; return; } @@ -3311,7 +3383,7 @@ void TBuffer::decodeOSC(const QString& sequence) QString customTooltip; if (queryParams.contains(qsl("tooltip"))) { - customTooltip = queryParams.value(qsl("tooltip")); + customTooltip = UntrustedText::forAuthoredText(queryParams.value(qsl("tooltip"))); } // Note: title is now parsed directly into mCurrentHyperlinkStyling by parseJsonHyperlinkConfig @@ -3367,19 +3439,25 @@ void TBuffer::decodeOSC(const QString& sequence) if (baseUrl.startsWith(qsl("send:"))) { QString innerCommand = QUrl::fromPercentEncoding(baseUrl.mid(5).toUtf8()); - command = {qsl("send([[%1]], false)").arg(innerCommand)}; - hint = {qsl("%1: %2").arg(QObject::tr("Send"), innerCommand)}; + mCurrentHyperlinkStyling.actionScheme = Mudlet::HyperlinkStyling::ActionSend; + mCurrentHyperlinkStyling.baseCommand = innerCommand; + command = {qsl("send(%1, false)").arg(LuaLiteral::quote(innerCommand))}; + hint = {qsl("%1: %2").arg(QObject::tr("Send"), UntrustedText::forTarget(innerCommand))}; } else if (baseUrl.startsWith(qsl("prompt:"))) { QString innerCommand = QUrl::fromPercentEncoding(baseUrl.mid(7).toUtf8()); - command = {qsl("sendCmdLine([[%1]])").arg(innerCommand)}; - hint = {qsl("%1: %2").arg(QObject::tr("Prompt"), innerCommand)}; + mCurrentHyperlinkStyling.actionScheme = Mudlet::HyperlinkStyling::ActionPrompt; + mCurrentHyperlinkStyling.baseCommand = innerCommand; + command = {qsl("sendCmdLine(%1)").arg(LuaLiteral::quote(innerCommand))}; + hint = {qsl("%1: %2").arg(QObject::tr("Prompt"), UntrustedText::forTarget(innerCommand))}; } else { QUrl qurl(baseUrl); QString scheme = qurl.scheme().toLower(); if (scheme == qsl("http") || scheme == qsl("https") || scheme == qsl("ftp")) { - command = {qsl("openUrl([[%1]])").arg(baseUrl)}; - hint = {qsl("%1: %2").arg(QObject::tr("Open browser to"), baseUrl)}; + mCurrentHyperlinkStyling.actionScheme = Mudlet::HyperlinkStyling::ActionOpenUrl; + mCurrentHyperlinkStyling.baseCommand = baseUrl; + command = {qsl("openUrl(%1)").arg(LuaLiteral::quote(baseUrl))}; + hint = {qsl("%1: %2").arg(QObject::tr("Open browser to"), UntrustedText::forTarget(baseUrl))}; } else { qWarning().noquote().nospace() << "TBuffer::decodeOSC(...) - Ignored untrusted or unsupported URI scheme: \"" << scheme << "\""; return; @@ -3413,20 +3491,20 @@ void TBuffer::decodeOSC(const QString& sequence) // Determine command type based on prefix if (menuCommand.startsWith(qsl("send:"))) { QString innerCommand = QUrl::fromPercentEncoding(menuCommand.mid(5).toUtf8()); - menuCommands.append(qsl("send([[%1]], false)").arg(innerCommand)); - menuHints.append(menuLabel); + menuCommands.append(qsl("send(%1, false)").arg(LuaLiteral::quote(innerCommand))); + menuHints.append(UntrustedText::forAuthoredText(menuLabel)); } else if (menuCommand.startsWith(qsl("prompt:"))) { QString innerCommand = QUrl::fromPercentEncoding(menuCommand.mid(7).toUtf8()); - menuCommands.append(qsl("sendCmdLine([[%1]])").arg(innerCommand)); - menuHints.append(menuLabel); + menuCommands.append(qsl("sendCmdLine(%1)").arg(LuaLiteral::quote(innerCommand))); + menuHints.append(UntrustedText::forAuthoredText(menuLabel)); } else if (menuCommand == qsl("-")) { // Special case: "-" creates a menu separator menuCommands.append(QString()); menuHints.append(QString()); } else { // Treat as direct command - menuCommands.append(qsl("send([[%1]], false)").arg(menuCommand)); - menuHints.append(menuLabel); + menuCommands.append(qsl("send(%1, false)").arg(LuaLiteral::quote(menuCommand))); + menuHints.append(UntrustedText::forAuthoredText(menuLabel)); } } @@ -3822,14 +3900,14 @@ bool TBuffer::parseJsonHyperlinkConfig(const QString& jsonString, QMap<QString, if (root.contains(qsl("title"))) { QJsonValue titleValue = root[qsl("title")]; if (titleValue.isString()) { - styling.menuTitle = titleValue.toString(); + styling.menuTitle = UntrustedText::forAuthoredText(titleValue.toString()); #if defined(DEBUG_OSC_PROCESSING) qDebug() << "[OSC] Title parameter added:" << titleValue.toString(); #endif } else if (titleValue.isObject()) { QJsonObject titleObj = titleValue.toObject(); if (titleObj.contains(qsl("text")) && titleObj[qsl("text")].isString()) { - styling.menuTitle = titleObj[qsl("text")].toString(); + styling.menuTitle = UntrustedText::forAuthoredText(titleObj[qsl("text")].toString()); } if (titleObj.contains(qsl("style")) && titleObj[qsl("style")].isObject()) { parseJsonStateStyle(titleObj[qsl("style")].toObject(), styling.menuTitleStyle); @@ -4723,6 +4801,10 @@ bool TBuffer::insertInLine(QPoint& P, const QString& text, const TChar& format) if (text.isEmpty()) { return false; } + // Bound a single insert to the same limit the echo/append path uses + // (see appendLine()), so an oversized insertText() cannot consume unbounded + // memory or processing time. + const QString insertedText = (text.size() > MAX_CHARACTERS_PER_ECHO) ? text.left(MAX_CHARACTERS_PER_ECHO) : text; const int x = P.x(); const int y = P.y(); if ((y >= 0) && (y < static_cast<int>(buffer.size()))) { @@ -4733,14 +4815,14 @@ bool TBuffer::insertInLine(QPoint& P, const QString& text, const TChar& format) TChar c(mpConsole); expandLine(y, x - buffer.at(y).size(), c); } - for (int i = 0, total = text.size(); i < total; ++i) { - lineBuffer[y].insert(x + i, text.at(i)); - const TChar c = format; - auto it = buffer[y].begin(); - buffer[y].insert(it + x + i, c); - } + // Insert the whole run in one operation. Inserting one character at a + // time into the middle of the QString/std::deque is O(n) per character, + // which is quadratic for large inserts. + lineBuffer[y].insert(x, insertedText); + buffer[y].insert(buffer[y].begin() + x, static_cast<std::size_t>(insertedText.size()), format); + syncPreTriggerPassLine(y); } else { - appendLine(text, 0, text.size(), format.mFgColor, format.mBgColor, format.mFlags); + appendLine(insertedText, 0, insertedText.size(), format.mFgColor, format.mBgColor, format.mFlags); } return true; } @@ -4945,7 +5027,7 @@ inline QList<WrapInfo> TBuffer::getWrapInfo(const QString& lineText, bool isNewl xPos = 0; continue; } - int nextBoundary = boundaryFinder.toNextBoundary(); + const int nextBoundary = boundaryFinder.toNextBoundary(); const QString grapheme = lineText.mid(indexOfChar, nextBoundary - indexOfChar); const uint unicode = graphemeInfo::getBaseCharacter(grapheme); // Safety check: during destruction, mpHost might be null @@ -4963,13 +5045,21 @@ inline QList<WrapInfo> TBuffer::getWrapInfo(const QString& lineText, bool isNewl const int firstNonIndentChar = firstChar + (needsIndent ? 0 : indentationHere); if (c == QChar::Space or lineBreakFinder.isAtBoundary() or lineBreakFinder.toPreviousBoundary() <= firstNonIndentChar) { boundaryFinder.setPosition(indexOfChar); - output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar)); } else { indexOfChar = lineBreakFinder.position(); - nextBoundary = lineBreakFinder.position(); - boundaryFinder.setPosition(nextBoundary); - output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar)); + boundaryFinder.setPosition(indexOfChar); } + if (indexOfChar <= firstChar) { + // no room for even one grapheme - either the wrap width is too + // narrow (or zero) or the indentation eats all of it. Breaking + // here would produce an empty segment and leave indexOfChar + // where it was, looping forever, so keep one grapheme on the + // line to guarantee the scan moves on + indexOfChar = (nextBoundary > firstChar) ? nextBoundary : firstChar + 1; + boundaryFinder.setPosition(indexOfChar); + totalWidth += charWidth; + } + output.append(WrapInfo(isNewline, needsIndent, firstChar, indexOfChar)); isNewline = false; needsIndent = true; xPos = 0; @@ -5294,6 +5384,7 @@ bool TBuffer::replaceInLine(QPoint& P_begin, QPoint& P_end, const QString& with, auto it1 = buffer[y].begin() + x; auto it2 = buffer[y].begin() + x_end; buffer[y].erase(it1, it2); + syncPreTriggerPassLine(y); } // insert replacement @@ -5303,6 +5394,29 @@ bool TBuffer::replaceInLine(QPoint& P_begin, QPoint& P_end, const QString& with, void TBuffer::clear() { + // Clearing the display is not gagging: flush any deferred log text before + // the deleteLines() calls below would discard it, so a received line that + // was still pending for logging is not lost from the log file + if (!mpHost.isNull() && mpHost->mpConsole && this == &mpHost->mpConsole->buffer && mpHost->mpConsole->mLogToLogFile) { + logRemainingOutput(); + + // A line whose own trigger calls clearWindow() has been committed and + // displayed, but commitLine() defers its log() call until runTriggers() + // returns - and the deleteLines() below will reset its commit index to + // -1, suppressing that call. Log those still-pending lines now so they + // are not lost. mCommitLineIndices holds them in display order. + for (const int commitLineIndex : mCommitLineIndices) { + if (commitLineIndex >= 0 && commitLineIndex < static_cast<int>(lineBuffer.size())) { + mpHost->mpConsole->mLogStream << assembleLog(commitLineIndex, commitLineIndex); + } + } + mpHost->mpConsole->mLogStream.flush(); + + lastTextToLog.clear(); + lastLoggedFromLine = -1; + lastloggedToLine = -1; + } + mCurrentHyperlinkCommand.clear(); mCurrentHyperlinkHint.clear(); mCurrentHyperlinkLinkId = 0; @@ -5426,6 +5540,7 @@ void TBuffer::shrinkBuffer() // We need to adjust the search result line as some lines have now gone // away: mpConsole->mCurrentSearchResult = qMax(0, mpConsole->mCurrentSearchResult - mBatchDeleteSize); + mPreTriggerPassLineNumber = -1; // The removed leading lines shift every remaining index down; keep the // deferred logging state pointing at the same lines @@ -5470,6 +5585,9 @@ bool TBuffer::deleteLines(int from, int to) } buffer.erase(buffer.begin() + from, buffer.begin() + to + 1); + if (mPreTriggerPassLineNumber >= from) { + mPreTriggerPassLineNumber = -1; + } // Keep the deferred logging state in step with the removed lines so // pending text is only dropped when the lines it holds were deleted @@ -5727,7 +5845,7 @@ QString TBuffer::bufferToHtml(const bool showTimeStamp /*= false*/, const int ro // we will NOT need a closing "</span>" if (showTimeStamp && !timeBuffer.at(row).isEmpty()) { // Use the console's background so the timestamp blends in with the - // rest of the text, as done in TTextEdit::drawLine(...). + // rest of the text, as done in TTextEdit::layoutLine(...). const QColor timeStampBgColor{mpConsole ? mpConsole->getConsoleBgColor() : QColor(Qt::black)}; s.append(qsl("<span style=\"color: rgb(200,150,0); background: %1; \">%2").arg(timeStampBgColor.name(), timeBuffer.at(row).left(mudlet::smTimeStampFormat.length()))); // Set the current idea of what the formatting is so we can spot if it diff --git a/src/TBuffer.h b/src/TBuffer.h index f0c73ad14..0bbd87f60 100644 --- a/src/TBuffer.h +++ b/src/TBuffer.h @@ -292,10 +292,10 @@ class TBuffer static inline const int TCHAR_IN_BYTES = sizeof(TChar); +public: // limit on how many characters a single echo can accept for performance reasons static inline const int MAX_CHARACTERS_PER_ECHO = 1000000; -public: explicit TBuffer(Host* pH, TConsole* pConsole = nullptr); ~TBuffer(); TBuffer(const TBuffer& other); @@ -312,6 +312,9 @@ public: int size() { return static_cast<int>(buffer.size()); } bool isEmpty() const { return buffer.size() == 0; } QString& line(int lineNumber); + // Colors of the current trigger-pass line as committed, before any + // trigger ran; nullptr when lineNumber is not the line being processed: + const std::deque<TChar>* preTriggerPassLine(int lineNumber) const; int find(int line, const QString& what, int pos); QStringList split(int line, const QString& splitter); QStringList split(int line, const QRegularExpression& splitter); @@ -391,6 +394,7 @@ public: private: inline QList<WrapInfo> getWrapInfo(const QString& lineText, bool isNewline, const int maxWidth, const int indent, const int hangingIndent); void shrinkBuffer(); + void syncPreTriggerPassLine(int y); int calculateWrapPosition(int lineNumber, int begin, int end); void handleNewLine(); void translateToPlainTextInner(std::string& incoming, bool isFromServer); @@ -444,6 +448,9 @@ private: // First stage in decoding SGR/OCS sequences - set true when we see the // ASCII ESC character: bool mGotESC = false; + // Set between the ESC '(', ')', '*' or '+' of an ISO 2022 character set + // designation and the byte that names the set: + bool mGotEscCharset = false; // Second stage in decoding SGR sequences - set true when we see the ASCII // ESC character followed by the '[' one: bool mGotCSI = false; @@ -503,6 +510,8 @@ private: QString mMudLine; std::deque<TChar> mMudBuffer; + std::deque<TChar> mPreTriggerPassLine; + int mPreTriggerPassLineNumber = -1; // A line that ended at the game's own wrap column (Host::mUndoServerWrap) // is held here instead of being committed, so its continuation can be // joined back on and triggers run once over the whole logical line: @@ -523,13 +532,14 @@ private: // translateToPlainText()}: std::string mIncompleteSequenceBytes; - // The parser sequence state (mGotESC, mGotCSI, mGotOSC, mGotString and + // The parser sequence state (the mGot... latches and // mIncompleteSequenceBytes) for whichever of the two data channels - Game // Server stream or locally generated text - is not currently being // processed; translateToPlainText() swaps it in around a local feed so // that such text cannot consume or clear a latch belonging to a sequence // split across Game Server packets (and vice versa): bool mLocalGotESC = false; + bool mLocalGotEscCharset = false; bool mLocalGotCSI = false; bool mLocalGotOSC = false; bool mLocalGotString = false; diff --git a/src/TCommandLine.cpp b/src/TCommandLine.cpp index b99605d2f..60d2482b9 100644 --- a/src/TCommandLine.cpp +++ b/src/TCommandLine.cpp @@ -163,6 +163,20 @@ bool TCommandLine::keybindingMatched(QKeyEvent* keyEvent) return false; } +bool TCommandLine::keybindingWouldMatchProfileSwitchShortcut(const QKeyEvent* keyEvent) const +{ + if (!mpKeyUnit || (mpHost && mpHost->isClosingDown())) { + return false; + } + + auto* pMudlet = mudlet::self(); + if (!pMudlet || !pMudlet->profileSwitchShortcutMatches(keyEvent)) { + return false; + } + + return mpKeyUnit->wouldMatch(static_cast<Qt::Key>(keyEvent->key()), keyEvent->modifiers()); +} + // This function overrides the QWidget::event() and should return true if the // event was recognized, otherwise it should return false. If the recognized // event was accepted (see QEvent::accepted), any further processing such as @@ -186,6 +200,15 @@ bool TCommandLine::event(QEvent* event) ke->accept(); return true; } + + // QShortcutMap consumes a key matching one of the profile switching + // shortcuts before the KeyPress ever reaches here, so a user binding on + // one has to be spotted now - and only spotted, since the binding runs + // off the KeyPress this claim lets through: + if (keybindingWouldMatchProfileSwitchShortcut(ke)) { + ke->accept(); + return true; + } } const Qt::KeyboardModifiers allModifiers = Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier | Qt::KeypadModifier | Qt::GroupSwitchModifier; diff --git a/src/TCommandLine.h b/src/TCommandLine.h index 99a519a3c..97b06804f 100644 --- a/src/TCommandLine.h +++ b/src/TCommandLine.h @@ -116,6 +116,7 @@ private: void enterCommand(QKeyEvent*); void processNormalKey(QEvent*); bool keybindingMatched(QKeyEvent*); + bool keybindingWouldMatchProfileSwitchShortcut(const QKeyEvent*) const; void spellCheckWord(QTextCursor& c); bool handleCtrlTabChange(QKeyEvent* key, int tabNumber); void restoreHistory(); diff --git a/src/TConsole.cpp b/src/TConsole.cpp index 081259f37..3386245cd 100644 --- a/src/TConsole.cpp +++ b/src/TConsole.cpp @@ -589,6 +589,10 @@ TConsole::TConsole(Host* pH, const QString& name, const ConsoleType type, QWidge mHScrollBarEnabled = true; } + // a Buffer is never displayed and the three types below start with their + // scroll bar hidden, so only the main and debug consoles begin with one + mScrollBarEnabled = !(mType & (ErrorConsole | SubConsole | UserWindow | Buffer)); + if (mType & (ErrorConsole | SubConsole | UserWindow)) { mpScrollBar->hide(); mLowerPane->hide(); @@ -743,6 +747,12 @@ void TConsole::resizeEvent(QResizeEvent* event) layerCommandLine->move(0, mpBaseVFrame->height() - layerCommandLine->height()); } + // MXP frames are positioned by hand against the space the borders leave, so + // they have to be moved whenever the window or those borders change + if ((mType & MainConsole) && !mpHost.isNull()) { + mpHost->mMxpFrameManager.scheduleRelayout(); + } + // Sync Host dimensions on resize so wraps and NAWS reflect the current pane width. if ((mType & MainConsole) && !mpHost.isNull() && mUpperPane && !mUpperPane->visibleRegion().isEmpty()) { const int paneWidthPx = mUpperPane->visibleRegion().boundingRect().width(); @@ -870,6 +880,9 @@ void TConsole::refresh() void TConsole::clear() { mUpperPane->resetHScrollbar(); + // before the buffer goes, or the selection is left pointing at lines that + // no longer exist and the copy actions work on out of range indices + clearSelection(); buffer.clear(); clearSplit(); mUpperPane->update(); @@ -1584,11 +1597,26 @@ bool TConsole::setWindowBackgroundImage(const QString& imgPath, int mode) if (mode == 5) { QPixmap pixmap(imgPath); if (pixmap.isNull()) { + qWarning().nospace().noquote() << "TConsole::setWindowBackgroundImage() ERROR - could not load \"" << imgPath << "\" as an image."; return false; } + const QPixmap previousSource = mWindowBgSourcePixmap; + const QString previousPath = mWindowBgImagePath; + const QString previousStyleSheet = mpWindowBackground->styleSheet(); mWindowBgSourcePixmap = pixmap; + mWindowBgImagePath = imgPath; + // clearing a stylesheet repolishes the widget and drops the palette brush, + // so it has to happen before the brush is installed mpWindowBackground->setStyleSheet(QString()); - updateWindowBackgroundCoverPixmap(); + if (!updateWindowBackgroundCoverPixmap()) { + mWindowBgSourcePixmap = previousSource; + mWindowBgImagePath = previousPath; + mpWindowBackground->setStyleSheet(previousStyleSheet); + // the failed attempt dropped the brush, so rebuild the one the previous + // source was showing rather than waiting for the next resize + updateWindowBackgroundCoverPixmap(); + return false; + } } else { const QColor bgColor = mpHost ? mpHost->mBgColor : QColorConstants::Black; const QString styleSheet = buildBackgroundImageStyleSheet(qsl("WindowBackground"), bgColor, mode, imgPath); @@ -1632,30 +1660,69 @@ void TConsole::updateMainFrameTransparency() QPalette framePalette; framePalette.setColor(QPalette::Text, QColor(Qt::black)); framePalette.setColor(QPalette::Highlight, QColor(55, 55, 255)); - framePalette.setColor(QPalette::Window, mWindowBgImageMode ? QColor(0, 0, 0, 0) : QColor(0, 0, 0, 255)); + framePalette.setColor(QPalette::Window, mWindowBgImageMode ? QColor(0, 0, 0, 0) : mBorderColor); mpMainFrame->setPalette(framePalette); mpMainFrame->setAutoFillBackground(true); } -// Simulates CSS "cover" since QT stylesheets do not support it -void TConsole::updateWindowBackgroundCoverPixmap() +void TConsole::setBorderColor(const QColor& color) +{ + mBorderColor = color; + updateMainFrameTransparency(); +} + +void TConsole::lowerMainDisplay() +{ + mpMainDisplay->lower(); + if (mpWindowBackground) { + mpWindowBackground->lower(); + } +} + +// The largest centred rectangle of the source that has the target's aspect ratio. +QRect TConsole::coverSourceRect(const QSize& sourceSize, const QSize& targetSize) +{ + QSize cropSize = targetSize; + cropSize.scale(sourceSize, Qt::KeepAspectRatio); + cropSize = cropSize.boundedTo(sourceSize).expandedTo(QSize(1, 1)); + return QRect(QPoint((sourceSize.width() - cropSize.width()) / 2, (sourceSize.height() - cropSize.height()) / 2), cropSize); +} + +// Simulates CSS "cover" since QT stylesheets do not support it. Crop first: the +// other order multiplies the intermediate by the aspect mismatch, so a 3000x100 +// image in a 1920x1080 window builds a 32400x1080 (~140MB) one on every resize. +bool TConsole::updateWindowBackgroundCoverPixmap() { if (!mpWindowBackground || mWindowBgSourcePixmap.isNull()) { - return; + return true; } const QSize targetSize = mpWindowBackground->size(); if (targetSize.isEmpty()) { - return; + return true; } - const QPixmap scaled = mWindowBgSourcePixmap.scaled(targetSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - const QRect cropRect(qMax(0, (scaled.width() - targetSize.width()) / 2), qMax(0, (scaled.height() - targetSize.height()) / 2), targetSize.width(), targetSize.height()); + const QRect sourceRect = coverSourceRect(mWindowBgSourcePixmap.size(), targetSize); + const QPixmap cropped = (sourceRect == mWindowBgSourcePixmap.rect()) ? mWindowBgSourcePixmap : mWindowBgSourcePixmap.copy(sourceRect); + const QPixmap scaled = cropped.scaled(targetSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + if (scaled.isNull()) { + if (!mWindowBgCoverScaleFailed) { + mWindowBgCoverScaleFailed = true; + qWarning().nospace().noquote() << "TConsole::updateWindowBackgroundCoverPixmap() ERROR - could not scale \"" << mWindowBgImagePath << "\" (source area " << sourceRect << ") to " + << targetSize << '.'; + } + // a brush smaller than the widget tiles, so drop the stale one + mpWindowBackground->setAutoFillBackground(false); + mpWindowBackground->setPalette(QPalette()); + return false; + } + mWindowBgCoverScaleFailed = false; QPalette palette; - palette.setBrush(QPalette::Window, QBrush(scaled.copy(cropRect))); + palette.setBrush(QPalette::Window, QBrush(scaled)); mpWindowBackground->setPalette(palette); mpWindowBackground->setAutoFillBackground(true); + return true; } void TConsole::setCmdVisible(bool isVisible) @@ -1675,7 +1742,7 @@ void TConsole::setCmdVisible(bool isVisible) mpCommandLine->setFont(font()); // put this CommandLine in the mainConsoles SubCommandLineMap // name is the console name - mpHost->mpConsole->mSubCommandLineMap[mConsoleName] = mpCommandLine; + mpHost->mpConsole->registerSubCommandLine(mConsoleName, mpCommandLine); layoutLayer2->addWidget(mpCommandLine); } if (mType == MainConsole) { @@ -1948,10 +2015,20 @@ void TConsole::setCommandFgColor(const QColor& newColor) void TConsole::setScrollBarVisible(bool isVisible) { if (mpScrollBar) { + mScrollBarEnabled = isVisible; mpScrollBar->setVisible(isVisible); } } +// Reports what enableScrollBar()/disableScrollBar() last asked for rather than +// QWidget::isVisible(): a profile that is not the front tab has its whole +// console hidden, which would otherwise make every background profile report +// its scroll bar as gone. +bool TConsole::getScrollBarVisible() const +{ + return mScrollBarEnabled; +} + void TConsole::setHorizontalScrollBar(bool isEnabled) { if (mpHScrollBar) { diff --git a/src/TConsole.h b/src/TConsole.h index fe642f2f4..ba68cfc78 100644 --- a/src/TConsole.h +++ b/src/TConsole.h @@ -77,6 +77,7 @@ struct TFontAttributes bool operator==(const TFontAttributes& other) const = default; bool operator!=(const TFontAttributes& other) const = default; + TFontAttributes(const TFontAttributes& other) = default; TFontAttributes& operator=(const TFontAttributes& other) = default; QFont makeFont() const @@ -251,6 +252,7 @@ public: void setCommandFgColor(const QColor&); void setCommandFgColor(int, int, int, int); void setScrollBarVisible(bool); + bool getScrollBarVisible() const; void setHorizontalScrollBar(bool); void setScrolling(const bool state); bool getScrolling() const { return mScrollingEnabled; } @@ -294,7 +296,12 @@ public: bool setWindowBackgroundImage(const QString&, int); bool resetWindowBackgroundImage(); void updateMainFrameTransparency(); - void updateWindowBackgroundCoverPixmap(); + // False only when a scale failed; no source or an unsized widget defers to the next resize + bool updateWindowBackgroundCoverPixmap(); + static QRect coverSourceRect(const QSize& sourceSize, const QSize& targetSize); + void setBorderColor(const QColor&); + QColor borderColor() const { return mBorderColor; } + void lowerMainDisplay(); void setLink(const QStringList& linkFunction, const QStringList& linkHint, const QVector<int> linkReference = QVector<int>()); // Cannot be called setAttributes as that would mask an inherited method void setDisplayAttributes(const TChar::AttributeFlags, const bool); @@ -431,6 +438,7 @@ public: QString mWindowBgImagePath; QPixmap mWindowBgSourcePixmap; bool mHScrollBarEnabled = false; + bool mScrollBarEnabled = true; ControlCharacterMode mControlCharacter = ControlCharacterMode::AsIs; QVideoWidget* mpVideoWidget = nullptr; QSplitter* commandSplitter = nullptr; @@ -495,6 +503,10 @@ private: // Whether to show (a 13 character by default) timestamp to the left of // each line of text: bool mShowTimeStamps = false; + // mpMainFrame's palette cannot hold this - it is rebuilt from scratch on every colour change + QColor mBorderColor = Qt::black; + // latches the 'cover' scale failure so a resize drag does not repeat the warning + bool mWindowBgCoverScaleFailed = false; }; Q_DECLARE_OPERATORS_FOR_FLAGS(TConsole::ConsoleType) diff --git a/src/TDetachedWindow.cpp b/src/TDetachedWindow.cpp index 8199db245..b314e4dc7 100644 --- a/src/TDetachedWindow.cpp +++ b/src/TDetachedWindow.cpp @@ -126,9 +126,9 @@ TDetachedWindow::~TDetachedWindow() if (auto pHost = mudletInstance->getHostManager().getHost(profileName)) { auto pMap = pHost->mpMap.data(); - if (pMap && pHost->mpDockableMapWidget) { + if (pMap && pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { // Find the main window's mapper - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; @@ -151,9 +151,9 @@ TDetachedWindow::~TDetachedWindow() if (auto pHost = mudletInstance->getHostManager().getHost(mCurrentProfileName)) { auto pMap = pHost->mpMap.data(); - if (pMap && pHost->mpDockableMapWidget) { + if (pMap && pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { // Find the main window's mapper - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; @@ -1561,8 +1561,8 @@ void TDetachedWindow::updateDockWidgetVisibilityForProfile(const QString& profil if (auto pMudlet = mudlet::self()) { if (auto pHost = pMudlet->getHostManager().getHost(dockProfileName)) { if (auto pMap = pHost->mpMap.data()) { - if (pHost->mpDockableMapWidget) { - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; @@ -2054,8 +2054,8 @@ bool TDetachedWindow::removeProfile(const QString& profileName) if (auto pMudlet = mudlet::self()) { if (auto pHost = pMudlet->getHostManager().getHost(profileName)) { if (auto pMap = pHost->mpMap.data()) { - if (pHost->mpDockableMapWidget) { - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; @@ -2785,8 +2785,8 @@ void TDetachedWindow::slot_showMapperDialog() mpMapDockWidget = nullptr; // Restore the main window's mapper as the active one - if (pHost->mpDockableMapWidget) { - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; } @@ -2802,7 +2802,7 @@ void TDetachedWindow::slot_showMapperDialog() // Store the main window's mapper temporarily so we can restore it later QPointer<dlgMapper> mainMapper = pMap->mpMapper; - QPointer<QDockWidget> mainDockWidget = pHost->mpDockableMapWidget; + QPointer<QDockWidget> mainDockWidget = (pHost->mpConsole ? pHost->mpConsole->mpDockableMapWidget : nullptr); // Create a new mapper instance for the detached window // We need to copy player room style details first @@ -2881,8 +2881,8 @@ void TDetachedWindow::slot_showMapperDialog() } // Restore the main window's mapper as the active one when hiding - if (pHost->mpDockableMapWidget) { - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; } @@ -3251,8 +3251,8 @@ void TDetachedWindow::addTransferredDockWidget(const QString& mapKey, QDockWidge } // Restore the main window's mapper as the active one when hiding - if (pHost->mpDockableMapWidget) { - auto mainMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto mainMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto mainMapper = qobject_cast<dlgMapper*>(mainMapWidget)) { pMap->mpMapper = mainMapper; diff --git a/src/TDockWidget.cpp b/src/TDockWidget.cpp index 0cab7209e..dd7cafeaf 100644 --- a/src/TDockWidget.cpp +++ b/src/TDockWidget.cpp @@ -30,6 +30,7 @@ TDockWidget::TDockWidget(Host* pH, const QString& consoleName) , mWidgetConsoleName(consoleName) , mpHost(pH) { + setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable); } // This sets the mutual pointers that the TConsole and the TDockWidget now diff --git a/src/TEasyButtonBar.cpp b/src/TEasyButtonBar.cpp index c3d90a506..3a06f96bb 100644 --- a/src/TEasyButtonBar.cpp +++ b/src/TEasyButtonBar.cpp @@ -28,6 +28,7 @@ #include "TFlipButton.h" #include <QGridLayout> +#include <QScopeGuard> TEasyButtonBar::TEasyButtonBar(TAction* pA, QString name, QWidget* pW) @@ -50,11 +51,9 @@ TEasyButtonBar::TEasyButtonBar(TAction* pA, QString name, QWidget* pW) const QSizePolicy sizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); mpWidget->setSizePolicy(sizePolicy); } else { - mpWidget->setMinimumHeight(mpTAction->mSizeY); - mpWidget->setMaximumHeight(mpTAction->mSizeY); - mpWidget->setMinimumWidth(mpTAction->mSizeX); - mpWidget->setMaximumWidth(mpTAction->mSizeX); - mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->mSizeX, mpTAction->mSizeY); + mpWidget->setMaximumSize(mpTAction->getSize()); + mpWidget->setMinimumSize(mpTAction->getSize()); + mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->getSizeX(), mpTAction->getSizeY()); } setStyleSheet(mpTAction->css); mpWidget->setStyleSheet(mpTAction->css); @@ -74,11 +73,11 @@ void TEasyButtonBar::addButton(TFlipButton* pB) } } else { qDebug() << "setting up custom sizes"; - const QSize size = QSize(pB->mpTAction->mSizeX, pB->mpTAction->mSizeY); + const QSize size = pB->mpTAction->getSize(); pB->setMaximumSize(size); pB->setMinimumSize(size); pB->setParent(mpWidget); - pB->setGeometry(pB->mpTAction->mPosX, pB->mpTAction->mPosY, pB->mpTAction->mSizeX, pB->mpTAction->mSizeY); + pB->setGeometry(pB->mpTAction->mPosX, pB->mpTAction->mPosY, pB->mpTAction->getSizeX(), pB->mpTAction->getSizeY()); } pB->setStyleSheet(pB->mpTAction->css); @@ -100,12 +99,8 @@ void TEasyButtonBar::addButton(TFlipButton* pB) if (!mpTAction->mUseCustomLayout) { // tool bar mButtonColumns > 0 -> autolayout // case == 0: use individual button placement for user defined layouts - int columns = mpTAction->getButtonColumns(); - if (columns <= 0) { - columns = 1; - } - mItemCount++; - const int row = mItemCount / columns; + int columns = std::max(1, mpTAction->getButtonColumns()); + const int row = ++mItemCount / columns; const int col = mItemCount % columns; if (mVerticalOrientation) { mpLayout->addWidget(pB, row, col); @@ -127,21 +122,23 @@ void TEasyButtonBar::addButton(TFlipButton* pB) void TEasyButtonBar::finalize() { - if (mpTAction->mUseCustomLayout) { + if (mpTAction->mUseCustomLayout || !mpTAction->getButtonFillerOffset()) { return; } - auto fillerWidget = new QWidget; - - const QSizePolicy sizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - fillerWidget->setSizePolicy(sizePolicy); - int columns = mpTAction->getButtonColumns(); - if (columns <= 0) { - columns = 1; - } - const int row = (++mItemCount) / columns; - const int column = mItemCount % columns; + auto fillerWidget = new QWidget(this); + QPushButton dummy; + fillerWidget->setMinimumSize(dummy.minimumSizeHint()); + fillerWidget->setMaximumSize(dummy.minimumSizeHint()); if (mpLayout) { - mpLayout->addWidget(fillerWidget, row, column); + if (mpTAction->mOrientation == 1) { + // The toolbar is to be filled with rows of mpTAction->getButtonColumns() wide + // The filler widget is to be one or more columns wide + mpLayout->addWidget(fillerWidget, 0, 0, mpTAction->getButtonFillerOffset(), 1); + } else { + // The toolbar is to be filled with columns of mpTAction->getButtonColumns() tall + // The filler widget is to be one or more rows tall + mpLayout->addWidget(fillerWidget, 0, 0, 1, mpTAction->getButtonFillerOffset()); + } } } @@ -156,6 +153,19 @@ void TEasyButtonBar::slot_pressed(const bool isChecked) TAction* pA = pB->mpTAction; + // Hold off ActionUnit deletes for this whole slot: showMenu() below blocks in + // a modal event loop in which a menu item's script (or inbound game data) can + // uninstall pA's own package. beginProcessing() keeps that delete deferred - + // even against a Host catch-all doCleanup() firing at depth 0 mid-loop - so pA + // survives every dereference here; the scope guard then flushes once, after pA + // is no longer touched (see ActionUnit::uninstall()): + ActionUnit* pActionUnit = pA->mpHost->getActionUnit(); + pActionUnit->beginProcessing(); + const auto processingGuard = qScopeGuard([pActionUnit] { + pActionUnit->endProcessing(); + pActionUnit->doCleanup(); + }); + // NOTE: This function blocks until an item is selected from the menu, and, // as the action to "pop-up" the menu is the same as "buttons" use to // perform their command/scripts is why "commands" are (no longer) permitted @@ -164,7 +174,7 @@ void TEasyButtonBar::slot_pressed(const bool isChecked) // entries... pB->showMenu(); - if (pA->mIsPushDownButton) { + if (pA->isPushDownButton()) { // DO NOT MANIPULATE THE BUTTON STATE OURSELF NOW pA->mButtonState = isChecked; pA->mpHost->mpConsole->mButtonState = (pA->mButtonState ? 2 : 1); @@ -202,11 +212,9 @@ void TEasyButtonBar::clear() mpWidget->setContentsMargins(0, 0, 0, 0); } else { mpLayout = nullptr; - mpWidget->setMinimumHeight(mpTAction->mSizeY); - mpWidget->setMaximumHeight(mpTAction->mSizeY); - mpWidget->setMinimumWidth(mpTAction->mSizeX); - mpWidget->setMaximumWidth(mpTAction->mSizeX); - mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->mSizeX, mpTAction->mSizeY); + mpWidget->setMinimumSize(mpTAction->getSize()); + mpWidget->setMaximumSize(mpTAction->getSize()); + mpWidget->setGeometry(mpTAction->mPosX, mpTAction->mPosY, mpTAction->getSizeX(), mpTAction->getSizeY()); } layout()->addWidget(pW); setStyleSheet(mpTAction->css); diff --git a/src/TEasyButtonBar.h b/src/TEasyButtonBar.h index 92ec2febe..ec347b499 100644 --- a/src/TEasyButtonBar.h +++ b/src/TEasyButtonBar.h @@ -41,6 +41,7 @@ public: Q_DISABLE_COPY(TEasyButtonBar) TEasyButtonBar(TAction*, QString, QWidget* pW = nullptr); void addButton(TFlipButton* pW); + void resetItemCount(const int initialOffset) { mItemCount = initialOffset; } void setVerticalOrientation() { mVerticalOrientation = true; } void setHorizontalOrientation() { mVerticalOrientation = false; } void clear(); diff --git a/src/TFlipButton.h b/src/TFlipButton.h index cb9132a38..b166fc3c7 100644 --- a/src/TFlipButton.h +++ b/src/TFlipButton.h @@ -53,7 +53,11 @@ protected: private: int mID = 0; QPointer<Host> mpHost; + // This and mMirrored are derived from TAction::mButtonRotation, NOT + // TAction::mOrientation! Qt::Orientation mOrientation = Qt::Horizontal; + // This and mOrientation are derived from TAction::mButtonRotation, NOT + // TAction::mOrientation! bool mMirrored = false; }; diff --git a/src/TForkedProcess.cpp b/src/TForkedProcess.cpp index 85f30443d..63ccd56d1 100644 --- a/src/TForkedProcess.cpp +++ b/src/TForkedProcess.cpp @@ -37,31 +37,14 @@ TForkedProcess::~TForkedProcess() } -TForkedProcess::TForkedProcess(TLuaInterpreter* pInterpreter, lua_State* L) +// Raises nothing: a lua_error() here would longjmp out of the constructor and +// strand both this QProcess and every argument the caller still holds, so +// checking the arguments and reporting a failed start are startProcess()'s job +TForkedProcess::TForkedProcess(TLuaInterpreter* pInterpreter, const QString& program, const QStringList& arguments, const int callBackReference) : QProcess() +, callBackFunctionRef(callBackReference) , mpInterpreter(pInterpreter) { - int n = lua_gettop(L); - if (n < 2) { - lua_pushstring(L, "Need read function and process name as parameters."); - lua_error(L); - } - - if (!lua_isfunction(L, 1)) { - lua_pushstring(L, "Need read function as first parameter."); - lua_error(L); - } - - lua_pushvalue(L, 1); - callBackFunctionRef = luaL_ref(L, LUA_REGISTRYINDEX); - - - QString prog{luaL_checkstring(L, 2)}; - QStringList args; - for (int i = 3; i <= n; i++) { - args << luaL_checkstring(L, i); - } - // QProcess::finished is overloaded so we have to say which form we are // connecting here connect(this, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), mpInterpreter, &TLuaInterpreter::slot_deleteSender); @@ -69,14 +52,8 @@ TForkedProcess::TForkedProcess(TLuaInterpreter* pInterpreter, lua_State* L) connect(this, &QProcess::readyReadStandardOutput, this, &TForkedProcess::slot_receivedData); setProcessChannelMode(QProcess::MergedChannels); - start(prog, args, QIODevice::ReadWrite); - if (!waitForStarted()) { - const QString errorMessage = qsl("Failed to start process '%1': %2. Working directory: '%3'. PATH: '%4'").arg(prog, errorString(), QDir::currentPath(), qEnvironmentVariable("PATH")); - lua_pushstring(L, errorMessage.toUtf8().constData()); - lua_error(L); - return; - } - running = true; + start(program, arguments, QIODevice::ReadWrite); + running = waitForStarted(); } void TForkedProcess::slot_finished(int exitCode, QProcess::ExitStatus exitStatus) @@ -156,7 +133,47 @@ static int qPointerGC(lua_State* L) int TForkedProcess::startProcess(TLuaInterpreter* pInterpreter, lua_State* L) { - auto process = new TForkedProcess(pInterpreter, L); + const int n = lua_gettop(L); + if (n < 2) { + lua_pushstring(L, "Need read function and process name as parameters."); + return lua_error(L); + } + if (!lua_isfunction(L, 1)) { + lua_pushstring(L, "Need read function as first parameter."); + return lua_error(L); + } + for (int i = 2; i <= n; ++i) { + // the same raise these used to make from inside the constructor, but + // while nothing of ours is alive for the longjmp to strand + static_cast<void>(luaL_checkstring(L, i)); + } + + TForkedProcess* process = nullptr; + { + const QString program{lua_tostring(L, 2)}; + QStringList arguments; + for (int i = 3; i <= n; ++i) { + arguments << lua_tostring(L, i); + } + + lua_pushvalue(L, 1); + const int callBackReference = luaL_ref(L, LUA_REGISTRYINDEX); + process = new TForkedProcess(pInterpreter, program, arguments, callBackReference); + if (!process->running) { + lua_pushstring(L, + qsl("Failed to start process '%1': %2. Working directory: '%3'. PATH: '%4'") + .arg(program, process->errorString(), QDir::currentPath(), qEnvironmentVariable("PATH")) + .toUtf8() + .constData()); + // the destructor releases the callback reference + delete process; + process = nullptr; + } + } + if (!process) { + // raised out here so program, arguments and the message are all gone + return lua_error(L); + } // The userdata for the closures. auto** luaMemory = (QPointer<TForkedProcess>**)lua_newuserdata(L, sizeof(QPointer<TForkedProcess>*)); diff --git a/src/TForkedProcess.h b/src/TForkedProcess.h index f613eaefd..a8b73b8d0 100644 --- a/src/TForkedProcess.h +++ b/src/TForkedProcess.h @@ -43,7 +43,7 @@ private: static int isProcessRunning(lua_State* L); static int sendMessage(lua_State* L); - TForkedProcess(TLuaInterpreter*, lua_State*); + TForkedProcess(TLuaInterpreter*, const QString& program, const QStringList& arguments, const int callBackReference); int callBackFunctionRef = -1; TLuaInterpreter* mpInterpreter = nullptr; diff --git a/src/THyperlinkSelectionManager.cpp b/src/THyperlinkSelectionManager.cpp index 2a0f322af..d9b18804c 100644 --- a/src/THyperlinkSelectionManager.cpp +++ b/src/THyperlinkSelectionManager.cpp @@ -19,6 +19,7 @@ ***************************************************************************/ #include "THyperlinkSelectionManager.h" +#include "LuaLiteral.h" #include "TConsole.h" #include <QUrl> @@ -83,61 +84,41 @@ void THyperlinkSelectionManager::clearAllSelections() QString THyperlinkSelectionManager::addSelectedParameter(const QString& command, bool isSelected) const { - QUrl url(command); - QUrlQuery query(url); + // Split on '?' by hand rather than parsing the whole thing as a QUrl. This + // is a game command, not a URL: QUrl::path() would drop everything after a + // '#' (an ordinary character in a MUD command) and would percent-decode a + // second time, since the payload was already decoded once when the URI was + // parsed. Only the query portion is ours to rewrite. + const int queryStart = command.indexOf(QLatin1Char('?')); + const QString base = queryStart >= 0 ? command.left(queryStart) : command; + + QUrlQuery query(queryStart >= 0 ? command.mid(queryStart + 1) : QString()); query.removeQueryItem(qsl("selected")); query.addQueryItem(qsl("selected"), isSelected ? qsl("true") : qsl("false")); - QString cleanCommand = url.path(); - if (!query.isEmpty()) { - cleanCommand += qsl("?") + query.query(QUrl::FullyEncoded); - } - return cleanCommand; + return base + QLatin1Char('?') + query.query(QUrl::FullyEncoded); } -QString THyperlinkSelectionManager::modifyUriForSelection(const QString& baseUri, const QString& group, const QString& value) const +QString THyperlinkSelectionManager::modifyUriForSelection(Mudlet::HyperlinkStyling::ActionScheme scheme, const QString& baseCommand, const QString& group, const QString& value) const { - // Query the current selection state from our internal state - bool isSelected = this->isSelected(group, value); + const bool isSelected = this->isSelected(group, value); + const QString command = addSelectedParameter(baseCommand, isSelected); #if defined(DEBUG_OSC_PROCESSING) - qDebug() << "modifyUriForSelection called with baseUri:" << baseUri << "group:" << group << "value:" << value << "isSelected:" << isSelected; + qDebug() << "modifyUriForSelection called with scheme:" << scheme << "baseCommand:" << baseCommand << "group:" << group << "value:" << value << "isSelected:" << isSelected; #endif - // Check if it's a send() or sendCmdLine() call - const QString sendPrefix = qsl("send([["); - const QString sendSuffix = qsl("]])"); - const QString sendCmdLinePrefix = qsl("sendCmdLine([["); - const QString sendCmdLineSuffix = qsl("]])"); - - if (baseUri.startsWith(sendPrefix) && baseUri.endsWith(sendSuffix)) { - const int prefixLength = sendPrefix.length(); - const int suffixLength = sendSuffix.length(); - QString command = baseUri.mid(prefixLength, baseUri.length() - prefixLength - suffixLength); - QString cleanCommand = addSelectedParameter(command, isSelected); - QString result = qsl("send([[%1]], false)").arg(cleanCommand); -#if defined(DEBUG_OSC_PROCESSING) - qDebug() << "Modified to:" << result; -#endif - return result; - } - if (baseUri.startsWith(sendCmdLinePrefix) && baseUri.endsWith(sendCmdLineSuffix)) { - const int prefixLength = sendCmdLinePrefix.length(); - const int suffixLength = sendCmdLineSuffix.length(); - QString command = baseUri.mid(prefixLength, baseUri.length() - prefixLength - suffixLength); - QString cleanCommand = addSelectedParameter(command, isSelected); - QString result = qsl("sendCmdLine([[%1]])").arg(cleanCommand); -#if defined(DEBUG_OSC_PROCESSING) - qDebug() << "Modified to:" << result; -#endif - return result; + switch (scheme) { + case Mudlet::HyperlinkStyling::ActionSend: + return qsl("send(%1, false)").arg(LuaLiteral::quote(command)); + case Mudlet::HyperlinkStyling::ActionPrompt: + return qsl("sendCmdLine(%1)").arg(LuaLiteral::quote(command)); + case Mudlet::HyperlinkStyling::ActionOpenUrl: + case Mudlet::HyperlinkStyling::ActionNone: + break; } - // For other URI formats (like openUrl), return as-is -#if defined(DEBUG_OSC_PROCESSING) - qDebug() << "No modification - returning as-is"; -#endif - return baseUri; + return QString(); } void THyperlinkSelectionManager::registerGroupMember(const QString& group, const QString& value) diff --git a/src/THyperlinkSelectionManager.h b/src/THyperlinkSelectionManager.h index 1d91c0d6c..f8342e935 100644 --- a/src/THyperlinkSelectionManager.h +++ b/src/THyperlinkSelectionManager.h @@ -20,6 +20,8 @@ * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * ***************************************************************************/ +#include "THyperlinkStyling.h" + #include <QHash> #include <QObject> #include <QSet> @@ -50,8 +52,11 @@ public: void setGroupExclusive(const QString& group, bool exclusive); bool isGroupExclusive(const QString& group) const; - // Modifies hyperlink URI to include current selection state - QString modifyUriForSelection(const QString& baseUri, const QString& group, const QString& value) const; + // Builds the Lua call for a link whose selection state just changed, with + // the group's current state appended to the command as &selected=. Returns + // an empty string for schemes that have no selection-aware form (openUrl, + // and links with no parsed action) - callers keep the command they have. + QString modifyUriForSelection(Mudlet::HyperlinkStyling::ActionScheme scheme, const QString& baseCommand, const QString& group, const QString& value) const; signals: void selectionChanged(const QString& group, const QString& value, bool selected); diff --git a/src/THyperlinkStyling.h b/src/THyperlinkStyling.h index 98ee9a4c4..1fb8f50d0 100644 --- a/src/THyperlinkStyling.h +++ b/src/THyperlinkStyling.h @@ -71,6 +71,13 @@ struct HyperlinkStyling { StateDisabled // Disabled state (from selection object) }; + // The link's primary action as parsed from the URI, kept alongside the + // generated Lua so selection callbacks can rebuild the call without + // parsing Lua source back apart. + enum ActionScheme { ActionNone, ActionSend, ActionPrompt, ActionOpenUrl }; + ActionScheme actionScheme = ActionNone; + QString baseCommand; + // State-specific styling containers struct StateStyle { QColor foregroundColor; diff --git a/src/TKey.cpp b/src/TKey.cpp index 6dba5169f..98aa83fb8 100644 --- a/src/TKey.cpp +++ b/src/TKey.cpp @@ -103,6 +103,27 @@ bool TKey::match(const Qt::Key key, const Qt::KeyboardModifiers modifier, const } +bool TKey::wouldMatch(const Qt::Key key, const Qt::KeyboardModifiers modifier) const +{ + // Also covers the dereference below - isActive() is false once mpMyChildrenList is gone + if (!isActive()) { + return false; + } + + if (!isFolder() && (mKeyCode == key) && (mKeyModifier == modifier)) { + return true; + } + + for (auto childKey : *mpMyChildrenList) { + if (childKey->wouldMatch(key, modifier)) { + return true; + } + } + + return false; +} + + bool TKey::registerKey() { if (!mpHost) { @@ -164,6 +185,17 @@ void TKey::compile() bool TKey::setScript(const QString& script) { + // Switching from a registered anonymous Lua function (set up by tempKey with a + // function argument) to a script string: release the old function from the Lua + // registry and leave callback mode. Otherwise execute() keeps calling the stale + // function so the new script never runs, and the registry entry leaks - the + // destructor would take its mScript-based branch and delete the compiled function. + if (mRegisteredAnonymousLuaFunction) { + if (mpHost) { + mpHost->mLuaInterpreter.delete_luafunction(this); + } + mRegisteredAnonymousLuaFunction = false; + } mScript = script; mNeedsToBeCompiled = true; mOK_code = compileScript(); diff --git a/src/TKey.h b/src/TKey.h index 715cefa49..8f61f4deb 100644 --- a/src/TKey.h +++ b/src/TKey.h @@ -64,6 +64,8 @@ public: bool match(const Qt::Key, const Qt::KeyboardModifiers, const bool); + // Query-only counterpart to match(), which executes what it matches + bool wouldMatch(const Qt::Key, const Qt::KeyboardModifiers) const; bool registerKey(); void validateKeyBinding(); diff --git a/src/TLuaInterpreter.cpp b/src/TLuaInterpreter.cpp index feab5e0c4..7d2d9bc41 100644 --- a/src/TLuaInterpreter.cpp +++ b/src/TLuaInterpreter.cpp @@ -185,18 +185,43 @@ TLuaInterpreter::~TLuaInterpreter() // See also: getVerifiedString, getVerifiedInt, getVerifiedFloat, errorArgumentType bool TLuaInterpreter::getVerifiedBool(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) { - if (!lua_isboolean(L, pos)) { - errorArgumentType(L, functionName, pos, publicName, "boolean", isOptional); + if (!checkBoolArg(L, functionName, pos, publicName, isOptional)) { lua_error(L); Q_UNREACHABLE(); } return lua_toboolean(L, pos); } +// No documentation available in wiki - internal function +// The non-raising counterpart of getVerifiedBool - see checkStringArg() +bool TLuaInterpreter::checkBoolArg(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) +{ + if (!lua_isboolean(L, pos)) { + errorArgumentType(L, functionName, pos, publicName, "boolean", isOptional); + return false; + } + return true; +} + +// No documentation available in wiki - internal function +// See also: getVerifiedBool +/*static*/ bool TLuaInterpreter::checkStringOrIntegerArg(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) +{ + if (lua_type(L, pos) != LUA_TNUMBER && lua_type(L, pos) != LUA_TSTRING) { + errorArgumentType(L, functionName, pos, publicName, "string or integer", isOptional); + return false; + } + return true; +} + // No documentation available in wiki - internal function // See also: getVerifiedBool /*static*/ std::pair<bool, QString> TLuaInterpreter::getVerifiedStringOrInteger(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) { + if (!checkStringOrIntegerArg(L, functionName, pos, publicName, isOptional)) { + lua_error(L); + Q_UNREACHABLE(); + } if (lua_type(L, pos) == LUA_TNUMBER) { // use lua_tonumber(...) and round because lua_tointeger(...) can return // oversized values (long long int?) on Windows which do not always fit @@ -204,21 +229,31 @@ bool TLuaInterpreter::getVerifiedBool(lua_State* L, const char* functionName, co return {true, QString::number(qRound(lua_tonumber(L, pos)))}; } - if (lua_type(L, pos) == LUA_TSTRING) { - return {false, lua_tostring(L, pos)}; - } + return {false, lua_tostring(L, pos)}; +} - errorArgumentType(L, functionName, pos, publicName, "string or integer", isOptional); - lua_error(L); - Q_UNREACHABLE(); +// No documentation available in wiki - internal function +// Leaves the "bad argument" message on the Lua stack instead of raising, so the +// caller can raise it with `return lua_error(L)` at a point of its choosing. +// lua_error() longjmps past C++ destructors: any QString already built from an +// earlier argument would have its buffer stranded, so callers taking more than +// one string check every argument here first - in argument order, so the same +// failure is still the one reported - and only then build the QStrings. +// See also: getVerifiedString, reportInvalidLuaCodeParam +bool TLuaInterpreter::checkStringArg(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) +{ + if (!lua_isstring(L, pos)) { + errorArgumentType(L, functionName, pos, publicName, "string", isOptional); + return false; + } + return true; } // No documentation available in wiki - internal function // See also: getVerifiedBool QString TLuaInterpreter::getVerifiedString(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) { - if (!lua_isstring(L, pos)) { - errorArgumentType(L, functionName, pos, publicName, "string", isOptional); + if (!checkStringArg(L, functionName, pos, publicName, isOptional)) { lua_error(L); Q_UNREACHABLE(); } @@ -226,13 +261,12 @@ QString TLuaInterpreter::getVerifiedString(lua_State* L, const char* functionNam } // No documentation available in wiki - internal function -// See also: getVerifiedBool -int TLuaInterpreter::getVerifiedInt(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) +// The non-raising counterpart of getVerifiedInt - see checkStringArg() +bool TLuaInterpreter::checkIntArg(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) { if (!lua_isnumber(L, pos)) { errorArgumentType(L, functionName, pos, publicName, "number", isOptional); - lua_error(L); - Q_UNREACHABLE(); + return false; } // lua_tointeger(...) returns a ptrdiff_t which on 64-bit platforms is a // signed 64 bit value, which is usually larger than an "int" a.k.a. an @@ -250,18 +284,38 @@ int TLuaInterpreter::getVerifiedInt(lua_State* L, const char* functionName, cons lua_tostring(L, pos), std::numeric_limits<int>::min(), std::numeric_limits<int>::max()); + return false; + } + return true; +} + +// No documentation available in wiki - internal function +// See also: getVerifiedBool +int TLuaInterpreter::getVerifiedInt(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) +{ + if (!checkIntArg(L, functionName, pos, publicName, isOptional)) { lua_error(L); Q_UNREACHABLE(); } - return static_cast<int>(result); + return static_cast<int>(lua_tointeger(L, pos)); +} + +// No documentation available in wiki - internal function +// The non-raising counterpart of getVerifiedFloat and getVerifiedDouble - see checkStringArg() +bool TLuaInterpreter::checkNumberArg(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) +{ + if (!lua_isnumber(L, pos)) { + errorArgumentType(L, functionName, pos, publicName, "number", isOptional); + return false; + } + return true; } // No documentation available in wiki - internal function // See also: getVerifiedBool float TLuaInterpreter::getVerifiedFloat(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) { - if (!lua_isnumber(L, pos)) { - errorArgumentType(L, functionName, pos, publicName, "number", isOptional); + if (!checkNumberArg(L, functionName, pos, publicName, isOptional)) { lua_error(L); Q_UNREACHABLE(); } @@ -272,8 +326,7 @@ float TLuaInterpreter::getVerifiedFloat(lua_State* L, const char* functionName, // See also: getVerifiedBool double TLuaInterpreter::getVerifiedDouble(lua_State* L, const char* functionName, const int pos, const char* publicName, const bool isOptional) { - if (!lua_isnumber(L, pos)) { - errorArgumentType(L, functionName, pos, publicName, "number", isOptional); + if (!checkNumberArg(L, functionName, pos, publicName, isOptional)) { lua_error(L); Q_UNREACHABLE(); } @@ -1126,11 +1179,11 @@ int TLuaInterpreter::feedTriggers(lua_State* L) return lua_error(L); } - const QByteArray data{lua_tostring(L, 1)}; bool dataIsUtf8Encoded = true; if (lua_gettop(L) > 1) { dataIsUtf8Encoded = getVerifiedBool(L, __func__, 2, "Utf8Encoded", true); } + const QByteArray data{lua_tostring(L, 1)}; const QByteArray currentEncoding = host.mTelnet.getEncoding(); if (dataIsUtf8Encoded) { @@ -1287,19 +1340,25 @@ int TLuaInterpreter::getModulePriority(lua_State* L) { const QString moduleName = getVerifiedString(L, __func__, 1, "module name"); Host& host = getHostFromLua(L); - if (host.mModulePriorities.contains(moduleName)) { - const int priority = host.mModulePriorities[moduleName]; - lua_pushnumber(L, priority); - return 1; + // Installing a module does not seed mModulePriorities, so whether the module + // exists has to be asked of mInstalledModules - the same list + // setModulePriority() checks. A module nobody has set a priority on has the + // default of 0 that the module manager and the saved profile use (#9655). + if (!host.mInstalledModules.contains(moduleName)) { + return warnArgumentValue(L, __func__, "module doesn't exist"); } - return warnArgumentValue(L, __func__, "module doesn't exist"); + lua_pushnumber(L, host.mModulePriorities.value(moduleName)); + return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setModulePriority int TLuaInterpreter::setModulePriority(lua_State* L) { - const QString moduleName = getVerifiedString(L, __func__, 1, "module name"); + if (!checkStringArg(L, __func__, 1, "module name")) { + return lua_error(L); + } const int modulePriority = getVerifiedInt(L, __func__, 2, "module priority"); + const QString moduleName{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); if (!host.mInstalledModules.contains(moduleName)) { @@ -1322,13 +1381,16 @@ int TLuaInterpreter::saveProfile(lua_State* L) { Host& host = getHostFromLua(L); + if (!lua_isnoneornil(L, 2) && !checkStringArg(L, __func__, 2, "file name", true)) { + return lua_error(L); + } QString saveToDir; if (lua_isstring(L, 1)) { saveToDir = lua_tostring(L, 1); } QString saveAsFile; if (!lua_isnoneornil(L, 2)) { - saveAsFile = getVerifiedString(L, __func__, 2, "file name", true); + saveAsFile = lua_tostring(L, 2); if (!saveAsFile.endsWith(".xml", Qt::CaseInsensitive)) { saveAsFile = saveAsFile + ".xml"; } @@ -1387,7 +1449,10 @@ int TLuaInterpreter::getMudletInfo(lua_State* L) } // Internal Function createLabel in an UserWindow -int TLuaInterpreter::createLabelUserWindow(lua_State* L, const QString& windowName, const QString& labelName) +// The names arrive as the Lua-owned strings still anchored at stack indexes 1 and +// 2 rather than as QStrings: every check below can raise, and lua_error() longjmps +// past C++ destructors, so the QStrings are only built once nothing else can raise +int TLuaInterpreter::createLabelUserWindow(lua_State* L, const char* windowName, const char* labelName) { const int n = lua_gettop(L); const int x = getVerifiedInt(L, "createLabel", 3, "label x-coordinate"); @@ -1420,7 +1485,7 @@ int TLuaInterpreter::createLabelUserWindow(lua_State* L, const QString& windowNa } Host& host = getHostFromLua(L); - if (auto [success, message] = host.createLabel(windowName, labelName, x, y, width, height, fillBackground, clickthrough); !success) { + if (auto [success, message] = host.createLabel(QString{windowName}, QString{labelName}, x, y, width, height, fillBackground, clickthrough); !success) { // We should, perhaps be returning a nil here but the published API // says the function returns true or false and we cannot change that now return warnArgumentValue(L, "createLabel", message, true); @@ -1431,9 +1496,9 @@ int TLuaInterpreter::createLabelUserWindow(lua_State* L, const QString& windowNa } // Internal Function create Label in MainWindow -int TLuaInterpreter::createLabelMainWindow(lua_State* L, const QString& labelName) +// See createLabelUserWindow() for why the name is not a QString +int TLuaInterpreter::createLabelMainWindow(lua_State* L, const char* labelName) { - const QString windowName = QLatin1String("main"); const int n = lua_gettop(L); const int x = getVerifiedInt(L, "createLabel", 2, "label x-coordinate"); const int y = getVerifiedInt(L, "createLabel", 3, "label y-coordinate"); @@ -1465,7 +1530,7 @@ int TLuaInterpreter::createLabelMainWindow(lua_State* L, const QString& labelNam } Host& host = getHostFromLua(L); - if (auto [success, message] = host.createLabel(windowName, labelName, x, y, width, height, fillBackground, clickthrough); !success) { + if (auto [success, message] = host.createLabel(qsl("main"), QString{labelName}, x, y, width, height, fillBackground, clickthrough); !success) { // We should, perhaps be returning a nil here but the published API // says the function returns true or false and we cannot change that now return warnArgumentValue(L, "createLabel", message, true); @@ -1545,39 +1610,49 @@ int TLuaInterpreter::appendLog(lua_State* L) // No documentation available in wiki - internal function -int TLuaInterpreter::setLabelCallback(lua_State* L, const QString& funcName) +// funcName is not a QString because a QByteArray made from one would still be +// alive inside the raising checks below - see checkStringArg() +int TLuaInterpreter::setLabelCallback(lua_State* L, const char* funcName) { Host& host = getHostFromLua(L); - const QString labelName = getVerifiedString(L, funcName.toUtf8().constData(), 1, "label name"); - if (labelName.isEmpty()) { + if (!checkStringArg(L, funcName, 1, "label name")) { + return lua_error(L); + } + // the empty-name refusal has to stay ahead of the argument #2 check, as it + // did before, or setLabelClickCallback("", <bad callback>) would raise + // instead of returning nil and a message + if (*lua_tostring(L, 1) == '\0') { return warnArgumentValue(L, __func__, "label name cannot be an empty string"); } + if (!lua_isnil(L, 2) && !lua_isfunction(L, 2)) { + lua_pushfstring(L, "%s: bad argument #2 type (function or nil expected, got %s!)", funcName, luaL_typename(L, 2)); + return lua_error(L); + } + const QLatin1StringView callbackName{funcName}; + const QString labelName{lua_tostring(L, 1)}; lua_remove(L, 1); int func = 0; if (lua_isnil(L, 1)) { lua_pop(L, 1); - } else if (lua_isfunction(L, 1)) { - func = luaL_ref(L, LUA_REGISTRYINDEX); } else { - lua_pushfstring(L, "%s: bad argument #2 type (function or nil expected, got %s!)", funcName.toUtf8().constData(), luaL_typename(L, 1)); - return lua_error(L); + func = luaL_ref(L, LUA_REGISTRYINDEX); } bool lua_result = false; - if (funcName == qsl("setLabelClickCallback")) { + if (callbackName == qsl("setLabelClickCallback")) { lua_result = host.setLabelClickCallback(labelName, func); - } else if (funcName == qsl("setLabelDoubleClickCallback")) { + } else if (callbackName == qsl("setLabelDoubleClickCallback")) { lua_result = host.setLabelDoubleClickCallback(labelName, func); - } else if (funcName == qsl("setLabelReleaseCallback")) { + } else if (callbackName == qsl("setLabelReleaseCallback")) { lua_result = host.setLabelReleaseCallback(labelName, func); - } else if (funcName == qsl("setLabelMoveCallback")) { + } else if (callbackName == qsl("setLabelMoveCallback")) { lua_result = host.setLabelMoveCallback(labelName, func); - } else if (funcName == qsl("setLabelWheelCallback")) { + } else if (callbackName == qsl("setLabelWheelCallback")) { lua_result = host.setLabelWheelCallback(labelName, func); - } else if (funcName == qsl("setLabelOnEnter")) { + } else if (callbackName == qsl("setLabelOnEnter")) { lua_result = host.setLabelOnEnter(labelName, func); - } else if (funcName == qsl("setLabelOnLeave")) { + } else if (callbackName == qsl("setLabelOnLeave")) { lua_result = host.setLabelOnLeave(labelName, func); } else { luaL_unref(L, LUA_REGISTRYINDEX, func); @@ -1679,9 +1754,10 @@ int TLuaInterpreter::debug(lua_State* L) int TLuaInterpreter::showHandlerError(lua_State* L) { Host& host = getHostFromLua(L); - const QString event = getVerifiedString(L, __func__, 1, "event name"); - const QString error = getVerifiedString(L, __func__, 2, "error message"); - host.mLuaInterpreter.logEventError(event, error); + if (!checkStringArg(L, __func__, 1, "event name") || !checkStringArg(L, __func__, 2, "error message")) { + return lua_error(L); + } + host.mLuaInterpreter.logEventError(QString{lua_tostring(L, 1)}, QString{lua_tostring(L, 2)}); return 0; } @@ -1749,10 +1825,9 @@ std::pair<int, TAction*> TLuaInterpreter::getTActionFromIdOrName(lua_State* L, c int TLuaInterpreter::findItems(lua_State* L) { const int n = lua_gettop(L); - const auto name = getVerifiedString(L, __func__, 1, "item name"); - // Although we only use 6 ASCII strings the user may not enter a purely - // ASCII value which we might have to report... - const QString type = getVerifiedString(L, __func__, 2, "item type"); + if (!checkStringArg(L, __func__, 1, "item name") || !checkStringArg(L, __func__, 2, "item type")) { + return lua_error(L); + } bool exactMatch = true; bool caseSensitive = true; if (n > 2) { @@ -1761,6 +1836,10 @@ int TLuaInterpreter::findItems(lua_State* L) if (n > 3) { caseSensitive = getVerifiedBool(L, __func__, 4, "case sensitive", true); } + const auto name = QString{lua_tostring(L, 1)}; + // Although we only use 6 ASCII strings the user may not enter a purely + // ASCII value which we might have to report... + const QString type{lua_tostring(L, 2)}; Host& host = getHostFromLua(L); auto generateList = [](const auto vector, auto l) { lua_newtable(l); @@ -1884,333 +1963,339 @@ int TLuaInterpreter::isAncestorsActive(lua_State* L) } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#ancestors +// returned by the helper lambdas below in place of a Lua return count: the +// message is already on the stack and the caller is expected to raise it +static constexpr int csmErrorAlreadyPushed = -1; + int TLuaInterpreter::ancestors(lua_State* L) { - auto id = getVerifiedInt(L, __func__, 1, "item ID"); - // Although we only use ASCII strings for the type the user may not enter a - // purely ASCII value which we might have to report... - QString type = getVerifiedString(L, __func__, 2, "item type"); - if (id < 0) { - // Must be zero or more but doesn't seem to be: - return warnArgumentValue(L, __func__, qsl("item ID as %1 does not seem to be parseable as a positive integer").arg(lua_tostring(L, 1))); - } - - Host& host = getHostFromLua(L); - // Remember, QString::compare(...) returns zero for a match: - QString typeCheck{QLatin1String("timer")}; - if (!type.compare(typeCheck, Qt::CaseInsensitive)) { - auto pT = host.getTimerUnit()->getTimer(id); - if (!pT) { - return warnArgumentValue(L, __func__, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); + // the type QStrings must be destroyed before the raise, so the internal + // error paths report back instead of raising - see checkStringArg() + const int results = [&L, functionName = __func__]() -> int { + auto id = getVerifiedInt(L, functionName, 1, "item ID"); + // Although we only use ASCII strings for the type the user may not enter a + // purely ASCII value which we might have to report... + QString type = getVerifiedString(L, functionName, 2, "item type"); + if (id < 0) { + // Must be zero or more but doesn't seem to be: + return warnArgumentValue(L, functionName, qsl("item ID as %1 does not seem to be parseable as a positive integer").arg(lua_tostring(L, 1))); } - const auto ancestorsList = pT->getAncestorList(); - lua_newtable(L); - int index = 0; - for (const auto pAncestor : ancestorsList) { - if (!pAncestor) { - // Uh oh! This is not expected, so clear that table off the - // stack so we can push an error message there: - lua_pop(L, 1); - lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", __func__, typeCheck.toLatin1().constData(), id); - lua_error(L); - Q_UNREACHABLE(); + + Host& host = getHostFromLua(L); + // Remember, QString::compare(...) returns zero for a match: + QString typeCheck{QLatin1String("timer")}; + if (!type.compare(typeCheck, Qt::CaseInsensitive)) { + auto pT = host.getTimerUnit()->getTimer(id); + if (!pT) { + return warnArgumentValue(L, functionName, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); } - lua_pushnumber(L, ++index); + const auto ancestorsList = pT->getAncestorList(); lua_newtable(L); - { - // We are confining ourselves to a small set of details here - // enough to help to build a table of the items perhaps but - // something to provide more details about each of the diffent - // item types (once the user knows which IDs/names to use to - // get them) would probably be a good idea as well: - lua_pushstring(L, "id"); - lua_pushnumber(L, pAncestor->getID()); - lua_settable(L, -3); + int index = 0; + for (const auto pAncestor : ancestorsList) { + if (!pAncestor) { + // Uh oh! This is not expected, so clear that table off the + // stack so we can push an error message there: + lua_pop(L, 1); + lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", functionName, typeCheck.toLatin1().constData(), id); + return csmErrorAlreadyPushed; + } + lua_pushnumber(L, ++index); + lua_newtable(L); + { + // We are confining ourselves to a small set of details here + // enough to help to build a table of the items perhaps but + // something to provide more details about each of the diffent + // item types (once the user knows which IDs/names to use to + // get them) would probably be a good idea as well: + lua_pushstring(L, "id"); + lua_pushnumber(L, pAncestor->getID()); + lua_settable(L, -3); - lua_pushstring(L, "name"); - lua_pushstring(L, pAncestor->getName().toUtf8().constData()); - lua_settable(L, -3); + lua_pushstring(L, "name"); + lua_pushstring(L, pAncestor->getName().toUtf8().constData()); + lua_settable(L, -3); - lua_pushstring(L, "node"); - if (pAncestor->isFolder()) { - if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { - lua_pushstring(L, "package"); + lua_pushstring(L, "node"); + if (pAncestor->isFolder()) { + if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { + lua_pushstring(L, "package"); + } else { + lua_pushstring(L, "group"); + } } else { - lua_pushstring(L, "group"); + // offset timers have a parent node that is NOT a group! + lua_pushstring(L, "item"); } - } else { - // offset timers have a parent node that is NOT a group! - lua_pushstring(L, "item"); + lua_settable(L, -3); + + lua_pushstring(L, "isActive"); + // Offset timer have their active state recorded differently + lua_pushboolean(L, pAncestor->isOffsetTimer() ? pAncestor->shouldBeActive() : pAncestor->isActive()); + lua_settable(L, -3); } lua_settable(L, -3); - - lua_pushstring(L, "isActive"); - // Offset timer have their active state recorded differently - lua_pushboolean(L, pAncestor->isOffsetTimer() ? pAncestor->shouldBeActive() : pAncestor->isActive()); - lua_settable(L, -3); } - lua_settable(L, -3); + + return 1; } - return 1; - } - - typeCheck = QLatin1String("trigger"); - if (!type.compare(typeCheck, Qt::CaseInsensitive)) { - auto pT = host.getTriggerUnit()->getTrigger(id); - if (!pT) { - return warnArgumentValue(L, __func__, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); - } - const auto ancestorsList = pT->getAncestorList(); - lua_newtable(L); - int index = 0; - for (const auto pAncestor : ancestorsList) { - if (!pAncestor) { - // Uh oh! This is not expected, so clear that table off the - // stack so we can push an error message there: - lua_pop(L, 1); - lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", __func__, typeCheck.toLatin1().constData(), id); - lua_error(L); - Q_UNREACHABLE(); + typeCheck = QLatin1String("trigger"); + if (!type.compare(typeCheck, Qt::CaseInsensitive)) { + auto pT = host.getTriggerUnit()->getTrigger(id); + if (!pT) { + return warnArgumentValue(L, functionName, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); } - lua_pushnumber(L, ++index); + const auto ancestorsList = pT->getAncestorList(); lua_newtable(L); - { - lua_pushstring(L, "id"); - lua_pushnumber(L, pAncestor->getID()); - lua_settable(L, -3); + int index = 0; + for (const auto pAncestor : ancestorsList) { + if (!pAncestor) { + // Uh oh! This is not expected, so clear that table off the + // stack so we can push an error message there: + lua_pop(L, 1); + lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", functionName, typeCheck.toLatin1().constData(), id); + return csmErrorAlreadyPushed; + } + lua_pushnumber(L, ++index); + lua_newtable(L); + { + lua_pushstring(L, "id"); + lua_pushnumber(L, pAncestor->getID()); + lua_settable(L, -3); - lua_pushstring(L, "name"); - lua_pushstring(L, pAncestor->getName().toUtf8().constData()); - lua_settable(L, -3); + lua_pushstring(L, "name"); + lua_pushstring(L, pAncestor->getName().toUtf8().constData()); + lua_settable(L, -3); - lua_pushstring(L, "node"); - if (pAncestor->isFolder()) { - if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { - lua_pushstring(L, "package"); + lua_pushstring(L, "node"); + if (pAncestor->isFolder()) { + if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { + lua_pushstring(L, "package"); + } else { + lua_pushstring(L, "group"); + } } else { - lua_pushstring(L, "group"); + lua_pushstring(L, "item"); } - } else { - lua_pushstring(L, "item"); + lua_settable(L, -3); + + lua_pushstring(L, "isActive"); + lua_pushboolean(L, pAncestor->isActive()); + lua_settable(L, -3); } lua_settable(L, -3); - - lua_pushstring(L, "isActive"); - lua_pushboolean(L, pAncestor->isActive()); - lua_settable(L, -3); } - lua_settable(L, -3); + + return 1; } - return 1; - } - - typeCheck = QLatin1String("alias"); - if (!type.compare(typeCheck, Qt::CaseInsensitive)) { - auto pT = host.getAliasUnit()->getAlias(id); - if (!pT) { - return warnArgumentValue(L, __func__, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); - } - const auto ancestorsList = pT->getAncestorList(); - lua_newtable(L); - int index = 0; - for (const auto pAncestor : ancestorsList) { - if (!pAncestor) { - // Uh oh! This is not expected, so clear that table off the - // stack so we can push an error message there: - lua_pop(L, 1); - lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", __func__, typeCheck.toLatin1().constData(), id); - lua_error(L); - Q_UNREACHABLE(); + typeCheck = QLatin1String("alias"); + if (!type.compare(typeCheck, Qt::CaseInsensitive)) { + auto pT = host.getAliasUnit()->getAlias(id); + if (!pT) { + return warnArgumentValue(L, functionName, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); } - lua_pushnumber(L, ++index); + const auto ancestorsList = pT->getAncestorList(); lua_newtable(L); - { - lua_pushstring(L, "id"); - lua_pushnumber(L, pAncestor->getID()); - lua_settable(L, -3); + int index = 0; + for (const auto pAncestor : ancestorsList) { + if (!pAncestor) { + // Uh oh! This is not expected, so clear that table off the + // stack so we can push an error message there: + lua_pop(L, 1); + lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", functionName, typeCheck.toLatin1().constData(), id); + return csmErrorAlreadyPushed; + } + lua_pushnumber(L, ++index); + lua_newtable(L); + { + lua_pushstring(L, "id"); + lua_pushnumber(L, pAncestor->getID()); + lua_settable(L, -3); - lua_pushstring(L, "name"); - lua_pushstring(L, pAncestor->getName().toUtf8().constData()); - lua_settable(L, -3); + lua_pushstring(L, "name"); + lua_pushstring(L, pAncestor->getName().toUtf8().constData()); + lua_settable(L, -3); - lua_pushstring(L, "node"); - if (pAncestor->isFolder()) { - if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { - lua_pushstring(L, "package"); + lua_pushstring(L, "node"); + if (pAncestor->isFolder()) { + if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { + lua_pushstring(L, "package"); + } else { + lua_pushstring(L, "group"); + } } else { - lua_pushstring(L, "group"); + lua_pushstring(L, "item"); } - } else { - lua_pushstring(L, "item"); + lua_settable(L, -3); + + lua_pushstring(L, "isActive"); + lua_pushboolean(L, pAncestor->isActive()); + lua_settable(L, -3); } lua_settable(L, -3); - - lua_pushstring(L, "isActive"); - lua_pushboolean(L, pAncestor->isActive()); - lua_settable(L, -3); } - lua_settable(L, -3); + + return 1; } - return 1; - } - - typeCheck = QLatin1String("keybind"); - if (!type.compare(typeCheck, Qt::CaseInsensitive)) { - auto pT = host.getKeyUnit()->getKey(id); - if (!pT) { - return warnArgumentValue(L, __func__, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); - } - const auto ancestorsList = pT->getAncestorList(); - lua_newtable(L); - int index = 0; - for (const auto pAncestor : ancestorsList) { - if (!pAncestor) { - // Uh oh! This is not expected, so clear that table off the - // stack so we can push an error message there: - lua_pop(L, 1); - lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", __func__, typeCheck.toLatin1().constData(), id); - lua_error(L); - Q_UNREACHABLE(); + typeCheck = QLatin1String("keybind"); + if (!type.compare(typeCheck, Qt::CaseInsensitive)) { + auto pT = host.getKeyUnit()->getKey(id); + if (!pT) { + return warnArgumentValue(L, functionName, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); } - lua_pushnumber(L, ++index); + const auto ancestorsList = pT->getAncestorList(); lua_newtable(L); - { - lua_pushstring(L, "id"); - lua_pushnumber(L, pAncestor->getID()); - lua_settable(L, -3); + int index = 0; + for (const auto pAncestor : ancestorsList) { + if (!pAncestor) { + // Uh oh! This is not expected, so clear that table off the + // stack so we can push an error message there: + lua_pop(L, 1); + lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", functionName, typeCheck.toLatin1().constData(), id); + return csmErrorAlreadyPushed; + } + lua_pushnumber(L, ++index); + lua_newtable(L); + { + lua_pushstring(L, "id"); + lua_pushnumber(L, pAncestor->getID()); + lua_settable(L, -3); - lua_pushstring(L, "name"); - lua_pushstring(L, pAncestor->getName().toUtf8().constData()); - lua_settable(L, -3); + lua_pushstring(L, "name"); + lua_pushstring(L, pAncestor->getName().toUtf8().constData()); + lua_settable(L, -3); - lua_pushstring(L, "node"); - if (pAncestor->isFolder()) { - if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { - lua_pushstring(L, "package"); + lua_pushstring(L, "node"); + if (pAncestor->isFolder()) { + if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { + lua_pushstring(L, "package"); + } else { + lua_pushstring(L, "group"); + } } else { - lua_pushstring(L, "group"); + lua_pushstring(L, "item"); } - } else { - lua_pushstring(L, "item"); + lua_settable(L, -3); + + lua_pushstring(L, "isActive"); + lua_pushboolean(L, pAncestor->isActive()); + lua_settable(L, -3); } lua_settable(L, -3); - - lua_pushstring(L, "isActive"); - lua_pushboolean(L, pAncestor->isActive()); - lua_settable(L, -3); } - lua_settable(L, -3); + + return 1; } - return 1; - } - - typeCheck = QLatin1String("button"); - if (!type.compare(typeCheck, Qt::CaseInsensitive)) { - auto pT = host.getActionUnit()->getAction(id); - if (!pT) { - return warnArgumentValue(L, __func__, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); - } - const auto ancestorsList = pT->getAncestorList(); - lua_newtable(L); - int index = 0; - for (const auto pAncestor : ancestorsList) { - if (!pAncestor) { - // Uh oh! This is not expected, so clear that table off the - // stack so we can push an error message there: - lua_pop(L, 1); - lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", __func__, typeCheck.toLatin1().constData(), id); - lua_error(L); - Q_UNREACHABLE(); + typeCheck = QLatin1String("button"); + if (!type.compare(typeCheck, Qt::CaseInsensitive)) { + auto pT = host.getActionUnit()->getAction(id); + if (!pT) { + return warnArgumentValue(L, functionName, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); } - lua_pushnumber(L, ++index); + const auto ancestorsList = pT->getAncestorList(); lua_newtable(L); - { - lua_pushstring(L, "id"); - lua_pushnumber(L, pAncestor->getID()); - lua_settable(L, -3); + int index = 0; + for (const auto pAncestor : ancestorsList) { + if (!pAncestor) { + // Uh oh! This is not expected, so clear that table off the + // stack so we can push an error message there: + lua_pop(L, 1); + lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", functionName, typeCheck.toLatin1().constData(), id); + return csmErrorAlreadyPushed; + } + lua_pushnumber(L, ++index); + lua_newtable(L); + { + lua_pushstring(L, "id"); + lua_pushnumber(L, pAncestor->getID()); + lua_settable(L, -3); - lua_pushstring(L, "name"); - lua_pushstring(L, pAncestor->getName().toUtf8().constData()); - lua_settable(L, -3); + lua_pushstring(L, "name"); + lua_pushstring(L, pAncestor->getName().toUtf8().constData()); + lua_settable(L, -3); - lua_pushstring(L, "node"); - if (pAncestor->isFolder()) { - if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { - lua_pushstring(L, "package"); + lua_pushstring(L, "node"); + if (pAncestor->isFolder()) { + if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { + lua_pushstring(L, "package"); + } else { + lua_pushstring(L, "group"); + } } else { - lua_pushstring(L, "group"); + lua_pushstring(L, "item"); } - } else { - lua_pushstring(L, "item"); + lua_settable(L, -3); + + lua_pushstring(L, "isActive"); + lua_pushboolean(L, pAncestor->isActive()); + lua_settable(L, -3); } lua_settable(L, -3); - - lua_pushstring(L, "isActive"); - lua_pushboolean(L, pAncestor->isActive()); - lua_settable(L, -3); } - lua_settable(L, -3); + + return 1; } - return 1; - } - - typeCheck = QLatin1String("script"); - if (!type.compare(typeCheck, Qt::CaseInsensitive)) { - auto pT = host.getScriptUnit()->getScript(id); - if (!pT) { - return warnArgumentValue(L, __func__, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); - } - const auto ancestorsList = pT->getAncestorList(); - lua_newtable(L); - int index = 0; - for (const auto pAncestor : ancestorsList) { - if (!pAncestor) { - // Uh oh! This is not expected, so clear that table off the - // stack so we can push an error message there: - lua_pop(L, 1); - lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", __func__, typeCheck.toLatin1().constData(), id); - lua_error(L); - Q_UNREACHABLE(); + typeCheck = QLatin1String("script"); + if (!type.compare(typeCheck, Qt::CaseInsensitive)) { + auto pT = host.getScriptUnit()->getScript(id); + if (!pT) { + return warnArgumentValue(L, functionName, qsl("%1 item ID %2 does not exist").arg(typeCheck, QString::number(id))); } - lua_pushnumber(L, ++index); + const auto ancestorsList = pT->getAncestorList(); lua_newtable(L); - { - lua_pushstring(L, "id"); - lua_pushnumber(L, pAncestor->getID()); - lua_settable(L, -3); + int index = 0; + for (const auto pAncestor : ancestorsList) { + if (!pAncestor) { + // Uh oh! This is not expected, so clear that table off the + // stack so we can push an error message there: + lua_pop(L, 1); + lua_pushfstring(L, "%s: internal error, got a nullptr whilst looking for an ancestor of the %s with ID: %i", functionName, typeCheck.toLatin1().constData(), id); + return csmErrorAlreadyPushed; + } + lua_pushnumber(L, ++index); + lua_newtable(L); + { + lua_pushstring(L, "id"); + lua_pushnumber(L, pAncestor->getID()); + lua_settable(L, -3); - lua_pushstring(L, "name"); - lua_pushstring(L, pAncestor->getName().toUtf8().constData()); - lua_settable(L, -3); + lua_pushstring(L, "name"); + lua_pushstring(L, pAncestor->getName().toUtf8().constData()); + lua_settable(L, -3); - lua_pushstring(L, "node"); - if (pAncestor->isFolder()) { - if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { - lua_pushstring(L, "package"); + lua_pushstring(L, "node"); + if (pAncestor->isFolder()) { + if (!pAncestor->mPackageName.isEmpty() && pAncestor->mPackageName == pAncestor->getName()) { + lua_pushstring(L, "package"); + } else { + lua_pushstring(L, "group"); + } } else { - lua_pushstring(L, "group"); + lua_pushstring(L, "item"); } - } else { - lua_pushstring(L, "item"); + lua_settable(L, -3); + + lua_pushstring(L, "isActive"); + lua_pushboolean(L, pAncestor->isActive()); + lua_settable(L, -3); } lua_settable(L, -3); - - lua_pushstring(L, "isActive"); - lua_pushboolean(L, pAncestor->isActive()); - lua_settable(L, -3); } - lua_settable(L, -3); + + return 1; } - return 1; + return warnArgumentValue(L, functionName, qsl("invalid item type '%1' given, it should be one (case insensitive) of: 'alias', 'button', 'script', 'keybind', 'timer' or 'trigger'").arg(type)); + }(); + if (results == csmErrorAlreadyPushed) { + return lua_error(L); } - - return warnArgumentValue(L, __func__, qsl("invalid item type '%1' given, it should be one (case insensitive) of: 'alias', 'button', 'script', 'keybind', 'timer' or 'trigger'").arg(type)); + return results; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getTimestamp @@ -2218,16 +2303,19 @@ int TLuaInterpreter::getTimestamp(lua_State* L) { const int n = lua_gettop(L); int s = 1; + if (n > 1 && !checkStringArg(L, __func__, s++, "mini console, user window or buffer name {may be omitted for the \"main\" console}")) { + return lua_error(L); + } + + const auto luaLine = getVerifiedInt(L, __func__, s, "line number"); QString name; if (n > 1) { - name = getVerifiedString(L, __func__, s++, "mini console, user window or buffer name {may be omitted for the \"main\" console}"); + name = lua_tostring(L, 1); if (name == QLatin1String("main")) { // clear it so it is treated as the main console below name.clear(); } } - - const auto luaLine = getVerifiedInt(L, __func__, s, "line number"); if (luaLine < 1) { return warnArgumentValue(L, __func__, qsl("line number %1 invalid, it should be greater than zero").arg(luaLine)); } @@ -2323,12 +2411,23 @@ void TLuaInterpreter::pushMapLabelPropertiesToLua(lua_State* L, const TMapLabel& // room, it would only show one of them at random. Each special exit was listed // in its own table (against the key of the exit roomID) and it is a key to a // "1" or "0" depending on whether the exit is locked or not. This was not -// The next three functions are internal helpers for use by +// The next functions are internal helpers for use by // (echo|insert|set)|(Link|Popup) functions + +// The non-raising counterpart of the type test in parseCommandOrFunction() - see checkStringArg() +bool TLuaInterpreter::checkCommandOrFunctionArg(lua_State* L, const char* functionName, const int pos) +{ + if (!(lua_isstring(L, pos) || lua_isfunction(L, pos))) { + lua_pushfstring(L, "%s: bad argument #%d type (command as string or function expected, got %s!)", functionName, pos, luaL_typename(L, pos)); + return false; + } + return true; +} + +// No documentation available in wiki - internal function void TLuaInterpreter::parseCommandOrFunction(lua_State* lState, const char* functionName, int& index, QString& command, int& luaFunctionNumber) { - if (!(lua_isstring(lState, index) || lua_isfunction(lState, index))) { - lua_pushfstring(lState, "%s: bad argument #%d type (command as string or function expected, got %s!)", functionName, index, luaL_typename(lState, index)); + if (!checkCommandOrFunctionArg(lState, functionName, index)) { lua_error(lState); Q_UNREACHABLE(); } @@ -2341,10 +2440,59 @@ void TLuaInterpreter::parseCommandOrFunction(lua_State* lState, const char* func command = lua_tostring(lState, index); } +// No documentation available in wiki - internal function +// The non-raising counterpart of parseCommandsOrFunctionsTable() - see +// checkStringArg(). Validating the whole table up front means the caller's +// QStringList is not yet populated when a bad item is reported, and no registry +// reference has been taken that a raise would strand +bool TLuaInterpreter::checkCommandsOrFunctionsTable(lua_State* L, const char* functionName, const int index) +{ + if (!lua_istable(L, index)) { + lua_pushfstring(L, "%s: bad argument #%d type (%s as table expected, got %s!)", functionName, index, "commands/functions", luaL_typename(L, index)); + return false; + } + + lua_pushnil(L); + int subIndex = 0; + while (lua_next(L, index)) { + ++subIndex; + if (!(lua_isstring(L, -1) || lua_isfunction(L, -1))) { + lua_pushfstring(L, "%s: bad item #%d in table argument #%d in type (command as string or function expected, got %s!)", functionName, subIndex, index, luaL_typename(L, -1)); + return false; + } + lua_pop(L, 1); + } + return true; +} + +// No documentation available in wiki - internal function +// The non-raising counterpart of parseHintsTable() - see checkStringArg() +bool TLuaInterpreter::checkHintsTable(lua_State* L, const char* functionName, const int index) +{ + if (!lua_istable(L, index)) { + lua_pushfstring(L, "%s: bad argument #%d type (%s as table expected, got %s!)", functionName, index, "hints", luaL_typename(L, index)); + return false; + } + + lua_pushnil(L); + int subIndex = 0; + while (lua_next(L, index)) { + ++subIndex; + if (!lua_isstring(L, -1)) { + lua_pushfstring(L, "%s: bad item #%d in table argument #%d in type (hint as string expected, got %s!)", functionName, subIndex, index, luaL_typename(L, -1)); + return false; + } + lua_pop(L, 1); + } + return true; +} + // No documentation available in wiki - internal function void TLuaInterpreter::parseHintsTable(lua_State* lState, const char* functionName, int& index, QStringList& hintList) { if (!lua_istable(lState, index)) { + // dead while every caller gates on checkHintsTable(), which duplicates + // this predicate: reaching it would strand the caller's hintList lua_pushfstring(lState, "%s: bad argument #%d type (%s as table expected, got %s!)", functionName, index, "hints", luaL_typename(lState, index)); lua_error(lState); Q_UNREACHABLE(); @@ -2357,6 +2505,7 @@ void TLuaInterpreter::parseHintsTable(lua_State* lState, const char* functionNam // key at index -2 and value at index -1 ++subIndex; if (!lua_isstring(lState, -1)) { + // dead while every caller gates on checkHintsTable() - see above lua_pushfstring(lState, "%s: bad item #%d in table argument #%d in type (hint as string expected, got %s!)", functionName, subIndex, index, luaL_typename(lState, -1)); lua_error(lState); Q_UNREACHABLE(); @@ -2374,6 +2523,9 @@ void TLuaInterpreter::parseHintsTable(lua_State* lState, const char* functionNam void TLuaInterpreter::parseCommandsOrFunctionsTable(lua_State* lState, const char* functionName, int& index, QStringList& commandsList, QVector<int>& luaFunctionNumbers) { if (!lua_istable(lState, index)) { + // dead while every caller gates on checkCommandsOrFunctionsTable(), + // which duplicates this predicate: reaching it would strand the + // caller's commandsList and every registry reference taken so far lua_pushfstring(lState, "%s: bad argument #%d type (%s as table expected, got %s!)", functionName, index, "commands/functions", luaL_typename(lState, index)); lua_error(lState); Q_UNREACHABLE(); @@ -2386,6 +2538,7 @@ void TLuaInterpreter::parseCommandsOrFunctionsTable(lua_State* lState, const cha // key at index -2 and value at index -1 ++subIndex; if (!(lua_isstring(lState, -1) || lua_isfunction(lState, -1))) { + // dead while every caller gates on checkCommandsOrFunctionsTable() - see above lua_pushfstring(lState, "%s: bad item #%d in table argument #%d in type (command as string or function expected, got %s!)", functionName, subIndex, index, luaL_typename(lState, -1)); lua_error(lState); Q_UNREACHABLE(); @@ -2429,15 +2582,17 @@ int TLuaInterpreter::echo(lua_State* L) { Host& host = getHostFromLua(L); - QString consoleName; const int n = lua_gettop(L); int s = 1; - if (n > 1) { - consoleName = getVerifiedString(L, __func__, s++, "console name", true); + if (n > 1 && !checkStringArg(L, __func__, s++, "console name", true)) { + return lua_error(L); } - - const QString displayText = getVerifiedString(L, __func__, s, "text to display"); + if (!checkStringArg(L, __func__, s, "text to display")) { + return lua_error(L); + } + const QString consoleName = (n > 1) ? QString{lua_tostring(L, 1)} : QString(); + const QString displayText{lua_tostring(L, s)}; if (isMain(consoleName)) { host.mpConsole->buffer.mEchoingText = true; @@ -2460,10 +2615,16 @@ int TLuaInterpreter::setMergeTables(lua_State* L) { Host& host = getHostFromLua(L); - QStringList modulesList; const int n = lua_gettop(L); - for (int i = 1; i <= n; i++) { - modulesList << getVerifiedString(L, __func__, i, "module"); + for (int i = 1; i <= n; ++i) { + if (!checkStringArg(L, __func__, i, "module")) { + return lua_error(L); + } + } + + QStringList modulesList; + for (int i = 1; i <= n; ++i) { + modulesList << lua_tostring(L, i); } host.mGMCP_merge_table_keys = host.mGMCP_merge_table_keys + modulesList; @@ -2475,96 +2636,107 @@ int TLuaInterpreter::setMergeTables(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getMudletVersion int TLuaInterpreter::getMudletVersion(lua_State* L) { - QByteArray version = QByteArray(APP_VERSION).trimmed(); - const QByteArray build = mudlet::self()->mAppBuild.trimmed().toLocal8Bit(); + // the QByteArrays below must be destroyed before the raise, so failures + // report back instead of raising - see checkStringArg() + const int results = [&L, functionName = __func__]() -> int { + QByteArray version = QByteArray(APP_VERSION).trimmed(); + const QByteArray build = mudlet::self()->mAppBuild.trimmed().toLocal8Bit(); - QList<QByteArray> const versionData = version.split('.'); - if (versionData.size() != 3) { - qWarning() << "TLuaInterpreter::getMudletVersion(): ERROR: Version data not correctly set on compilation,\n" - << " is the VERSION value in the project file present?"; - lua_pushstring(L, "getMudletVersion: sorry, version information not available."); - return lua_error(L); - } - - bool ok = true; - int major = 0; - int minor = 0; - int revision = 0; - { - major = versionData.at(0).toInt(&ok); - if (ok) { - minor = versionData.at(1).toInt(&ok); + QList<QByteArray> const versionData = version.split('.'); + if (versionData.size() != 3) { + qWarning() << "TLuaInterpreter::getMudletVersion(): ERROR: Version data not correctly set on compilation,\n" + << " is the VERSION value in the project file present?"; + lua_pushstring(L, "getMudletVersion: sorry, version information not available."); + return csmErrorAlreadyPushed; } - if (ok) { - revision = versionData.at(2).toInt(&ok); + + bool ok = true; + int major = 0; + int minor = 0; + int revision = 0; + { + major = versionData.at(0).toInt(&ok); + if (ok) { + minor = versionData.at(1).toInt(&ok); + } + if (ok) { + revision = versionData.at(2).toInt(&ok); + } + } + if (!ok) { + qWarning("TLuaInterpreter::getMudletVersion(): ERROR: Version data not correctly parsed,\n" + " was the VERSION value in the project file correct at compilation time?"); + lua_pushstring(L, "getMudletVersion: sorry, version information corrupted."); + return csmErrorAlreadyPushed; } - } - if (!ok) { - qWarning("TLuaInterpreter::getMudletVersion(): ERROR: Version data not correctly parsed,\n" - " was the VERSION value in the project file correct at compilation time?"); - lua_pushstring(L, "getMudletVersion: sorry, version information corrupted."); - return lua_error(L); - } - const int n = lua_gettop(L); + const int n = lua_gettop(L); - if (n == 1) { - const QString tidiedWhat = getVerifiedString(L, __func__, 1, "style", true).toLower().trimmed(); - if (tidiedWhat.contains("major")) { + if (n == 1) { + if (!checkStringArg(L, functionName, 1, "style", true)) { + return csmErrorAlreadyPushed; + } + const QString tidiedWhat = QString{lua_tostring(L, 1)}.toLower().trimmed(); + if (tidiedWhat.contains("major")) { + lua_pushinteger(L, major); + } else if (tidiedWhat.contains("minor")) { // NOLINT(readability-else-after-return) + lua_pushinteger(L, minor); + } else if (tidiedWhat.contains("revision")) { // NOLINT(readability-else-after-return) + lua_pushinteger(L, revision); + } else if (tidiedWhat.contains("build")) { // NOLINT(readability-else-after-return) + if (build.isEmpty()) { + lua_pushnil(L); + } else { + lua_pushstring(L, build); + } + } else if (tidiedWhat.contains("string")) { // NOLINT(readability-else-after-return) + if (build.isEmpty()) { + lua_pushstring(L, version.constData()); + } else { + lua_pushstring(L, version.append(build).constData()); + } + } else if (tidiedWhat.contains("table")) { // NOLINT(readability-else-after-return) + lua_pushinteger(L, major); + lua_pushinteger(L, minor); + lua_pushinteger(L, revision); + if (build.isEmpty()) { + lua_pushnil(L); + } else { + lua_pushstring(L, build); + } + return 4; + } else { // NOLINT(readability-else-after-return) + lua_pushstring(L, + "getMudletVersion: takes one (optional) argument:\n" + " \"major\", \"minor\", \"revision\", \"build\", \"string\" or \"table\"."); + return csmErrorAlreadyPushed; + } + } else if (n == 0) { // NOLINT(readability-else-after-return) + lua_newtable(L); + lua_pushstring(L, "major"); lua_pushinteger(L, major); - } else if (tidiedWhat.contains("minor")) { // NOLINT(readability-else-after-return) + lua_settable(L, -3); + lua_pushstring(L, "minor"); lua_pushinteger(L, minor); - } else if (tidiedWhat.contains("revision")) { // NOLINT(readability-else-after-return) + lua_settable(L, -3); + lua_pushstring(L, "revision"); lua_pushinteger(L, revision); - } else if (tidiedWhat.contains("build")) { // NOLINT(readability-else-after-return) - if (build.isEmpty()) { - lua_pushnil(L); - } else { - lua_pushstring(L, build); - } - } else if (tidiedWhat.contains("string")) { // NOLINT(readability-else-after-return) - if (build.isEmpty()) { - lua_pushstring(L, version.constData()); - } else { - lua_pushstring(L, version.append(build).constData()); - } - } else if (tidiedWhat.contains("table")) { // NOLINT(readability-else-after-return) - lua_pushinteger(L, major); - lua_pushinteger(L, minor); - lua_pushinteger(L, revision); - if (build.isEmpty()) { - lua_pushnil(L); - } else { - lua_pushstring(L, build); - } - return 4; + lua_settable(L, -3); + lua_pushstring(L, "build"); + lua_pushstring(L, mudlet::self()->mAppBuild.trimmed().toUtf8().constData()); + lua_settable(L, -3); } else { // NOLINT(readability-else-after-return) lua_pushstring(L, - "getMudletVersion: takes one (optional) argument:\n" + "getMudletVersion: only takes one (optional) argument:\n" " \"major\", \"minor\", \"revision\", \"build\", \"string\" or \"table\"."); - return lua_error(L); + return csmErrorAlreadyPushed; } - } else if (n == 0) { // NOLINT(readability-else-after-return) - lua_newtable(L); - lua_pushstring(L, "major"); - lua_pushinteger(L, major); - lua_settable(L, -3); - lua_pushstring(L, "minor"); - lua_pushinteger(L, minor); - lua_settable(L, -3); - lua_pushstring(L, "revision"); - lua_pushinteger(L, revision); - lua_settable(L, -3); - lua_pushstring(L, "build"); - lua_pushstring(L, mudlet::self()->mAppBuild.trimmed().toUtf8().constData()); - lua_settable(L, -3); - } else { // NOLINT(readability-else-after-return) - lua_pushstring(L, - "getMudletVersion: only takes one (optional) argument:\n" - " \"major\", \"minor\", \"revision\", \"build\", \"string\" or \"table\"."); + return 1; + }(); + if (results == csmErrorAlreadyPushed) { return lua_error(L); } - return 1; + return results; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#openWebPage @@ -2580,14 +2752,14 @@ int TLuaInterpreter::getTime(lua_State* L) { const int n = lua_gettop(L); bool return_string = false; - QString format = qsl("yyyy.MM.dd hh:mm:ss.zzz"); - QString tm; if (n > 0) { return_string = getVerifiedBool(L, __func__, 1, "return as string", true); - if (n > 1) { - format = getVerifiedString(L, __func__, 2, "custom time format"); + if (n > 1 && !checkStringArg(L, __func__, 2, "custom time format")) { + return lua_error(L); } } + QString format = (n > 1) ? QString{lua_tostring(L, 2)} : qsl("yyyy.MM.dd hh:mm:ss.zzz"); + QString tm; const QDateTime time = QDateTime::currentDateTime(); if (return_string) { tm = time.toString(format); @@ -2633,13 +2805,19 @@ int TLuaInterpreter::getEpoch(lua_State* L) int TLuaInterpreter::addCmdLineBlacklist(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + // The mandatory text is last, but with no arguments at all that would be + // index 0 - not a valid Lua stack index, and Lua 5.1 hands back the first + // free slot for it rather than complaining: + const int textIndex = qMax(n, 1); + const char* name = "main"; if (n > 1) { name = CMDLINE_NAME(L, 1); } - const QString text = getVerifiedString(L, __func__, n, "suggestion text"); - auto pN = COMMANDLINE(L, name); - pN->addBlacklist(text); + if (!checkStringArg(L, __func__, textIndex, "suggestion text")) { + return lua_error(L); + } + auto pN = COMMANDLINE(L, QString{name}); + pN->addBlacklist(QString{lua_tostring(L, textIndex)}); return 0; } @@ -2647,13 +2825,17 @@ int TLuaInterpreter::addCmdLineBlacklist(lua_State* L) int TLuaInterpreter::removeCmdLineBlacklist(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + // See addCmdLineBlacklist() on why the index is clamped: + const int textIndex = qMax(n, 1); + const char* name = "main"; if (n > 1) { name = CMDLINE_NAME(L, 1); } - const QString text = getVerifiedString(L, __func__, n, "suggestion text"); - auto pN = COMMANDLINE(L, name); - pN->removeBlacklist(text); + if (!checkStringArg(L, __func__, textIndex, "suggestion text")) { + return lua_error(L); + } + auto pN = COMMANDLINE(L, QString{name}); + pN->removeBlacklist(QString{lua_tostring(L, textIndex)}); return 0; } @@ -2661,11 +2843,11 @@ int TLuaInterpreter::removeCmdLineBlacklist(lua_State* L) int TLuaInterpreter::clearCmdLineBlacklist(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + const char* name = "main"; if (n == 1) { name = CMDLINE_NAME(L, 1); } - auto pN = COMMANDLINE(L, name); + auto pN = COMMANDLINE(L, QString{name}); pN->clearBlacklist(); return 0; } @@ -2833,12 +3015,15 @@ int TLuaInterpreter::getModules(lua_State* L) int TLuaInterpreter::getModuleInfo(lua_State* L) { const Host& host = getHostFromLua(L); - auto infoMap = host.mModuleInfo; const int n = lua_gettop(L); - const QString name = getVerifiedString(L, __func__, 1, "module name"); + if (!checkStringArg(L, __func__, 1, "module name") || (n > 1 && !checkStringArg(L, __func__, 2, "info", true))) { + return lua_error(L); + } + auto infoMap = host.mModuleInfo; + const QString name{lua_tostring(L, 1)}; QString info; if (n > 1) { - info = getVerifiedString(L, __func__, 2, "info", true); + info = lua_tostring(L, 2); } if (info.isEmpty()) { QMap<QString, QString>::const_iterator iter = infoMap.value(name).constBegin(); @@ -2859,12 +3044,15 @@ int TLuaInterpreter::getModuleInfo(lua_State* L) int TLuaInterpreter::getPackageInfo(lua_State* L) { const Host& host = getHostFromLua(L); - auto infoMap = host.mPackageInfo; const int n = lua_gettop(L); - const QString name = getVerifiedString(L, __func__, 1, "package name"); + if (!checkStringArg(L, __func__, 1, "package name") || (n > 1 && !checkStringArg(L, __func__, 2, "info", true))) { + return lua_error(L); + } + auto infoMap = host.mPackageInfo; + const QString name{lua_tostring(L, 1)}; QString info; if (n > 1) { - info = getVerifiedString(L, __func__, 2, "info", true); + info = lua_tostring(L, 2); } if (info.isEmpty()) { QMap<QString, QString>::const_iterator iter = infoMap.value(name).constBegin(); @@ -2885,10 +3073,10 @@ int TLuaInterpreter::getPackageInfo(lua_State* L) int TLuaInterpreter::setModuleInfo(lua_State* L) { Host& host = getHostFromLua(L); - const QString moduleName = getVerifiedString(L, __func__, 1, "module name"); - const QString info = getVerifiedString(L, __func__, 2, "info"); - const QString value = getVerifiedString(L, __func__, 3, "value"); - host.mModuleInfo[moduleName][info] = value; + if (!checkStringArg(L, __func__, 1, "module name") || !checkStringArg(L, __func__, 2, "info") || !checkStringArg(L, __func__, 3, "value")) { + return lua_error(L); + } + host.mModuleInfo[QString{lua_tostring(L, 1)}][QString{lua_tostring(L, 2)}] = QString{lua_tostring(L, 3)}; lua_pushboolean(L, true); return 1; } @@ -2897,10 +3085,10 @@ int TLuaInterpreter::setModuleInfo(lua_State* L) int TLuaInterpreter::setPackageInfo(lua_State* L) { Host& host = getHostFromLua(L); - const QString packageName = getVerifiedString(L, __func__, 1, "package name"); - const QString info = getVerifiedString(L, __func__, 2, "info"); - const QString value = getVerifiedString(L, __func__, 3, "value"); - host.mPackageInfo[packageName][info] = value; + if (!checkStringArg(L, __func__, 1, "package name") || !checkStringArg(L, __func__, 2, "info") || !checkStringArg(L, __func__, 3, "value")) { + return lua_error(L); + } + host.mPackageInfo[QString{lua_tostring(L, 1)}][QString{lua_tostring(L, 2)}] = QString{lua_tostring(L, 3)}; lua_pushboolean(L, true); return 1; } @@ -2943,17 +3131,20 @@ int TLuaInterpreter::setDefaultAreaVisible(lua_State* L) // this function to get called events. int TLuaInterpreter::registerAnonymousEventHandler(lua_State* L) { - const QString event = getVerifiedString(L, __func__, 1, "event name"); - const QString func = getVerifiedString(L, __func__, 2, "function name"); + if (!checkStringArg(L, __func__, 1, "event name") || !checkStringArg(L, __func__, 2, "function name")) { + return lua_error(L); + } Host& host = getHostFromLua(L); - host.registerAnonymousEventHandler(event, func); + host.registerAnonymousEventHandler(QString{lua_tostring(L, 1)}, QString{lua_tostring(L, 2)}); return 0; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#expandAlias int TLuaInterpreter::expandAlias(lua_State* L) { - const QString payload = getVerifiedString(L, __func__, 1, "text to parse"); + if (!checkStringArg(L, __func__, 1, "text to parse")) { + return lua_error(L); + } bool wantPrint = true; if (lua_gettop(L) > 1) { // check if the 2nd argument is a 'false', but don't match if it is 'nil' @@ -2964,6 +3155,7 @@ int TLuaInterpreter::expandAlias(lua_State* L) wantPrint = getVerifiedBool(L, __func__, 2, "echo", true); } } + const QString payload{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); // Host::send will encode the UTF encoded data here in the wanted Server // encoding: @@ -2988,11 +3180,14 @@ int TLuaInterpreter::sendCmdLine(lua_State* L) // encoded in the required Mud Server encoding. int TLuaInterpreter::sendRaw(lua_State* L) { - const QString text = getVerifiedString(L, __func__, 1, "command"); + if (!checkStringArg(L, __func__, 1, "command")) { + return lua_error(L); + } bool wantPrint = true; if (lua_gettop(L) > 1) { wantPrint = getVerifiedBool(L, __func__, 2, "showOnScreen", true); } + const QString text{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); // Host::send will encode the UTF encoded data here in the wanted Server encoding: host.send(text, wantPrint, true); @@ -3208,13 +3403,14 @@ bool TLuaInterpreter::compileAndExecuteScript(const QString& code) return false; } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); const int error = luaL_dostring(L, code.toUtf8().constData()); if (error) { std::string e = "no error message available from Lua"; - if (lua_isstring(L, 1)) { + if (lua_isstring(L, -1)) { e = "Lua error:"; - e += lua_tostring(L, 1); + e += lua_tostring(L, -1); } if (mudlet::smDebugMode) { qDebug() << "LUA ERROR: code did not compile: ERROR:" << e.c_str(); @@ -3224,7 +3420,7 @@ bool TLuaInterpreter::compileAndExecuteScript(const QString& code) logError(e, _n, _n2); } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return !error; } @@ -3283,28 +3479,33 @@ QString TLuaInterpreter::formatLuaCode(const QString& code) bool TLuaInterpreter::compile(const QString& code, QString& errorMsg, const QString& name) { lua_State* L = pGlobalLua; + // This runs on the global lua_State, which is shared with whatever C + // function is calling us, so everything already on the stack is that + // caller's and has to be left exactly as it was found: + const int callerStackTop = lua_gettop(L); const int error = (luaL_loadbuffer(L, code.toUtf8().constData(), strlen(code.toUtf8().constData()), name.toUtf8().constData()) || lua_pcall(L, 0, 0, 0)); if (error) { + // The error object is on the top of the stack. Absolute slot 1 - which + // this used to read - is the calling C function's first argument, which + // is how a failure came to be reported as the script's own name. std::string e = "Lua syntax error:"; - if (lua_isstring(L, 1)) { - e.append(lua_tostring(L, 1)); + if (lua_isstring(L, -1)) { + e.append(lua_tostring(L, -1)); + } else { + e.append("error object is a ").append(luaL_typename(L, -1)).append(" value"); } - errorMsg = "<b><font color='blue'>"; - errorMsg.append(QString::fromStdString(e).toHtmlEscaped().toUtf8()); - errorMsg.append("</font></b>"); + errorMsg = qsl("<b>%1</b>").arg(QString::fromStdString(e).toHtmlEscaped()); if (mudlet::smDebugMode) { auto& host = getHostFromLua(L); TDebug(Qt::white, Qt::red) << "\n " << e.c_str() << "\n" >> &host; } - } else { - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::darkGreen) << "LUA: code compiled without errors. OK\n" >> &host; - } + } else if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::darkGreen) << "LUA: code compiled without errors. OK\n" >> &host; } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return !error; } @@ -3417,12 +3618,13 @@ void TLuaInterpreter::clearCaptureGroups() mMultiCaptureNameGroups.clear(); lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_newtable(L); lua_setglobal(L, "matches"); lua_newtable(L); lua_setglobal(L, "multimatches"); - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); } // No documentation available in wiki - internal function @@ -3465,13 +3667,16 @@ void TLuaInterpreter::setAtcpTable(const QString& var, const QString& arg) void TLuaInterpreter::signalMXPEvent(const QString& type, const QMap<QString, QString>& attrs, const QStringList& actions, const QString& caption) { lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_getglobal(L, "mxp"); if (!lua_istable(L, -1)) { + lua_pop(L, 1); lua_newtable(L); lua_setglobal(L, "mxp"); lua_getglobal(L, "mxp"); if (!lua_istable(L, -1)) { qDebug() << "ERROR: mxp table not defined"; + lua_settop(L, callerStackTop); return; } } @@ -3481,6 +3686,7 @@ void TLuaInterpreter::signalMXPEvent(const QString& type, const QMap<QString, QS lua_getfield(L, -1, type.toUtf8().toLower().constData()); if (!lua_istable(L, -1)) { qDebug() << "ERROR: 'mxp." << type << "' table could not be defined"; + lua_settop(L, callerStackTop); return; } @@ -3503,7 +3709,7 @@ void TLuaInterpreter::signalMXPEvent(const QString& type, const QMap<QString, QS lua_pushstring(L, caption.toUtf8().constData()); lua_setfield(L, -2, "text"); - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); TEvent event{}; @@ -3528,11 +3734,13 @@ void TLuaInterpreter::setGMCPTable(QString& key, const QString& string_data) lua_State* L = pGlobalLua; lua_getglobal(L, "gmcp"); //defined in Lua init if (!lua_istable(L, -1)) { + lua_pop(L, 1); lua_newtable(L); lua_setglobal(L, "gmcp"); lua_getglobal(L, "gmcp"); if (!lua_istable(L, -1)) { qDebug() << "ERROR: gmcp table not defined"; + lua_pop(L, 1); return; } } @@ -3545,11 +3753,13 @@ void TLuaInterpreter::setMSSPTable(const QString& string_data) lua_State* L = pGlobalLua; lua_getglobal(L, "mssp"); //defined in Lua init if (!lua_istable(L, -1)) { + lua_pop(L, 1); lua_newtable(L); lua_setglobal(L, "mssp"); lua_getglobal(L, "mssp"); if (!lua_istable(L, -1)) { qDebug() << "ERROR: mssp table not defined"; + lua_pop(L, 1); return; } } @@ -3562,11 +3772,13 @@ void TLuaInterpreter::setMSDPTable(QString& key, const QString& string_data) lua_State* L = pGlobalLua; lua_getglobal(L, "msdp"); if (!lua_istable(L, -1)) { + lua_pop(L, 1); lua_newtable(L); lua_setglobal(L, "msdp"); lua_getglobal(L, "msdp"); if (!lua_istable(L, -1)) { qDebug() << "ERROR: msdp table not defined"; + lua_pop(L, 1); return; } } @@ -3579,9 +3791,16 @@ void TLuaInterpreter::parseJSON(QString& key, const QString& string_data, const { // key is in format of Blah.Blah or Blah.Blah.Bleh - we want to push & pre-create the tables as appropriate lua_State* L = pGlobalLua; + // Our callers push exactly the protocol's global table for us to fill in and + // we consume it, so everything below that is the stack of whatever C + // function this arrived in the middle of and must be left untouched. A + // negative level would make lua_settop() pop relatively instead: + const int callerStackTop = lua_gettop(L) - 1; + Q_ASSERT_X(callerStackTop >= 0, "TLuaInterpreter::parseJSON()", "the protocol's global table must already be on the stack"); QStringList tokenList = key.split(QLatin1Char('.')); if (!lua_checkstack(L, tokenList.size() + 5)) { qCritical() << "ERROR: could not grow Lua stack by" << tokenList.size() + 5 << "elements, parsing GMCP/MSDP failed. Current stack size is" << lua_gettop(L); + lua_settop(L, callerStackTop); return; } int i = 0; @@ -3615,7 +3834,7 @@ void TLuaInterpreter::parseJSON(QString& key, const QString& string_data, const lua_getglobal(L, "json_to_value"); if (!lua_isfunction(L, -1)) { - lua_settop(L, 0); + lua_settop(L, callerStackTop); qDebug() << "CRITICAL ERROR: json_to_value not defined"; return; } @@ -3626,10 +3845,10 @@ void TLuaInterpreter::parseJSON(QString& key, const QString& string_data, const // Top of stack should now contain the lua representation of json. lua_rawset(L, -3); if (__needMerge) { - lua_settop(L, 0); + lua_settop(L, callerStackTop); lua_getglobal(L, "__gmcp_merge_gmcp_sub_tables"); if (!lua_isfunction(L, -1)) { - lua_settop(L, 0); + lua_settop(L, callerStackTop); qDebug() << "CRITICAL ERROR: __gmcp_merge_gmcp_sub_tables is not defined in lua_LuaGlobal.lua"; return; } @@ -3663,7 +3882,7 @@ void TLuaInterpreter::parseJSON(QString& key, const QString& string_data, const logError(e, _n, _f); } } - lua_settop(L, 0); + lua_settop(L, callerStackTop); // events: for key "foo.bar.top" we raise: gmcp.foo, gmcp.foo.bar and gmcp.foo.bar.top // with the actual key given as parameter e.g. event=gmcp.foo, param="gmcp.foo.bar" @@ -3693,7 +3912,7 @@ void TLuaInterpreter::parseJSON(QString& key, const QString& string_data, const if (tokenList.size() == 3 && tokenList.at(0).toLower() == "ire" && tokenList.at(1).toLower() == "composer" && tokenList.at(2).toLower() == "edit") { handleIreComposerEdit(string_data); } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); } void TLuaInterpreter::handleIreComposerEdit(const QString& jsonData) @@ -3737,6 +3956,11 @@ void TLuaInterpreter::handleIreComposerEdit(const QString& jsonData) void TLuaInterpreter::parseMSSP(const QString& string_data) { lua_State* L = pGlobalLua; + // setMSSPTable() pushes exactly the mssp global table for us and we consume + // it, so everything below that belongs to whichever C function this arrived + // in the middle of. A negative level would make lua_settop() pop relatively: + const int callerStackTop = lua_gettop(L) - 1; + Q_ASSERT_X(callerStackTop >= 0, "TLuaInterpreter::parseMSSP()", "the mssp global table must already be on the stack"); // string_data is in the format of MSSP_VAR "PLAYERS" MSSP_VAL "52" MSSP_VAR "UPTIME" MSSP_VAL "1234567890" // The quote characters mean that the encased word is a string, the quotes themselves are not sent. @@ -3747,7 +3971,7 @@ void TLuaInterpreter::parseMSSP(const QString& string_data) for (int i = 1; i < packageList.size(); i++) { // clear the stack to avoid it getting to big - lua_settop(L, 0); + lua_settop(L, callerStackTop); QStringList payloadList = packageList[i].split(MSSP_VAL); @@ -3789,9 +4013,9 @@ void TLuaInterpreter::parseMSSP(const QString& string_data) host.mMSSPTlsPort = (msspVAL != "-1" && msspVAL != "1") ? msspVAL.toInt() : 0; } } - - lua_pop(L, lua_gettop(L)); } + + lua_settop(L, callerStackTop); } // No documentation available in wiki - internal function @@ -3972,25 +4196,23 @@ bool TLuaInterpreter::call_luafunction(void* pT) } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_pushlightuserdata(L, pT); lua_gettable(L, LUA_REGISTRYINDEX); if (lua_isfunction(L, -1)) { setMatches(L); const int error = lua_pcall(L, 0, LUA_MULTRET, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e = "Lua error:"; - e += lua_tostring(L, i); - const QString _n = "error in anonymous Lua function"; - const QString _n2 = "no debug data available"; - logError(e, _n, _n2); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running anonymous Lua function ERROR:" << e.c_str() >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e = "Lua error:"; + e += lua_tostring(L, -1); + const QString _n = "error in anonymous Lua function"; + const QString _n2 = "no debug data available"; + logError(e, _n, _n2); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running anonymous Lua function ERROR:" << e.c_str() >> &host; } } } else { @@ -4001,11 +4223,11 @@ bool TLuaInterpreter::call_luafunction(void* pT) >> &host; } } - lua_pop(L, lua_gettop(L)); - //lua_settop(L, 0); + lua_settop(L, callerStackTop); return !error; } + lua_settop(L, callerStackTop); const QString _n = "error in anonymous Lua function"; const QString _n2 = "func reference not found by Lua, func cannot be called"; std::string e = "Lua error:"; @@ -4026,14 +4248,15 @@ void TLuaInterpreter::delete_luafunction(void* pT) void TLuaInterpreter::delete_luafunction(const QString& name) { lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_getglobal(L, name.toUtf8().constData()); if (lua_isfunction(L, -1)) { lua_pushnil(L); lua_setglobal(L, name.toUtf8().constData()); - lua_pop(L, lua_gettop(L)); } else if (mudlet::smDebugMode) { qWarning() << "LUA: ERROR deleting " << name << ", it is not a function as expected"; } + lua_settop(L, callerStackTop); } // No documentation available in wiki - internal function @@ -4048,6 +4271,7 @@ std::pair<bool, bool> TLuaInterpreter::callLuaFunctionReturnBool(void* pT) } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_pushlightuserdata(L, pT); lua_gettable(L, LUA_REGISTRYINDEX); @@ -4057,25 +4281,21 @@ std::pair<bool, bool> TLuaInterpreter::callLuaFunctionReturnBool(void* pT) setMatches(L); const int error = lua_pcall(L, 0, LUA_MULTRET, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e = "Lua error:"; - e += lua_tostring(L, i); - const QString _n = "error in anonymous Lua function"; - const QString _n2 = "no debug data available"; - logError(e, _n, _n2); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running anonymous Lua function ERROR:" << e.c_str() >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e = "Lua error:"; + e += lua_tostring(L, -1); + const QString _n = "error in anonymous Lua function"; + const QString _n2 = "no debug data available"; + logError(e, _n, _n2); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running anonymous Lua function ERROR:" << e.c_str() >> &host; } } } else { - auto index = lua_gettop(L); - if (lua_isboolean(L, index)) { - returnValue = lua_toboolean(L, index); + if (lua_gettop(L) > callerStackTop && lua_isboolean(L, -1)) { + returnValue = lua_toboolean(L, -1); } if (mudlet::smDebugMode) { @@ -4085,10 +4305,11 @@ std::pair<bool, bool> TLuaInterpreter::callLuaFunctionReturnBool(void* pT) >> &host; } } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return {!error, returnValue}; } + lua_settop(L, callerStackTop); const QString _n = "error in anonymous Lua function"; const QString _n2 = "func reference not found by Lua, func cannot be called"; std::string e = "Lua error:"; @@ -4109,21 +4330,19 @@ bool TLuaInterpreter::call(const QString& function, const QString& mName, const } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); setMatches(L); lua_getglobal(L, function.toUtf8().constData()); const int error = lua_pcall(L, 0, LUA_MULTRET, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e += lua_tostring(L, i); - logError(e, mName, function); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA ERROR: when running script " << mName << " (" << function << "),\nreason: " << e.c_str() << "\n" >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e += lua_tostring(L, -1); + logError(e, mName, function); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA ERROR: when running script " << mName << " (" << function << "),\nreason: " << e.c_str() << "\n" >> &host; } } } else { @@ -4134,7 +4353,7 @@ bool TLuaInterpreter::call(const QString& function, const QString& mName, const >> &host; } } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return !error; } @@ -4149,6 +4368,7 @@ std::pair<bool, bool> TLuaInterpreter::callReturnBool(const QString& function, c } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); bool returnValue = false; setMatches(L); @@ -4156,22 +4376,18 @@ std::pair<bool, bool> TLuaInterpreter::callReturnBool(const QString& function, c lua_getglobal(L, function.toUtf8().constData()); const int error = lua_pcall(L, 0, LUA_MULTRET, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e += lua_tostring(L, i); - logError(e, mName, function); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function << ") ERROR:" << e.c_str() << "\n" >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e += lua_tostring(L, -1); + logError(e, mName, function); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function << ") ERROR:" << e.c_str() << "\n" >> &host; } } } else { - auto index = lua_gettop(L); - if (lua_isboolean(L, index)) { - returnValue = lua_toboolean(L, index); + if (lua_gettop(L) > callerStackTop && lua_isboolean(L, -1)) { + returnValue = lua_toboolean(L, -1); } if (mudlet::smDebugMode) { @@ -4181,7 +4397,7 @@ std::pair<bool, bool> TLuaInterpreter::callReturnBool(const QString& function, c >> &host; } } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return {!error, returnValue}; } @@ -4257,21 +4473,19 @@ bool TLuaInterpreter::callConditionFunction(std::string& function, const QString } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_getfield(L, LUA_GLOBALSINDEX, function.c_str()); const int error = lua_pcall(L, 0, 1, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e += lua_tostring(L, i); - const QString _f = function.c_str(); - logError(e, mName, _f); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function.c_str() << ") ERROR:" << e.c_str() << "\n" >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e += lua_tostring(L, -1); + const QString _f = function.c_str(); + logError(e, mName, _f); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function.c_str() << ") ERROR:" << e.c_str() << "\n" >> &host; } } } else { @@ -4284,13 +4498,12 @@ bool TLuaInterpreter::callConditionFunction(std::string& function, const QString } bool ret = false; - const int returnValues = lua_gettop(L); - if (returnValues > 0) { + if (!error && lua_gettop(L) > callerStackTop) { // Lua docs: Like all tests in Lua, lua_toboolean returns 1 for any Lua value different from false and nil; otherwise it returns 0 // This means trigger patterns don't have to strictly return true or false, as it is accepted in Lua - ret = lua_toboolean(L, 1); + ret = lua_toboolean(L, -1); } - lua_pop(L, returnValues); + lua_settop(L, callerStackTop); return ((!error) && (ret > 0)); } @@ -4304,6 +4517,7 @@ bool TLuaInterpreter::callMulti(const QString& function, const QString& mName) } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); if (!mMultiCaptureGroupList.empty()) { int k = 1; // Lua indexes start with 1 as a general convention @@ -4331,16 +4545,13 @@ bool TLuaInterpreter::callMulti(const QString& function, const QString& mName) lua_getglobal(L, function.toUtf8().constData()); const int error = lua_pcall(L, 0, LUA_MULTRET, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e += lua_tostring(L, i); - logError(e, mName, function); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function << ") ERROR:" << e.c_str() << "\n" >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e += lua_tostring(L, -1); + logError(e, mName, function); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function << ") ERROR:" << e.c_str() << "\n" >> &host; } } } else { @@ -4351,7 +4562,7 @@ bool TLuaInterpreter::callMulti(const QString& function, const QString& mName) >> &host; } } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return !error; } @@ -4365,6 +4576,7 @@ std::pair<bool, bool> TLuaInterpreter::callMultiReturnBool(const QString& functi } lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); bool returnValue = false; @@ -4389,22 +4601,18 @@ std::pair<bool, bool> TLuaInterpreter::callMultiReturnBool(const QString& functi lua_getglobal(L, function.toUtf8().constData()); const int error = lua_pcall(L, 0, LUA_MULTRET, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e += lua_tostring(L, i); - logError(e, mName, function); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function << ") ERROR:" << e.c_str() << "\n" >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e += lua_tostring(L, -1); + logError(e, mName, function); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running script " << mName << " (" << function << ") ERROR:" << e.c_str() << "\n" >> &host; } } } else { - auto index = lua_gettop(L); - if (lua_isboolean(L, index)) { - returnValue = lua_toboolean(L, index); + if (lua_gettop(L) > callerStackTop && lua_isboolean(L, -1)) { + returnValue = lua_toboolean(L, -1); } if (mudlet::smDebugMode) { @@ -4414,13 +4622,19 @@ std::pair<bool, bool> TLuaInterpreter::callMultiReturnBool(const QString& functi >> &host; } } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return {!error, returnValue}; } // No documentation available in wiki - internal function bool TLuaInterpreter::callReference(lua_State* L, QString name, int parameters) { + // Our callers have already pushed the function and its arguments for us, so + // anything below those belongs to whichever C function we are nested in. + // A negative level would make lua_settop() pop relatively instead: + const int callerStackTop = lua_gettop(L) - parameters - 1; + Q_ASSERT_X(callerStackTop >= 0, "TLuaInterpreter::callReference()", "the function to call and its arguments must already be on the stack"); + int error = 0; error = lua_pcall(L, parameters, LUA_MULTRET, 0); if (error) { @@ -4434,7 +4648,7 @@ bool TLuaInterpreter::callReference(lua_State* L, QString name, int parameters) TDebug(Qt::white, Qt::red) << "LUA: ERROR running anonymous Lua function (" << name << ")\nError: " << err.c_str() << "\n" >> &host; } } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return !error; } @@ -4466,6 +4680,7 @@ bool TLuaInterpreter::callCmdLineAction(const int func, QString text) bool TLuaInterpreter::callLabelCallbackEvent(const int func, const QEvent* qE) { lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_rawgeti(L, LUA_REGISTRYINDEX, func); const QString name = qsl("label callback event"); @@ -4604,7 +4819,7 @@ bool TLuaInterpreter::callLabelCallbackEvent(const int func, const QEvent* qE) } else { return callReference(L, name, 0); } - lua_pop(L, lua_gettop(L)); + lua_settop(L, callerStackTop); return true; } @@ -4628,7 +4843,9 @@ bool TLuaInterpreter::callEventHandler(const QString& function, const TEvent& pE return false; } - // Record initial stack size for cleanup + // Events are often raised from inside another Lua API C function, which is + // still holding its arguments and any already-pushed return values on this + // same stack, so only ever unwind back down to what we found: const int initialStackSize = lua_gettop(L); int error = luaL_dostring(L, qsl("return %1").arg(function).toUtf8().constData()); @@ -4653,6 +4870,15 @@ bool TLuaInterpreter::callEventHandler(const QString& function, const TEvent& pE qWarning() << "TLuaInterpreter::callEventHandler() WARNING: argument list size" << pE.mArgumentList.size() << "does not match type list size" << pE.mArgumentTypeList.size() << "for function:" << function; } + // A lua_CFunction is only guaranteed LUA_MINSTACK slots, and whatever C + // function we are nested inside is already using some of them: + if (!lua_checkstack(L, static_cast<int>(maxArguments) + 1)) { + std::string err = "could not grow the Lua stack to pass this event's arguments to the handler"; + const QString name = "event handler function"; + logError(err, name, function); + lua_settop(L, initialStackSize); + return false; + } for (int i = 0; i < maxArguments; i++) { switch (pE.mArgumentTypeList.at(i)) { case ARGUMENT_TYPE_NUMBER: @@ -4701,16 +4927,71 @@ bool TLuaInterpreter::callEventHandler(const QString& function, const TEvent& pE } } - // Ensure stack is properly cleaned up and validate before cleanup - const int finalStackSize = lua_gettop(L); - if (finalStackSize > initialStackSize) { - qWarning() << "TLuaInterpreter::callEventHandler() - Stack grew during execution. Initial:" << initialStackSize << "Final:" << finalStackSize; - } - - lua_pop(L, lua_gettop(L)); + lua_settop(L, initialStackSize); return !error; } +// No documentation available in wiki - internal, test-only helper for waitForEvent() +// Snapshots a TEvent's arguments into a fresh Lua table {[1]=name, [2]=arg, ...} +// with a numeric length in the "n" field, and returns a registry reference to +// it. Table/function arguments are anchored by this new table (it holds a +// reference to the same object the handlers saw), so they survive +// Host::raiseEvent() freeing the event's own registry entries, letting +// waitForEvent() hand the values back after the loop unwinds. +int TLuaInterpreter::createEventArgsTableRef(const TEvent& pE) +{ + lua_State* L = pGlobalLua; + const int initialStackSize = lua_gettop(L); + const auto argCount = std::min(pE.mArgumentList.size(), pE.mArgumentTypeList.size()); + lua_newtable(L); + for (qsizetype i = 0; i < argCount; ++i) { + switch (pE.mArgumentTypeList.at(i)) { + case ARGUMENT_TYPE_NUMBER: + lua_pushnumber(L, pE.mArgumentList.at(i).toDouble()); + break; + case ARGUMENT_TYPE_STRING: + lua_pushstring(L, pE.mArgumentList.at(i).toUtf8().constData()); + break; + case ARGUMENT_TYPE_BOOLEAN: + lua_pushboolean(L, pE.mArgumentList.at(i).toInt()); + break; + case ARGUMENT_TYPE_NIL: + lua_pushnil(L); + break; + case ARGUMENT_TYPE_TABLE: + case ARGUMENT_TYPE_FUNCTION: + lua_rawgeti(L, LUA_REGISTRYINDEX, pE.mArgumentList.at(i).toInt()); + break; + default: + lua_pushnil(L); + } + lua_rawseti(L, -2, static_cast<int>(i) + 1); + } + lua_pushinteger(L, argCount); + lua_setfield(L, -2, "n"); + const int ref = luaL_ref(L, LUA_REGISTRYINDEX); + // luaL_ref popped the table; restore the stack exactly in case this runs + // nested inside another operation's stack. + lua_settop(L, initialStackSize); + return ref; +} + +// No documentation available in wiki - internal, test-only helper for waitForEvent() +void TLuaInterpreter::captureEventForWaits(const TEvent& pE) +{ + if (mPendingEventWaits.isEmpty() || pE.mArgumentList.isEmpty()) { + return; + } + const QString& eventName = pE.mArgumentList.at(0); + for (auto* pWait : mPendingEventWaits) { + if (pWait->mCaptured || pWait->mName != eventName) { + continue; + } + pWait->mArgsRef = createEventArgsTableRef(pE); + pWait->mCaptured = true; + } +} + // No documentation available in wiki - internal function double TLuaInterpreter::condenseMapLoad() { @@ -4724,21 +5005,19 @@ double TLuaInterpreter::condenseMapLoad() double loadTime = -1.0; lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_getfield(L, LUA_GLOBALSINDEX, "condenseMapLoad"); const int error = lua_pcall(L, 0, 1, 0); if (error) { - const int nbpossible_errors = lua_gettop(L); - for (int i = 1; i <= nbpossible_errors; i++) { - std::string e = ""; - if (lua_isstring(L, i)) { - e += lua_tostring(L, i); - const QString _f = luaFunction.toUtf8().constData(); - logError(e, luaFunction, _f); - if (mudlet::smDebugMode) { - auto& host = getHostFromLua(L); - TDebug(Qt::white, Qt::red) << "LUA: ERROR running " << luaFunction << " ERROR:" << e.c_str() << "\n" >> &host; - } + std::string e = ""; + if (lua_isstring(L, -1)) { + e += lua_tostring(L, -1); + const QString _f = luaFunction.toUtf8().constData(); + logError(e, luaFunction, _f); + if (mudlet::smDebugMode) { + auto& host = getHostFromLua(L); + TDebug(Qt::white, Qt::red) << "LUA: ERROR running " << luaFunction << " ERROR:" << e.c_str() << "\n" >> &host; } } } else { @@ -4749,72 +5028,53 @@ double TLuaInterpreter::condenseMapLoad() } } - const int returnValues = lua_gettop(L); - if (returnValues > 0 && !lua_isnoneornil(L, 1)) { - loadTime = lua_tonumber(L, 1); + if (lua_gettop(L) > callerStackTop && !lua_isnoneornil(L, -1)) { + loadTime = lua_tonumber(L, -1); } - lua_pop(L, returnValues); + lua_settop(L, callerStackTop); return loadTime; } // No documentation available in wiki - internal function -int TLuaInterpreter::performHttpRequest(lua_State* L, const char* functionName, const int pos, QNetworkAccessManager::Operation operation, const QString& verb) +// `verb` is a const char* so that customHTTP() need not own a heap buffer +// across the checks below - see checkStringArg() +int TLuaInterpreter::performHttpRequest(lua_State* L, const char* functionName, const int pos, QNetworkAccessManager::Operation operation, const char* verb) { auto& host = getHostFromLua(L); - QString dataToPost; if (!lua_isstring(L, pos + 1) && !lua_isstring(L, pos + 4)) { lua_pushfstring(L, "%s: bad argument #%d type (data to send as string expected, got %s!)", functionName, pos + 1, luaL_typename(L, pos + 1)); return lua_error(L); } + if (!checkStringArg(L, functionName, pos + 2, "remote url")) { + return lua_error(L); + } + validateHttpHeaders(L, pos + 3, functionName); + if (!lua_isstring(L, pos + 4) && !lua_isnoneornil(L, pos + 4)) { + lua_pushfstring(L, "%s: bad argument #%d type (file to send as string location expected, got %s!)", functionName, pos + 4, luaL_typename(L, pos + 4)); + return lua_error(L); + } + + QString dataToPost; if (lua_isstring(L, pos + 1)) { dataToPost = lua_tostring(L, pos + 1); } - - const QString urlString = getVerifiedString(L, functionName, pos + 2, "remote url"); - const QUrl url = QUrl::fromUserInput(urlString); - - if (!url.isValid()) { - return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1.").arg(url.errorString())); - } - - QNetworkRequest request = QNetworkRequest(url); - mudlet::self()->setNetworkRequestDefaults(url, request); - - if (!lua_istable(L, pos + 3) && !lua_isnoneornil(L, pos + 3)) { - lua_pushfstring(L, "%s: bad argument #%d type (headers as a table expected, got %s!)", functionName, pos + 3, luaL_typename(L, 3)); - return lua_error(L); - } - if (lua_istable(L, pos + 3)) { - lua_pushnil(L); - while (lua_next(L, pos + 3) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING && lua_type(L, -2) == LUA_TSTRING) { - request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1))); - } else { - lua_pushfstring(L, - "%s: bad argument #%d type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))", - functionName, - pos + 3, - luaL_typename(L, -2), - luaL_typename(L, -1)); - return lua_error(L); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - } - - QByteArray fileToUpload; + const QString urlString{lua_tostring(L, pos + 2)}; QString fileLocation; - if (!lua_isstring(L, pos + 4) && !lua_isnoneornil(L, pos + 4)) { - lua_pushfstring(L, "%s: bad argument #%d type (file to send as string location expected, got %s!)", functionName, pos + 4, luaL_typename(L, 4)); - return lua_error(L); - } if (lua_isstring(L, pos + 4)) { fileLocation = lua_tostring(L, pos + 4); } + const QUrl url = QUrl::fromUserInput(urlString); + if (!url.isValid()) { + return warnArgumentValue(L, functionName, qsl("url is invalid, reason: %1.").arg(url.errorString())); + } + + QNetworkRequest request = QNetworkRequest(url); + mudlet::self()->setNetworkRequestDefaults(url, request); + applyHttpHeaders(L, pos + 3, request); + + QByteArray fileToUpload; if (!fileLocation.isEmpty()) { QFile file(fileLocation); if (!file.open(QFile::ReadOnly)) { @@ -4836,7 +5096,7 @@ int TLuaInterpreter::performHttpRequest(lua_State* L, const char* functionName, reply = host.mLuaInterpreter.mpFileDownloader->put(request, fileToUpload.isEmpty() ? dataToPost.toUtf8() : fileToUpload); break; default: - reply = host.mLuaInterpreter.mpFileDownloader->sendCustomRequest(request, verb.toUtf8(), fileToUpload.isEmpty() ? dataToPost.toUtf8() : fileToUpload); + reply = host.mLuaInterpreter.mpFileDownloader->sendCustomRequest(request, QByteArray{verb}, fileToUpload.isEmpty() ? dataToPost.toUtf8() : fileToUpload); }; if (mudlet::smDebugMode) { @@ -4851,8 +5111,11 @@ int TLuaInterpreter::performHttpRequest(lua_State* L, const char* functionName, // Documentation: https://wiki.mudlet.org/w/Manual:Networking_Functions#unzipAsync int TLuaInterpreter::unzipAsync(lua_State* L) { - const QString zipLocation = getVerifiedString(L, __func__, 1, "zip location"); - QString extractLocation = getVerifiedString(L, __func__, 2, "extract location"); + if (!checkStringArg(L, __func__, 1, "zip location") || !checkStringArg(L, __func__, 2, "extract location")) { + return lua_error(L); + } + const QString zipLocation{lua_tostring(L, 1)}; + QString extractLocation{lua_tostring(L, 2)}; const QTemporaryDir temporaryDir; if (!temporaryDir.isValid()) { @@ -4900,6 +5163,7 @@ int TLuaInterpreter::unzipAsync(lua_State* L) void TLuaInterpreter::set_lua_table(const QString& tableName, QStringList& variableList) { lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_newtable(L); for (int i = 0; i < variableList.size(); i++) { lua_pushnumber(L, i + 1); // Lua indexes start with 1 @@ -4907,17 +5171,18 @@ void TLuaInterpreter::set_lua_table(const QString& tableName, QStringList& varia lua_settable(L, -3); } lua_setglobal(L, tableName.toUtf8().constData()); - lua_pop(pGlobalLua, lua_gettop(pGlobalLua)); + lua_settop(L, callerStackTop); } // No documentation available in wiki - internal function void TLuaInterpreter::set_lua_string(const QString& varName, const QString& varValue) { lua_State* L = pGlobalLua; + const int callerStackTop = lua_gettop(L); lua_pushstring(L, varValue.toUtf8().constData()); lua_setglobal(L, varName.toUtf8().constData()); - lua_pop(pGlobalLua, lua_gettop(pGlobalLua)); + lua_settop(L, callerStackTop); } // No documentation available in wiki - internal function @@ -5110,6 +5375,7 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "getFontSize", TLuaInterpreter::getFontSize); lua_register(pGlobalLua, "openUserWindow", TLuaInterpreter::openUserWindow); lua_register(pGlobalLua, "setUserWindowTitle", TLuaInterpreter::setUserWindowTitle); + lua_register(pGlobalLua, "getUserWindowTitle", TLuaInterpreter::getUserWindowTitle); lua_register(pGlobalLua, "echoUserWindow", TLuaInterpreter::echoUserWindow); lua_register(pGlobalLua, "enableTimer", TLuaInterpreter::enableTimer); lua_register(pGlobalLua, "disableTimer", TLuaInterpreter::disableTimer); @@ -5137,6 +5403,8 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "selectCaptureGroup", TLuaInterpreter::selectCaptureGroup); lua_register(pGlobalLua, "tempLineTrigger", TLuaInterpreter::tempLineTrigger); lua_register(pGlobalLua, "raiseEvent", TLuaInterpreter::raiseEvent); + lua_register(pGlobalLua, "waitForEvent", TLuaInterpreter::waitForEvent); + lua_register(pGlobalLua, "pumpEvents", TLuaInterpreter::pumpEvents); lua_register(pGlobalLua, "deleteLine", TLuaInterpreter::deleteLine); lua_register(pGlobalLua, "copy", TLuaInterpreter::copy); lua_register(pGlobalLua, "cut", TLuaInterpreter::cut); @@ -5172,6 +5440,7 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "setTextEditTabMovesFocus", TLuaInterpreter::setTextEditTabMovesFocus); lua_register(pGlobalLua, "deleteScrollBox", TLuaInterpreter::deleteScrollBox); lua_register(pGlobalLua, "setLabelToolTip", TLuaInterpreter::setLabelToolTip); + lua_register(pGlobalLua, "getLabelToolTip", TLuaInterpreter::getLabelToolTip); lua_register(pGlobalLua, "setLabelCursor", TLuaInterpreter::setLabelCursor); lua_register(pGlobalLua, "setLabelCustomCursor", TLuaInterpreter::setLabelCustomCursor); lua_register(pGlobalLua, "raiseWindow", TLuaInterpreter::raiseWindow); @@ -5201,6 +5470,7 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "setCmdLineAction", TLuaInterpreter::setCmdLineAction); lua_register(pGlobalLua, "resetCmdLineAction", TLuaInterpreter::resetCmdLineAction); lua_register(pGlobalLua, "setCmdLineStyleSheet", TLuaInterpreter::setCmdLineStyleSheet); + lua_register(pGlobalLua, "getCmdLineStyleSheet", TLuaInterpreter::getCmdLineStyleSheet); lua_register(pGlobalLua, "setLabelClickCallback", TLuaInterpreter::setLabelClickCallback); lua_register(pGlobalLua, "setLabelDoubleClickCallback", TLuaInterpreter::setLabelDoubleClickCallback); lua_register(pGlobalLua, "setLabelReleaseCallback", TLuaInterpreter::setLabelReleaseCallback); @@ -5219,9 +5489,13 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "setWindow", TLuaInterpreter::setWindow); lua_register(pGlobalLua, "openMapWidget", TLuaInterpreter::openMapWidget); lua_register(pGlobalLua, "closeMapWidget", TLuaInterpreter::closeMapWidget); + lua_register(pGlobalLua, "getMapWidgetGeometry", TLuaInterpreter::getMapWidgetGeometry); lua_register(pGlobalLua, "setTextFormat", TLuaInterpreter::setTextFormat); lua_register(pGlobalLua, "getMainWindowSize", TLuaInterpreter::getMainWindowSize); lua_register(pGlobalLua, "getUserWindowSize", TLuaInterpreter::getUserWindowSize); + lua_register(pGlobalLua, "getWindowGeometry", TLuaInterpreter::getWindowGeometry); + lua_register(pGlobalLua, "windowVisible", TLuaInterpreter::windowVisible); + lua_register(pGlobalLua, "getLabelText", TLuaInterpreter::getLabelText); lua_register(pGlobalLua, "getMousePosition", TLuaInterpreter::getMousePosition); lua_register(pGlobalLua, "setProfileIcon", TLuaInterpreter::setProfileIcon); lua_register(pGlobalLua, "resetProfileIcon", TLuaInterpreter::resetProfileIcon); @@ -5290,6 +5564,7 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "setConsoleBufferSize", TLuaInterpreter::setConsoleBufferSize); lua_register(pGlobalLua, "enableScrollBar", TLuaInterpreter::enableScrollBar); lua_register(pGlobalLua, "disableScrollBar", TLuaInterpreter::disableScrollBar); + lua_register(pGlobalLua, "getScrollBarVisible", TLuaInterpreter::getScrollBarVisible); lua_register(pGlobalLua, "enableHorizontalScrollBar", TLuaInterpreter::enableHorizontalScrollBar); lua_register(pGlobalLua, "disableHorizontalScrollBar", TLuaInterpreter::disableHorizontalScrollBar); lua_register(pGlobalLua, "enableCommandLine", TLuaInterpreter::enableCommandLine); @@ -5319,6 +5594,7 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "killAlias", TLuaInterpreter::killAlias); lua_register(pGlobalLua, "setLabelStyleSheet", TLuaInterpreter::setLabelStyleSheet); lua_register(pGlobalLua, "setUserWindowStyleSheet", TLuaInterpreter::setUserWindowStyleSheet); + lua_register(pGlobalLua, "getUserWindowStyleSheet", TLuaInterpreter::getUserWindowStyleSheet); lua_register(pGlobalLua, "getTime", TLuaInterpreter::getTime); lua_register(pGlobalLua, "getEpoch", TLuaInterpreter::getEpoch); lua_register(pGlobalLua, "invokeFileDialog", TLuaInterpreter::invokeFileDialog); @@ -5611,6 +5887,7 @@ void TLuaInterpreter::initLuaGlobals() lua_register(pGlobalLua, "getConnectionInfo", TLuaInterpreter::getConnectionInfo); lua_register(pGlobalLua, "unzipAsync", TLuaInterpreter::unzipAsync); lua_register(pGlobalLua, "setMapWindowTitle", TLuaInterpreter::setMapWindowTitle); + lua_register(pGlobalLua, "getMapWindowTitle", TLuaInterpreter::getMapWindowTitle); lua_register(pGlobalLua, "getMudletInfo", TLuaInterpreter::getMudletInfo); lua_register(pGlobalLua, "getMapBackgroundColor", TLuaInterpreter::getMapBackgroundColor); lua_register(pGlobalLua, "setMapBackgroundColor", TLuaInterpreter::setMapBackgroundColor); @@ -6812,7 +7089,9 @@ int TLuaInterpreter::spellCheckWord(lua_State* L) bool hasSharedDictionary = false; host.getUserDictionaryOptions(hasUserDictionary, hasSharedDictionary); - const QString text = getVerifiedString(L, __func__, 1, "word"); + if (!checkStringArg(L, __func__, 1, "word")) { + return lua_error(L); + } bool useUserDictionary = false; if (lua_gettop(L) > 1) { @@ -6821,6 +7100,7 @@ int TLuaInterpreter::spellCheckWord(lua_State* L) return warnArgumentValue(L, __func__, "no user dictionary enabled in the preferences for this profile"); } } + const QString text{lua_tostring(L, 1)}; Hunhandle* handle = nullptr; QByteArray encodedText; @@ -6848,7 +7128,9 @@ int TLuaInterpreter::spellSuggestWord(lua_State* L) bool hasSharedDictionary = false; host.getUserDictionaryOptions(hasUserDictionary, hasSharedDictionary); - const QString text = getVerifiedString(L, __func__, 1, "word"); + if (!checkStringArg(L, __func__, 1, "word")) { + return lua_error(L); + } bool useUserDictionary = false; if (lua_gettop(L) > 1) { @@ -6857,6 +7139,7 @@ int TLuaInterpreter::spellSuggestWord(lua_State* L) return warnArgumentValue(L, __func__, "no user dictionary enabled in the preferences for this profile"); } } + const QString text{lua_tostring(L, 1)}; char** wordList; size_t wordCount = 0; @@ -6980,20 +7263,23 @@ int TLuaInterpreter::getProfileInformation(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Miscellaneous_Functions#setProfileInformation int TLuaInterpreter::setProfileInformation(lua_State* L) { - QString profileName = getHostFromLua(L).getName(); - QString text; const int params = lua_gettop(L); - switch (params) { - case 1: { - text = getVerifiedString(L, __func__, 1, "text"); - break; - } - default: { - profileName = getVerifiedString(L, __func__, 1, "profile name"); - text = getVerifiedString(L, __func__, 2, "text"); - break; + if (params == 1) { + if (!checkStringArg(L, __func__, 1, "text")) { + return lua_error(L); + } + } else if (!checkStringArg(L, __func__, 1, "profile name") || !checkStringArg(L, __func__, 2, "text")) { + return lua_error(L); } + + QString profileName = getHostFromLua(L).getName(); + QString text; + if (params == 1) { + text = lua_tostring(L, 1); + } else { + profileName = lua_tostring(L, 1); + text = lua_tostring(L, 2); } QPair<bool, QString> result = mudlet::self()->writeProfileData(profileName, qsl("description"), text); @@ -7009,18 +7295,14 @@ int TLuaInterpreter::setProfileInformation(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Miscellaneous_Functions#clearProfileInformation int TLuaInterpreter::clearProfileInformation(lua_State* L) { - QString profileName = getHostFromLua(L).getName(); - QString desc = ""; const int params = lua_gettop(L); - - switch (params) { - case 0: - break; - default: - profileName = getVerifiedString(L, __func__, 1, "profile name"); - break; + if (params > 0 && !checkStringArg(L, __func__, 1, "profile name")) { + return lua_error(L); } + QString profileName = (params > 0) ? QString{lua_tostring(L, 1)} : getHostFromLua(L).getName(); + QString desc = ""; + // if this is a default game, return to the orginal text auto itDetails = TGameDetails::findGame(profileName); if (itDetails != TGameDetails::scmDefaultGames.constEnd()) { @@ -7408,15 +7690,15 @@ int TLuaInterpreter::setMapRoomExitsColor(lua_State* L) int TLuaInterpreter::showNotification(lua_State* L) { const int n = lua_gettop(L); - const QString title = getVerifiedString(L, __func__, 1, "title"); - QString text = title; - if (n >= 2) { - text = getVerifiedString(L, __func__, 2, "message"); + if (!checkStringArg(L, __func__, 1, "title") || (n >= 2 && !checkStringArg(L, __func__, 2, "message")) || (n >= 3 && !checkNumberArg(L, __func__, 3, "expiration time in seconds"))) { + return lua_error(L); } std::optional<int> notificationExpirationTime; if (n >= 3) { - notificationExpirationTime = qMax(qRound(getVerifiedDouble(L, __func__, 3, "expiration time in seconds") * 1000), 1000); + notificationExpirationTime = qMax(qRound(lua_tonumber(L, 3) * 1000), 1000); } + const QString title{lua_tostring(L, 1)}; + const QString text = (n >= 2) ? QString{lua_tostring(L, 2)} : title; mudlet::self()->mTrayIcon.show(); if (notificationExpirationTime.has_value()) { @@ -7462,14 +7744,21 @@ int TLuaInterpreter::setConfig(lua_State* L) { auto& host = getHostFromLua(L); const bool currentHost = (mudlet::self()->mpCurrentActiveHost == &host); - QString key = getVerifiedString(L, __func__, 1, "key"); + if (!checkStringArg(L, __func__, 1, "key")) { + return lua_error(L); + } + // a view rather than a QString because the getVerified*() calls below raise - + // see checkStringArg(). Every comparison is against an ASCII literal, so + // reading the key as Latin-1 picks the branch a UTF-8 QString would; the two + // places that show the key decode it as UTF-8 there and then + const QLatin1StringView key{lua_tostring(L, 1)}; if (key.isEmpty()) { return warnArgumentValue(L, __func__, "you must provide key"); } auto success = [&]() { if (mudlet::smDebugMode) { - TDebug(Qt::white, Qt::blue) << qsl("setConfig: a script has changed %1\n").arg(key) >> &host; + TDebug(Qt::white, Qt::blue) << qsl("setConfig: a script has changed %1\n").arg(QString::fromUtf8(lua_tostring(L, 1))) >> &host; } lua_pushboolean(L, true); return 1; @@ -7860,7 +8149,8 @@ int TLuaInterpreter::setConfig(lua_State* L) // Handle experiment keys if (key.startsWith(qsl("experiment."))) { - auto [result, errorMessage] = host.setExperimentEnabled(key, getVerifiedBool(L, __func__, 2, "value")); + const bool enabled = getVerifiedBool(L, __func__, 2, "value"); + auto [result, errorMessage] = host.setExperimentEnabled(QString::fromUtf8(lua_tostring(L, 1)), enabled); if (!result) { return warnArgumentValue(L, __func__, errorMessage); } @@ -7919,29 +8209,24 @@ int TLuaInterpreter::setConfig(lua_State* L) } return warnArgumentValue(L, __func__, result.second); } - return warnArgumentValue(L, __func__, qsl("'%1' isn't a valid configuration option").arg(key)); + return warnArgumentValue(L, __func__, qsl("'%1' isn't a valid configuration option").arg(QString::fromUtf8(lua_tostring(L, 1)))); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#announce int TLuaInterpreter::announce(lua_State* L) { - const QString text = getVerifiedString(L, __func__, 1, "text to announce"); static const QStringList processingKinds{"importantall", "importantmostrecent", "all", "mostrecent", "currentthenmostrecent"}; - QString processing; - const int n = lua_gettop(L); - if (n > 1) { - // while this only has effect on Windows, it should fail silently in order not to spam - processing = getVerifiedString(L, __func__, 2, "processing style"); - - if (!processingKinds.contains(processing)) { - lua_pushfstring( - L, "%s: bad argument #%d type (processing should be one of %s, got %s!)", __func__, 2, processingKinds.join(qsl(", ")).toUtf8().constData(), processing.toUtf8().constData()); - return lua_error(L); - } + if (!checkStringArg(L, __func__, 1, "text to announce") || (n > 1 && !checkStringArg(L, __func__, 2, "processing style"))) { + return lua_error(L); + } + // while this only has effect on Windows, it should fail silently in order not to spam + if (n > 1 && !processingKinds.contains(QLatin1StringView{lua_tostring(L, 2)})) { + lua_pushfstring(L, "%s: bad argument #%d type (processing should be one of %s, got %s!)", __func__, 2, processingKinds.join(qsl(", ")).toUtf8().constData(), lua_tostring(L, 2)); + return lua_error(L); } - mudlet::self()->announce(text, processing, true); + mudlet::self()->announce(QString{lua_tostring(L, 1)}, (n > 1) ? QString{lua_tostring(L, 2)} : QString(), true); return 0; } @@ -8341,11 +8626,11 @@ int TLuaInterpreter::getSaveCommandHistory(lua_State* L) lua_pushstring(L, "disabled by profile global preference"); return 2; } - QString name = QLatin1String("main"); + const char* name = "main"; if (lua_gettop(L)) { name = CMDLINE_NAME(L, 1); } - auto pCommandline = COMMANDLINE(L, name); + auto pCommandline = COMMANDLINE(L, QString{name}); lua_pushboolean(L, pCommandline->mSaveCommands); lua_pushstring(L, (pCommandline->mSaveCommands ? qsl("enabled (%1 lines will be saved)").arg(QString::number(numberOfLines)) : qsl("disabled")).toUtf8().constData()); return 2; @@ -8363,7 +8648,7 @@ int TLuaInterpreter::setSaveCommandHistory(lua_State* L) // profile: return warnArgumentValue(L, __func__, "disabled by profile global preference"); } - QString name = QLatin1String("main"); + const char* name = "main"; bool saveCommands = true; // if there is no arguments we will set the "save command history" on the // main command line: @@ -8387,7 +8672,7 @@ int TLuaInterpreter::setSaveCommandHistory(lua_State* L) } } - auto pCommandline = COMMANDLINE(L, name); + auto pCommandline = COMMANDLINE(L, QString{name}); pCommandline->mSaveCommands = saveCommands; lua_pushboolean(L, true); return 1; diff --git a/src/TLuaInterpreter.h b/src/TLuaInterpreter.h index e5376925a..d47642dde 100644 --- a/src/TLuaInterpreter.h +++ b/src/TLuaInterpreter.h @@ -370,6 +370,7 @@ public: static int getFontSize(lua_State*); static int openUserWindow(lua_State*); static int setUserWindowTitle(lua_State*); + static int getUserWindowTitle(lua_State*); static int echoUserWindow(lua_State*); static int clearUserWindow(lua_State*); static int enableTimer(lua_State*); @@ -395,6 +396,8 @@ public: static int selectCaptureGroup(lua_State*); static int tempLineTrigger(lua_State*); static int raiseEvent(lua_State*); + static int waitForEvent(lua_State*); + static int pumpEvents(lua_State*); static int deleteLine(lua_State*); static int copy(lua_State*); static int cut(lua_State*); @@ -444,8 +447,8 @@ public: static int createMiniConsole(lua_State*); static int createScrollBox(lua_State*); static int createLabel(lua_State*); - static int createLabelMainWindow(lua_State*, const QString& labelName); - static int createLabelUserWindow(lua_State*, const QString& windowName, const QString& labelName); + static int createLabelMainWindow(lua_State*, const char* labelName); + static int createLabelUserWindow(lua_State*, const char* windowName, const char* labelName); static int deleteLabel(lua_State*); static int deleteMiniConsole(lua_State*); static int deleteCommandLine(lua_State*); @@ -462,12 +465,14 @@ public: static int setTextEditTabMovesFocus(lua_State*); static int deleteScrollBox(lua_State*); static int setLabelToolTip(lua_State*); + static int getLabelToolTip(lua_State*); static int setLabelCursor(lua_State*); static int setLabelCustomCursor(lua_State*); static int moveWindow(lua_State*); static int setWindow(lua_State*); static int openMapWidget(lua_State*); static int closeMapWidget(lua_State*); + static int getMapWidgetGeometry(lua_State*); static int setTextFormat(lua_State*); static int setBackgroundImage(lua_State*); static int resetBackgroundImage(lua_State*); @@ -484,6 +489,7 @@ public: static int setCmdLineAction(lua_State*); static int resetCmdLineAction(lua_State*); static int setCmdLineStyleSheet(lua_State*); + static int getCmdLineStyleSheet(lua_State*); static int getImageSize(lua_State*); static int setLabelDoubleClickCallback(lua_State*); static int setLabelReleaseCallback(lua_State*); @@ -493,6 +499,9 @@ public: static int setLabelOnLeave(lua_State*); static int getMainWindowSize(lua_State*); static int getUserWindowSize(lua_State*); + static int getWindowGeometry(lua_State*); + static int windowVisible(lua_State*); + static int getLabelText(lua_State*); static int getMousePosition(lua_State*); static int setProfileIcon(lua_State*); static int resetProfileIcon(lua_State*); @@ -555,6 +564,7 @@ public: static int getConsoleBufferSize(lua_State*); static int setConsoleBufferSize(lua_State*); static int enableScrollBar(lua_State*); + static int getScrollBarVisible(lua_State*); static int disableScrollBar(lua_State*); static int disableHorizontalScrollBar(lua_State*); static int enableHorizontalScrollBar(lua_State*); @@ -594,6 +604,7 @@ public: static int killAlias(lua_State*); static int permBeginOfLineStringTrigger(lua_State*); static int setUserWindowStyleSheet(lua_State*); + static int getUserWindowStyleSheet(lua_State*); static int getTime(lua_State*); static int getEpoch(lua_State*); static int invokeFileDialog(lua_State*); @@ -722,6 +733,7 @@ public: static int getConnectionInfo(lua_State*); static int unzipAsync(lua_State*); static int setMapWindowTitle(lua_State*); + static int getMapWindowTitle(lua_State*); static int getMudletInfo(lua_State*); static int getMapBackgroundColor(lua_State*); static int setMapBackgroundColor(lua_State*); @@ -780,6 +792,12 @@ public: void freeLuaRegistryIndex(int index); void freeAllInLuaRegistry(TEvent); + // Called from Host::raiseEvent(), to unblock a waitForEvent() on that event. + void captureEventForWaits(const TEvent&); + // Lets callers refuse anything that would lua_close() the state the pump is + // running Lua on. Always false outside MUDLET_TEST_MODE. + bool pumpingEvents() const { return !mPendingEventWaits.isEmpty() || mEventPumpDepth > 0; } + inline static const QMap<Qt::MouseButton, QString> csmMouseButtons = { {Qt::NoButton, qsl("NoButton")}, {Qt::LeftButton, qsl("LeftButton")}, {Qt::RightButton, qsl("RightButton")}, {Qt::MiddleButton, qsl("MidButton")}, {Qt::BackButton, qsl("BackButton")}, {Qt::ForwardButton, qsl("ForwardButton")}, {Qt::TaskButton, qsl("TaskButton")}, {Qt::ExtraButton4, qsl("ExtraButton4")}, @@ -809,6 +827,14 @@ public slots: private: static bool getVerifiedBool(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); static QString getVerifiedString(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); + static bool checkStringArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); + static bool checkIntArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); + static bool checkBoolArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); + static bool checkNumberArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); + static bool checkStringOrIntegerArg(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); + static bool checkCommandOrFunctionArg(lua_State*, const char* functionName, const int pos); + static bool checkCommandsOrFunctionsTable(lua_State*, const char* functionName, const int index); + static bool checkHintsTable(lua_State*, const char* functionName, const int index); static int getVerifiedInt(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); static float getVerifiedFloat(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); static double getVerifiedDouble(lua_State*, const char* functionName, const int pos, const char* publicName, const bool isOptional = false); @@ -816,11 +842,13 @@ private: static void errorArgumentType(lua_State*, const char* functionName, const int pos, const char* publicName, const char* publicType, const bool isOptional = false); static int warnArgumentValue(lua_State*, const char* functionName, const QString& message, const bool useFalseInsteadofNil = false); static int warnArgumentValue(lua_State*, const char* functionName, const char* message, const bool useFalseInsteadofNil = false); - static int setLabelCallback(lua_State*, const QString& funcName); - static int movieFunc(lua_State*, const QString& funcName); + static int setLabelCallback(lua_State*, const char* funcName); + static int movieFunc(lua_State*, const char* funcName); static std::pair<bool, QString> discordApiEnabled(lua_State*, bool writeAccess = false); static void setRequestDefaults(const QUrl& url, QNetworkRequest& request); - static int performHttpRequest(lua_State*, const char* functionName, const int pos, QNetworkAccessManager::Operation operation, const QString& verb); + static int performHttpRequest(lua_State*, const char* functionName, const int pos, QNetworkAccessManager::Operation operation, const char* verb); + static void validateHttpHeaders(lua_State*, const int index, const char* functionName); + static void applyHttpHeaders(lua_State*, const int index, QNetworkRequest& request); // The last argument is only needed if the third one is true: static void generateElapsedTimeTable(lua_State*, const QStringList&, const bool, const qint64 elapsedTimeMilliSeconds = 0); static std::tuple<bool, int> getWatchId(lua_State*, Host&); @@ -893,6 +921,21 @@ private: QMap<QString, QPair<int, int>> mCapturedNameGroupsPosList; QVector<QVector<QPair<QString, QString>>> mMultiCaptureNameGroups; QMap<QNetworkReply*, QString> downloadMap; + + // A waitForEvent() call in progress. mArgsRef is a Lua registry reference, + // so it has to be unref'd once the waiter has read it. + struct TEventWait + { + QString mName; + int mArgsRef = LUA_NOREF; + bool mCaptured = false; + }; + QList<TEventWait*> mPendingEventWaits; + // pumpEvents() registers no TEventWait of its own, so it needs its own + // counter to be visible to pumpingEvents(). + int mEventPumpDepth = 0; + int createEventArgsTableRef(const TEvent&); + lua_State* pGlobalLua = nullptr; std::unique_ptr<lua_State, lua_state_deleter> pIndenterState; QPointer<Host> mpHost; diff --git a/src/TLuaInterpreterDiscord.cpp b/src/TLuaInterpreterDiscord.cpp index c1d8d7d7e..ee1fa255c 100644 --- a/src/TLuaInterpreterDiscord.cpp +++ b/src/TLuaInterpreterDiscord.cpp @@ -118,7 +118,10 @@ int TLuaInterpreter::getDiscordDetail(lua_State* L) return warnArgumentValue(L, __func__, result.second); } - lua_pushfstring(L, pMudlet->mDiscord.getDetailText(&host).toUtf8().constData()); + // Pushed as data, never as a format string: presence text can come from the + // game server, and a '%' in it would otherwise be taken as a printf + // specifier. The same holds for the five other Discord text getters below. + lua_pushstring(L, pMudlet->mDiscord.getDetailText(&host).toUtf8().constData()); return 1; } @@ -133,7 +136,7 @@ int TLuaInterpreter::getDiscordLargeIcon(lua_State* L) return warnArgumentValue(L, __func__, result.second); } - lua_pushfstring(L, pMudlet->mDiscord.getLargeImage(&host).toUtf8().constData()); + lua_pushstring(L, pMudlet->mDiscord.getLargeImage(&host).toUtf8().constData()); return 1; } @@ -148,7 +151,7 @@ int TLuaInterpreter::getDiscordLargeIconText(lua_State* L) return warnArgumentValue(L, __func__, result.second); } - lua_pushfstring(L, pMudlet->mDiscord.getLargeImageText(&host).toUtf8().constData()); + lua_pushstring(L, pMudlet->mDiscord.getLargeImageText(&host).toUtf8().constData()); return 1; } @@ -180,7 +183,7 @@ int TLuaInterpreter::getDiscordSmallIcon(lua_State* L) return warnArgumentValue(L, __func__, result.second); } - lua_pushfstring(L, pMudlet->mDiscord.getSmallImage(&host).toUtf8().constData()); + lua_pushstring(L, pMudlet->mDiscord.getSmallImage(&host).toUtf8().constData()); return 1; } @@ -195,7 +198,7 @@ int TLuaInterpreter::getDiscordSmallIconText(lua_State* L) return warnArgumentValue(L, __func__, result.second); } - lua_pushfstring(L, pMudlet->mDiscord.getSmallImageText(&host).toUtf8().constData()); + lua_pushstring(L, pMudlet->mDiscord.getSmallImageText(&host).toUtf8().constData()); return 1; } @@ -210,7 +213,7 @@ int TLuaInterpreter::getDiscordState(lua_State* L) return warnArgumentValue(L, __func__, result.second); } - lua_pushfstring(L, pMudlet->mDiscord.getStateText(&host).toUtf8().constData()); + lua_pushstring(L, pMudlet->mDiscord.getStateText(&host).toUtf8().constData()); return 1; } @@ -371,11 +374,21 @@ int TLuaInterpreter::setDiscordGameUrl(lua_State* L) lua_pushboolean(L, true); return 1; } - QString inputText = getVerifiedString(L, __func__, 1, "url").trimmed(); - host.setDiscordInviteURL(inputText.isEmpty() ? QString() : inputText); + // argument 1 is applied before argument 2 is checked, as it was before: + // setDiscordInviteURL() persists to the profile, and hoisting the second + // check above it would stop a bad game name from saving the URL + if (!checkStringArg(L, __func__, 1, "url")) { + return lua_error(L); + } + { + const QString inviteUrl = QString{lua_tostring(L, 1)}.trimmed(); + host.setDiscordInviteURL(inviteUrl.isEmpty() ? QString() : inviteUrl); + } if (args > 1) { - inputText = getVerifiedString(L, __func__, 2, "game name").trimmed(); - host.setDiscordGameName(inputText); + if (!checkStringArg(L, __func__, 2, "game name")) { + return lua_error(L); + } + host.setDiscordGameName(QString{lua_tostring(L, 2)}.trimmed()); } else { host.setDiscordGameName(QString()); } diff --git a/src/TLuaInterpreterMMCP.cpp b/src/TLuaInterpreterMMCP.cpp index 8a409e70c..65310a8f7 100644 --- a/src/TLuaInterpreterMMCP.cpp +++ b/src/TLuaInterpreterMMCP.cpp @@ -30,8 +30,11 @@ int TLuaInterpreter::mmcpChatTo(lua_State* L) { const char* sFunc = "mmcp.chatTo"; - const QString target = getVerifiedString(L, sFunc, 1, "target"); - const QString msg = getVerifiedString(L, sFunc, 2, "message"); + if (!checkStringArg(L, sFunc, 1, "target") || !checkStringArg(L, sFunc, 2, "message")) { + return lua_error(L); + } + const QString target{lua_tostring(L, 1)}; + const QString msg{lua_tostring(L, 2)}; Host* pHost = &getHostFromLua(L); if (!pHost->mMMCPServer) { @@ -107,17 +110,23 @@ int TLuaInterpreter::mmcpAllowSnoop(lua_State* L) int TLuaInterpreter::mmcpCall(lua_State* L) { const char* sFunc = "mmcp.call"; - const QString host = getVerifiedString(L, sFunc, 1, "host"); + if (!checkStringArg(L, sFunc, 1, "host")) { + return lua_error(L); + } int port = csDefaultMMCPHostPort; const int n = lua_gettop(L); if (n > 1) { - port = getVerifiedInt(L, sFunc, 2, qsl("port number {default = %1}").arg(csDefaultMMCPHostPort).toUtf8().constData(), true); + // static: a temporary here would be alive inside getVerifiedInt() when + // it raises - see checkStringArg() + static const QByteArray portName = qsl("port number {default = %1}").arg(csDefaultMMCPHostPort).toUtf8(); + port = getVerifiedInt(L, sFunc, 2, portName.constData(), true); if (port > 65535 || port < 1) { return warnArgumentValue(L, sFunc, qsl("invalid port number %1 given, if supplied it must be in range 1 to 65535").arg(port)); } } + const QString host{lua_tostring(L, 1)}; Host* pHost = &getHostFromLua(L); if (!pHost->mMMCPServer) { pHost->initMMCPServer(); @@ -186,8 +195,11 @@ int TLuaInterpreter::mmcpEmoteAll(lua_State* L) int TLuaInterpreter::mmcpChatGroup(lua_State* L) { const char* sFunc = "mmcp.chatGroup"; - const QString group = getVerifiedString(L, sFunc, 1, "group"); - const QString msg = getVerifiedString(L, sFunc, 2, "message"); + if (!checkStringArg(L, sFunc, 1, "group") || !checkStringArg(L, sFunc, 2, "message")) { + return lua_error(L); + } + const QString group{lua_tostring(L, 1)}; + const QString msg{lua_tostring(L, 2)}; Host* pHost = &getHostFromLua(L); if (!pHost->mMMCPServer) { @@ -363,8 +375,11 @@ int TLuaInterpreter::mmcpServe(lua_State* L) int TLuaInterpreter::mmcpSetGroup(lua_State* L) { const char* sFunc = "mmcp.setGroup"; - const QString target = getVerifiedString(L, sFunc, 1, "target"); - const QString group = getVerifiedString(L, sFunc, 2, "group"); + if (!checkStringArg(L, sFunc, 1, "target") || !checkStringArg(L, sFunc, 2, "group")) { + return lua_error(L); + } + const QString target{lua_tostring(L, 1)}; + const QString group{lua_tostring(L, 2)}; Host* pHost = &getHostFromLua(L); if (!pHost->mMMCPServer) { @@ -383,8 +398,11 @@ int TLuaInterpreter::mmcpSetGroup(lua_State* L) int TLuaInterpreter::mmcpSendSideChannel(lua_State* L) { const char* sFunc = "mmcp.sendSideChannel"; - const QString channel = getVerifiedString(L, sFunc, 1, "channel"); - const QString message = getVerifiedString(L, sFunc, 2, "message"); + if (!checkStringArg(L, sFunc, 1, "channel") || !checkStringArg(L, sFunc, 2, "message")) { + return lua_error(L); + } + const QString channel{lua_tostring(L, 1)}; + const QString message{lua_tostring(L, 2)}; Host* pHost = &getHostFromLua(L); if (!pHost->mMMCPServer) { @@ -453,8 +471,8 @@ int TLuaInterpreter::mmcpStopServer(lua_State* L) if (!result.first) { return warnArgumentValue(L, sFunc, result.second.toUtf8().constData()); } - } - + } + lua_pushboolean(L, true); return 1; } @@ -478,7 +496,8 @@ int TLuaInterpreter::mmcpDisconnect(lua_State* L) return 1; } -int TLuaInterpreter::mmcpGetClientList(lua_State* L) { +int TLuaInterpreter::mmcpGetClientList(lua_State* L) +{ Host* pHost = &getHostFromLua(L); if (!pHost->mMMCPServer) { @@ -524,10 +543,10 @@ int TLuaInterpreter::mmcpGetClientList(lua_State* L) { lua_pushstring(L, pClient->getVersion().toUtf8().constData()); lua_settable(L, -3); - + lua_pushnumber(L, ++i); // Push outer table key (index) - lua_insert(L, -2); // Swap the inner table and key so that the table is on top - lua_settable(L, -3); // Set the inner table in the outer table. + lua_insert(L, -2); // Swap the inner table and key so that the table is on top + lua_settable(L, -3); // Set the inner table in the outer table. } return 1; diff --git a/src/TLuaInterpreterMapper.cpp b/src/TLuaInterpreterMapper.cpp index f1338606b..cd71e75a4 100644 --- a/src/TLuaInterpreterMapper.cpp +++ b/src/TLuaInterpreterMapper.cpp @@ -306,10 +306,7 @@ int TLuaInterpreter::addCustomLine(lua_State* L) int g = 0; int b = 0; Qt::PenStyle line_style(Qt::SolidLine); - QString direction; - QList<qreal> x; - QList<qreal> y; - QList<int> z; + TRoom* pR_to = nullptr; const int id_from = getVerifiedInt(L, __func__, 1, "roomID"); TRoom* pR = host.mpMap->mpRoomDB->getRoom(id_from); if (!pR) { @@ -322,7 +319,7 @@ int TLuaInterpreter::addCustomLine(lua_State* L) } if (lua_isnumber(L, 2)) { id_to = static_cast<int>(lua_tointeger(L, 2)); - TRoom* pR_to = host.mpMap->mpRoomDB->getRoom(id_to); + pR_to = host.mpMap->mpRoomDB->getRoom(id_to); if (!pR_to) { return warnArgumentValue(L, __func__, qsl("number %1 is not a valid target roomID").arg(id_to)); } @@ -335,13 +332,12 @@ int TLuaInterpreter::addCustomLine(lua_State* L) qsl("target room is in area '%1' (ID: %2) which is not the one '%3' (ID: %4) in which this custom line is to be drawn") .arg((host.mpMap->mpRoomDB->getAreaNamesMap()).value(area_to), QString::number(area_to), (host.mpMap->mpRoomDB->getAreaNamesMap()).value(area), QString::number(area))); } - - x.append(static_cast<qreal>(pR_to->x())); - y.append(static_cast<qreal>(pR_to->y())); - z.append(pR->z()); } else if (lua_istable(L, 2)) { lua_pushnil(L); int i = 0; // Indexes groups of coordinates in the table + int xCount = 0; + int yCount = 0; + int zCount = 0; while (lua_next(L, 2) != 0) { ++i; if (lua_type(L, -1) != LUA_TTABLE) { @@ -382,13 +378,13 @@ int TLuaInterpreter::addCustomLine(lua_State* L) } switch (j) { case 1: - x.append(lua_tonumber(L, -1)); + ++xCount; break; case 2: - y.append(lua_tonumber(L, -1)); + ++yCount; break; case 3: - z.append(static_cast<int>(lua_tonumber(L, -1))); + ++zCount; break; default:; // No-op } @@ -397,14 +393,14 @@ int TLuaInterpreter::addCustomLine(lua_State* L) } lua_pop(L, 1); } - if (!i || x.isEmpty()) { + if (!i || !xCount) { // If there is only an empty sub-table inside the table then i is - // one but there is nothing in any of the QLists and things will - // still blow up as per Issue #5272 - so also check for at least one + // one but there is no coordinate at all and things will still blow + // up as per Issue #5272 - so also check for at least one // x-coordinate value: return warnArgumentValue(L, __func__, "missing coordinates to create the line to"); } - if (x.count() != y.count() || x.count() != z.count()) { + if (xCount != yCount || xCount != zCount) { return warnArgumentValue(L, __func__, "mismatch in numbers of coordinates for the points for the custom line given in table as second argument; each must contain three coordinates, i.e. x, y AND z " @@ -412,28 +408,35 @@ int TLuaInterpreter::addCustomLine(lua_State* L) } } - direction = dirToString(L, 3); - if (direction.isEmpty()) { - lua_pushfstring(L, "addCustomLine: bad argument #3 type (direction as string or number (between 1 and 12 inclusive) expected, got %s!)", luaL_typename(L, 3)); - return lua_error(L); - } - if (!pR->hasExitOrSpecialExit(direction)) { - return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit in a direction that can be identified from '%2'").arg(QString::number(id_from), lua_tostring(L, 3))); + { + const QString direction = dirToString(L, 3); + if (direction.isEmpty()) { + lua_pushfstring(L, "addCustomLine: bad argument #3 type (direction as string or number (between 1 and 12 inclusive) expected, got %s!)", luaL_typename(L, 3)); + return lua_error(L); + } + if (!pR->hasExitOrSpecialExit(direction)) { + return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit in a direction that can be identified from '%2'").arg(QString::number(id_from), lua_tostring(L, 3))); + } } - const QString lineStyleString = getVerifiedString(L, __func__, 4, "line style"); - if (!lineStyleString.compare(QLatin1String("solid line"))) { - line_style = Qt::SolidLine; - } else if (!lineStyleString.compare(QLatin1String("dot line"))) { - line_style = Qt::DotLine; - } else if (!lineStyleString.compare(QLatin1String("dash line"))) { - line_style = Qt::DashLine; - } else if (!lineStyleString.compare(QLatin1String("dash dot line"))) { - line_style = Qt::DashDotLine; - } else if (!lineStyleString.compare(QLatin1String("dash dot dot line"))) { - line_style = Qt::DashDotDotLine; - } else { - return warnArgumentValue(L, __func__, qsl("invalid line style '%1', only use one of: 'solid line', 'dot line', 'dash line', 'dash dot line' or 'dash dot dot line'").arg(lineStyleString)); + if (!checkStringArg(L, __func__, 4, "line style")) { + return lua_error(L); + } + { + const QString lineStyleString{lua_tostring(L, 4)}; + if (!lineStyleString.compare(QLatin1String("solid line"))) { + line_style = Qt::SolidLine; + } else if (!lineStyleString.compare(QLatin1String("dot line"))) { + line_style = Qt::DotLine; + } else if (!lineStyleString.compare(QLatin1String("dash line"))) { + line_style = Qt::DashLine; + } else if (!lineStyleString.compare(QLatin1String("dash dot line"))) { + line_style = Qt::DashDotLine; + } else if (!lineStyleString.compare(QLatin1String("dash dot dot line"))) { + line_style = Qt::DashDotDotLine; + } else { + return warnArgumentValue(L, __func__, qsl("invalid line style '%1', only use one of: 'solid line', 'dot line', 'dash line', 'dash dot line' or 'dash dot dot line'").arg(lineStyleString)); + } } if (!lua_istable(L, 5)) { @@ -478,6 +481,38 @@ int TLuaInterpreter::addCustomLine(lua_State* L) } const bool arrow = getVerifiedBool(L, __func__, 6, "end with arrow"); + + QList<qreal> x; + QList<qreal> y; + QList<int> z; + if (pR_to) { + x.append(static_cast<qreal>(pR_to->x())); + y.append(static_cast<qreal>(pR_to->y())); + z.append(pR->z()); + } else { + lua_pushnil(L); + while (lua_next(L, 2) != 0) { + lua_pushnil(L); + int j = 0; + while (lua_next(L, -2) != 0) { + switch (++j) { + case 1: + x.append(lua_tonumber(L, -1)); + break; + case 2: + y.append(lua_tonumber(L, -1)); + break; + case 3: + z.append(static_cast<int>(lua_tonumber(L, -1))); + break; + default:; // No-op + } + lua_pop(L, 1); + } + lua_pop(L, 1); + } + } + const int lz = z.at(0); QList<QPointF> points; points.append(QPointF(x.at(0), y.at(0))); @@ -488,6 +523,7 @@ int TLuaInterpreter::addCustomLine(lua_State* L) points.append(QPointF(x.at(i), y.at(i))); } + const QString direction = dirToString(L, 3); //Heiko: direction/line relationship must be unique pR->customLines[direction] = points; pR->customLinesArrow[direction] = arrow; @@ -509,9 +545,12 @@ int TLuaInterpreter::addCustomLine(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#addMapEvent int TLuaInterpreter::addMapEvent(lua_State* L) { + if (!checkStringArg(L, __func__, 1, "uniquename")) { + return lua_error(L); + } QStringList actionInfo; - const QString uniqueName = getVerifiedString(L, __func__, 1, "uniquename"); actionInfo << getVerifiedString(L, __func__, 2, "event name"); + const QString uniqueName{lua_tostring(L, 1)}; if (!lua_isstring(L, 3)) { actionInfo << QString(); @@ -912,6 +951,22 @@ int TLuaInterpreter::closeMapWidget(lua_State* L) return 1; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getMapWidgetGeometry +int TLuaInterpreter::getMapWidgetGeometry(lua_State* L) +{ + const Host& host = getHostFromLua(L); + + if (auto geometry = host.mapWidgetGeometry()) { + lua_pushnumber(L, geometry->x()); + lua_pushnumber(L, geometry->y()); + lua_pushnumber(L, geometry->width()); + lua_pushnumber(L, geometry->height()); + return 4; + } + + return warnArgumentValue(L, __func__, "no floating/dockable type map window found"); +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#connectExitStub int TLuaInterpreter::connectExitStub(lua_State* L) { @@ -1029,13 +1084,14 @@ int TLuaInterpreter::createMapLabel(lua_State* L) bool showOnTop = true; bool noScaling = true; bool temporary = false; - QString fontName; int foregroundTransparency = 255; int backgroundTransparency = 50; const int args = lua_gettop(L); const int area = getVerifiedInt(L, __func__, 1, "areaID"); - const QString text = getVerifiedString(L, __func__, 2, "text"); + if (!checkStringArg(L, __func__, 2, "text")) { + return lua_error(L); + } const float posx = getVerifiedFloat(L, __func__, 3, "posX"); const float posy = getVerifiedFloat(L, __func__, 4, "posY"); const float posz = getVerifiedFloat(L, __func__, 5, "posZ"); @@ -1059,8 +1115,8 @@ int TLuaInterpreter::createMapLabel(lua_State* L) } } } - if (args > 15) { - fontName = getVerifiedString(L, __func__, 16, "fontName", true); + if (args > 15 && !checkStringArg(L, __func__, 16, "fontName", true)) { + return lua_error(L); } if (args > 16) { foregroundTransparency = getVerifiedInt(L, __func__, 17, "foregroundTransparency", true); @@ -1078,6 +1134,11 @@ int TLuaInterpreter::createMapLabel(lua_State* L) } const Host& host = getHostFromLua(L); + const QString text{lua_tostring(L, 2)}; + QString fontName; + if (args > 15) { + fontName = lua_tostring(L, 16); + } lua_pushinteger(L, host.mpMap->createMapLabel(area, text, @@ -1102,7 +1163,9 @@ int TLuaInterpreter::createMapImageLabel(lua_State* L) { const int args = lua_gettop(L); const int area = getVerifiedInt(L, __func__, 1, "areaID"); - const QString imagePathFileName = getVerifiedString(L, __func__, 2, "imagePathFileName"); + if (!checkStringArg(L, __func__, 2, "imagePathFileName")) { + return lua_error(L); + } const float posx = getVerifiedFloat(L, __func__, 3, "posX"); const float posy = getVerifiedFloat(L, __func__, 4, "posY"); const float posz = getVerifiedFloat(L, __func__, 5, "posZ"); @@ -1116,6 +1179,7 @@ int TLuaInterpreter::createMapImageLabel(lua_State* L) } const Host& host = getHostFromLua(L); + const QString imagePathFileName{lua_tostring(L, 2)}; lua_pushinteger(L, host.mpMap->createMapImageLabel(area, imagePathFileName, posx, posy, posz, width, height, zoom, showOnTop, temporary)); host.mpMap->updateArea(area); return 1; @@ -1125,20 +1189,15 @@ int TLuaInterpreter::createMapImageLabel(lua_State* L) int TLuaInterpreter::createMapper(lua_State* L) { const int n = lua_gettop(L); - QString windowName = ""; + const bool hasParentWindow = (n > 4); int counter = 1; - if (n > 4) { + if (hasParentWindow) { if (lua_type(L, 1) != LUA_TSTRING) { lua_pushfstring(L, "createMapper: bad argument #1 type (parent window name as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } - windowName = lua_tostring(L, 1); counter++; - if (isMain(windowName)) { - // createMapper only accepts the empty name as the main window - windowName.clear(); - } } const int x = getVerifiedInt(L, __func__, counter, "mapper x-coordinate"); @@ -1149,6 +1208,15 @@ int TLuaInterpreter::createMapper(lua_State* L) counter++; const int height = getVerifiedInt(L, __func__, counter, "mapper height"); + QString windowName = ""; + if (hasParentWindow) { + windowName = lua_tostring(L, 1); + if (isMain(windowName)) { + // createMapper only accepts the empty name as the main window + windowName.clear(); + } + } + const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->createMapper(windowName, x, y, width, height); !success) { return warnArgumentValue(L, __func__, message); @@ -1694,8 +1762,6 @@ int TLuaInterpreter::getExitStubs1(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getExitStubsNames int TLuaInterpreter::getExitStubsNames(lua_State* L) { - const QStringList stubmap = {"north", "northeast", "northwest", "east", "west", "south", "southeast", "southwest", "up", "down", "in", "out", "other"}; - const Host& host = getHostFromLua(L); if (!host.mpMap || !host.mpMap->mpRoomDB) { return warnArgumentValue(L, __func__, "no map present or loaded"); @@ -1707,6 +1773,7 @@ int TLuaInterpreter::getExitStubsNames(lua_State* L) if (!pR) { return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(roomId)); } + const QStringList stubmap = {"north", "northeast", "northwest", "east", "west", "south", "southeast", "southwest", "up", "down", "in", "out", "other"}; QList<int> const stubs = pR->exitStubs; lua_newtable(L); for (int i = 0, total = stubs.size(); i < total; ++i) { @@ -2338,11 +2405,14 @@ int TLuaInterpreter::getRoomUserData(lua_State* L) } const int roomId = getVerifiedInt(L, __func__, 1, "roomID"); - const QString key = getVerifiedString(L, __func__, 2, "key"); + if (!checkStringArg(L, __func__, 2, "key")) { + return lua_error(L); + } bool isBackwardCompatibilityRequired = true; if (lua_gettop(L) > 2) { isBackwardCompatibilityRequired = !getVerifiedBool(L, __func__, 3, "enableFullErrorReporting {default = false}", true); } + const QString key{lua_tostring(L, 2)}; TRoom* pR = host.mpMap->mpRoomDB->getRoom(roomId); if (!pR) { @@ -2739,11 +2809,15 @@ int TLuaInterpreter::lockSpecialExit(lua_State* L) { const int fromRoomID = getVerifiedInt(L, __func__, 1, "exit roomID"); // The second argument (was the toRoomID) is now ignored as it is not required/considered in any way - const QString dir = getVerifiedString(L, __func__, 3, "special exit name/command"); - if (dir.isEmpty()) { + if (!checkStringArg(L, __func__, 3, "special exit name/command")) { + return lua_error(L); + } + const char* const exitName = lua_tostring(L, 3); + if (!exitName[0]) { return warnArgumentValue(L, __func__, "the special exit name/command cannot be empty"); } const bool b = getVerifiedBool(L, __func__, 4, "special exit lock state"); + const QString dir{exitName}; const Host& host = getHostFromLua(L); TRoom* pR = host.mpMap->mpRoomDB->getRoom(fromRoomID); @@ -2796,12 +2870,15 @@ int TLuaInterpreter::openMapWidget(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#registerMapInfo int TLuaInterpreter::registerMapInfo(lua_State* L) { - auto name = getVerifiedString(L, __func__, 1, "label"); - + if (!checkStringArg(L, __func__, 1, "label")) { + return lua_error(L); + } if (!lua_isfunction(L, 2)) { lua_pushfstring(L, "registerMapInfo: bad argument #2 type (callback as function expected, got %s!)", luaL_typename(L, 2)); return lua_error(L); } + + auto name = QString{lua_tostring(L, 1)}; const int callback = luaL_ref(L, LUA_REGISTRYINDEX); auto& host = getHostFromLua(L); @@ -2809,6 +2886,7 @@ int TLuaInterpreter::registerMapInfo(lua_State* L) name, [=](int roomID, int selectionSize, int areaId, int displayAreaId, QColor& infoColor) { Q_UNUSED(infoColor) + const int callerStackTop = lua_gettop(L); lua_rawgeti(L, LUA_REGISTRYINDEX, callback); if (roomID > 0) { lua_pushinteger(L, roomID); @@ -2821,21 +2899,15 @@ int TLuaInterpreter::registerMapInfo(lua_State* L) const int error = lua_pcall(L, 4, 6, 0); if (error) { - const int errorCount = lua_gettop(L); - if (mudlet::smDebugMode) { - for (int i = 1; i <= errorCount; i++) { - if (lua_isstring(L, i)) { - auto errorMessage = lua_tostring(L, i); - TDebug(QColor(Qt::white), QColor(Qt::red)) << "LUA ERROR: when running map info callback for '" << name << "\nreason: " << errorMessage << "\n" >> 0; - } - } + if (mudlet::smDebugMode && lua_isstring(L, -1)) { + auto errorMessage = lua_tostring(L, -1); + TDebug(QColor(Qt::white), QColor(Qt::red)) << "LUA ERROR: when running map info callback for '" << name << "\nreason: " << errorMessage << "\n" >> 0; } - lua_pop(L, errorCount); + lua_settop(L, callerStackTop); return MapInfoProperties{}; } - auto nResult = lua_gettop(L); - auto index = -nResult; + auto index = -6; // the lua_pcall() above always leaves exactly this many results const QString text = lua_tostring(L, index); const bool isBold = lua_toboolean(L, ++index); const bool isItalic = lua_toboolean(L, ++index); @@ -2855,7 +2927,7 @@ int TLuaInterpreter::registerMapInfo(lua_State* L) if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { color = QColor(r, g, b); } - lua_pop(L, nResult); + lua_settop(L, callerStackTop); return MapInfoProperties{isBold, isItalic, text, color}; }, L, @@ -3056,16 +3128,23 @@ int TLuaInterpreter::saveJsonMap(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#saveMap int TLuaInterpreter::saveMap(lua_State* L) { - QString location; + const int args = lua_gettop(L); int saveVersion = 0; - if (lua_gettop(L) > 0) { - location = getVerifiedString(L, __func__, 1, "save location path and file name", true); - if (lua_gettop(L) > 1) { + if (args > 0) { + if (!checkStringArg(L, __func__, 1, "save location path and file name", true)) { + return lua_error(L); + } + if (args > 1) { saveVersion = getVerifiedInt(L, __func__, 2, "map format version", true); } } + QString location; + if (args > 0) { + location = lua_tostring(L, 1); + } + const Host& host = getHostFromLua(L); const bool error = host.mpConsole->saveMap(location, saveVersion); lua_pushboolean(L, error); @@ -3080,13 +3159,23 @@ int TLuaInterpreter::searchAreaUserData(lua_State* L) return warnArgumentValue(L, __func__, "no map present or loaded"); } + const int args = lua_gettop(L); + if (args) { + if (!checkStringArg(L, __func__, 1, "key", true)) { + return lua_error(L); + } + if (args > 1 && !checkStringArg(L, __func__, 2, "value", true)) { + return lua_error(L); + } + } + QString key = QString(); QString value = QString(); //both of these assigns a null value which is detectably different from the empty value - if (lua_gettop(L)) { - key = getVerifiedString(L, __func__, 1, "key", true); - if (lua_gettop(L) > 1) { - value = getVerifiedString(L, __func__, 2, "value", true); + if (args) { + key = lua_tostring(L, 1); + if (args > 1) { + value = lua_tostring(L, 2); } } @@ -3255,13 +3344,23 @@ int TLuaInterpreter::searchRoomUserData(lua_State* L) return warnArgumentValue(L, __func__, "no map present or loaded"); } + const int args = lua_gettop(L); + if (args) { + if (!checkStringArg(L, __func__, 1, "key", true)) { + return lua_error(L); + } + if (args > 1 && !checkStringArg(L, __func__, 2, "value", true)) { + return lua_error(L); + } + } + QString key = QString(); QString value = QString(); //both of these assigns a null value which is detectably different from the empty value - if (lua_gettop(L)) { - key = getVerifiedString(L, __func__, 1, "key", true); - if (lua_gettop(L) > 1) { - value = getVerifiedString(L, __func__, 2, "value", true); + if (args) { + key = lua_tostring(L, 1); + if (args > 1) { + value = lua_tostring(L, 2); } } @@ -3347,7 +3446,6 @@ int TLuaInterpreter::searchRoomUserData(lua_State* L) int TLuaInterpreter::setAreaName(lua_State* L) { int id = -1; - QString existingName; const Host& host = getHostFromLua(L); if (!host.mpMap || !host.mpMap->mpRoomDB) { return warnArgumentValue(L, __func__, "no map present or loaded"); @@ -3366,7 +3464,7 @@ int TLuaInterpreter::setAreaName(lua_State* L) // return warnArgumentValue(L, __func__, csmInvalidAreaID.arg(id)); // } } else if (lua_isstring(L, 1)) { - existingName = lua_tostring(L, 1); + const QString existingName{lua_tostring(L, 1)}; id = host.mpMap->mpRoomDB->getAreaNamesMap().key(existingName, 0); if (existingName.isEmpty()) { return warnArgumentValue(L, __func__, "area name cannot be empty"); @@ -3432,11 +3530,15 @@ int TLuaInterpreter::setAreaName(lua_State* L) int TLuaInterpreter::setAreaUserData(lua_State* L) { const int areaId = getVerifiedInt(L, __func__, 1, "areaID"); - const QString key = getVerifiedString(L, __func__, 2, "key"); - if (key.isEmpty()) { + if (!checkStringArg(L, __func__, 2, "key")) { + return lua_error(L); + } + const char* const keyName = lua_tostring(L, 2); + if (!keyName[0]) { return warnArgumentValue(L, __func__, "key is not allowed to be an empty string"); } const QString value = getVerifiedString(L, __func__, 3, "value"); + const QString key{keyName}; const Host& host = getHostFromLua(L); if (!host.mpMap || !host.mpMap->mpRoomDB) { @@ -3561,36 +3663,40 @@ int TLuaInterpreter::setDoor(lua_State* L) if (!pR) { return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(roomId)); } - const QString exitCmd = getVerifiedString(L, __func__, 2, "door command"); - - if (exitCmd.compare(qsl("n")) && exitCmd.compare(qsl("e")) && exitCmd.compare(qsl("s")) && exitCmd.compare(qsl("w")) && exitCmd.compare(qsl("ne")) && exitCmd.compare(qsl("se")) - && exitCmd.compare(qsl("sw")) && exitCmd.compare(qsl("nw")) && exitCmd.compare(qsl("up")) && exitCmd.compare(qsl("down")) && exitCmd.compare(qsl("in")) && exitCmd.compare(qsl("out"))) { - // One of the above WILL BE ZERO if the exitCmd is ONE of the above qsls - // So the above will be TRUE if NONE of above strings match - which - // means we must treat the exitCmd as a SPECIAL exit - if (!(pR->getSpecialExits().contains(exitCmd))) { - // And NOT a special one either - return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a special exit in direction '%2'").arg(QString::number(roomId), exitCmd)); + if (!checkStringArg(L, __func__, 2, "door command")) { + return lua_error(L); + } + { + const QString exitCmd{lua_tostring(L, 2)}; + if (exitCmd.compare(qsl("n")) && exitCmd.compare(qsl("e")) && exitCmd.compare(qsl("s")) && exitCmd.compare(qsl("w")) && exitCmd.compare(qsl("ne")) && exitCmd.compare(qsl("se")) + && exitCmd.compare(qsl("sw")) && exitCmd.compare(qsl("nw")) && exitCmd.compare(qsl("up")) && exitCmd.compare(qsl("down")) && exitCmd.compare(qsl("in")) && exitCmd.compare(qsl("out"))) { + // One of the above WILL BE ZERO if the exitCmd is ONE of the above qsls + // So the above will be TRUE if NONE of above strings match - which + // means we must treat the exitCmd as a SPECIAL exit + if (!(pR->getSpecialExits().contains(exitCmd))) { + // And NOT a special one either + return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a special exit in direction '%2'").arg(QString::number(roomId), exitCmd)); + } + // else IS a valid special exit - so fall out of if and continue + } else { + // Is a normal exit so see if it is valid + if (!(((!exitCmd.compare(qsl("n"))) && (pR->getExit(DIR_NORTH) > 0 || pR->exitStubs.contains(DIR_NORTH))) + || ((!exitCmd.compare(qsl("e"))) && (pR->getExit(DIR_EAST) > 0 || pR->exitStubs.contains(DIR_EAST))) + || ((!exitCmd.compare(qsl("s"))) && (pR->getExit(DIR_SOUTH) > 0 || pR->exitStubs.contains(DIR_SOUTH))) + || ((!exitCmd.compare(qsl("w"))) && (pR->getExit(DIR_WEST) > 0 || pR->exitStubs.contains(DIR_WEST))) + || ((!exitCmd.compare(qsl("ne"))) && (pR->getExit(DIR_NORTHEAST) > 0 || pR->exitStubs.contains(DIR_NORTHEAST))) + || ((!exitCmd.compare(qsl("se"))) && (pR->getExit(DIR_SOUTHEAST) > 0 || pR->exitStubs.contains(DIR_SOUTHEAST))) + || ((!exitCmd.compare(qsl("sw"))) && (pR->getExit(DIR_SOUTHWEST) > 0 || pR->exitStubs.contains(DIR_SOUTHWEST))) + || ((!exitCmd.compare(qsl("nw"))) && (pR->getExit(DIR_NORTHWEST) > 0 || pR->exitStubs.contains(DIR_NORTHWEST))) + || ((!exitCmd.compare(qsl("up"))) && (pR->getExit(DIR_UP) > 0 || pR->exitStubs.contains(DIR_UP))) + || ((!exitCmd.compare(qsl("down"))) && (pR->getExit(DIR_DOWN) > 0 || pR->exitStubs.contains(DIR_DOWN))) + || ((!exitCmd.compare(qsl("in"))) && (pR->getExit(DIR_IN) > 0 || pR->exitStubs.contains(DIR_IN))) + || ((!exitCmd.compare(qsl("out"))) && (pR->getExit(DIR_OUT) > 0 || pR->exitStubs.contains(DIR_OUT))))) { + // No there IS NOT a stub or real exit in the exitCmd direction + return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a normal exit or a stub exit in direction '%2'").arg(QString::number(roomId), exitCmd)); + } + // else IS a valid stub or real normal exit -fall through to continue } - // else IS a valid special exit - so fall out of if and continue - } else { - // Is a normal exit so see if it is valid - if (!(((!exitCmd.compare(qsl("n"))) && (pR->getExit(DIR_NORTH) > 0 || pR->exitStubs.contains(DIR_NORTH))) - || ((!exitCmd.compare(qsl("e"))) && (pR->getExit(DIR_EAST) > 0 || pR->exitStubs.contains(DIR_EAST))) - || ((!exitCmd.compare(qsl("s"))) && (pR->getExit(DIR_SOUTH) > 0 || pR->exitStubs.contains(DIR_SOUTH))) - || ((!exitCmd.compare(qsl("w"))) && (pR->getExit(DIR_WEST) > 0 || pR->exitStubs.contains(DIR_WEST))) - || ((!exitCmd.compare(qsl("ne"))) && (pR->getExit(DIR_NORTHEAST) > 0 || pR->exitStubs.contains(DIR_NORTHEAST))) - || ((!exitCmd.compare(qsl("se"))) && (pR->getExit(DIR_SOUTHEAST) > 0 || pR->exitStubs.contains(DIR_SOUTHEAST))) - || ((!exitCmd.compare(qsl("sw"))) && (pR->getExit(DIR_SOUTHWEST) > 0 || pR->exitStubs.contains(DIR_SOUTHWEST))) - || ((!exitCmd.compare(qsl("nw"))) && (pR->getExit(DIR_NORTHWEST) > 0 || pR->exitStubs.contains(DIR_NORTHWEST))) - || ((!exitCmd.compare(qsl("up"))) && (pR->getExit(DIR_UP) > 0 || pR->exitStubs.contains(DIR_UP))) - || ((!exitCmd.compare(qsl("down"))) && (pR->getExit(DIR_DOWN) > 0 || pR->exitStubs.contains(DIR_DOWN))) - || ((!exitCmd.compare(qsl("in"))) && (pR->getExit(DIR_IN) > 0 || pR->exitStubs.contains(DIR_IN))) - || ((!exitCmd.compare(qsl("out"))) && (pR->getExit(DIR_OUT) > 0 || pR->exitStubs.contains(DIR_OUT))))) { - // No there IS NOT a stub or real exit in the exitCmd direction - return warnArgumentValue(L, __func__, qsl("roomID %1 does not have a normal exit or a stub exit in direction '%2'").arg(QString::number(roomId), exitCmd)); - } - // else IS a valid stub or real normal exit -fall through to continue } const int doorStatus = getVerifiedInt(L, __func__, 3, "door type {0='none', 1='open', 2='closed' or 3='locked'}"); @@ -3598,6 +3704,7 @@ int TLuaInterpreter::setDoor(lua_State* L) return warnArgumentValue(L, __func__, qsl("door type %1 is not one of 0='none', 1='open', 2='closed' or 3='locked'").arg(doorStatus)); } + const QString exitCmd{lua_tostring(L, 2)}; const bool result = pR->setDoor(exitCmd, doorStatus); if (result) { host.mpMap->setUnsaved(__func__); @@ -3789,13 +3896,15 @@ int TLuaInterpreter::setExitWeight(lua_State* L) return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(roomID)); } - const QString direction(dirToString(L, 2)); - if (direction.isEmpty()) { - lua_pushfstring(L, "setExitWeight: bad argument #2 type (direction as string or number {between 1 and 12 inclusive} expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); - } - if (!pR->hasExitOrSpecialExit(direction)) { - return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit that can be identified from '%2'").arg(QString::number(roomID), lua_tostring(L, 2))); + { + const QString direction(dirToString(L, 2)); + if (direction.isEmpty()) { + lua_pushfstring(L, "setExitWeight: bad argument #2 type (direction as string or number {between 1 and 12 inclusive} expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + if (!pR->hasExitOrSpecialExit(direction)) { + return warnArgumentValue(L, __func__, qsl("roomID %1 does not have an exit that can be identified from '%2'").arg(QString::number(roomID), lua_tostring(L, 2))); + } } const int weight = getVerifiedInt(L, __func__, 3, "exit weight"); @@ -3806,7 +3915,7 @@ int TLuaInterpreter::setExitWeight(lua_State* L) .arg(QString::number(weight), QString::number(std::numeric_limits<int>::max()))); } - pR->setExitWeight(direction, weight); + pR->setExitWeight(dirToString(L, 2), weight); lua_pushboolean(L, true); host.mpMap->updateArea(pR->getArea()); return 1; @@ -3839,11 +3948,15 @@ int TLuaInterpreter::setMapUserData(lua_State* L) return warnArgumentValue(L, __func__, "no map present or loaded"); } - const QString key = getVerifiedString(L, __func__, 1, "key"); - if (key.isEmpty()) { + if (!checkStringArg(L, __func__, 1, "key")) { + return lua_error(L); + } + const char* const keyName = lua_tostring(L, 1); + if (!keyName[0]) { return warnArgumentValue(L, __func__, "key is not allowed to be an empty string"); } const QString value = getVerifiedString(L, __func__, 2, "value"); + const QString key{keyName}; host.mpMap->mUserData[key] = value; host.mpMap->setUnsaved(__func__); @@ -3914,13 +4027,11 @@ int TLuaInterpreter::setRoomArea(lua_State* L) return warnArgumentValue(L, __func__, "no map present or loaded"); } - QVector<int> roomIds; if (lua_isnumber(L, 1)) { const int id = getVerifiedInt(L, __func__, 1, "roomID"); if (!host.mpMap->mpRoomDB->getRoomIDList().contains(id)) { return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(id)); } - roomIds.append(id); } else if (lua_istable(L, 1)) { lua_pushnil(L); while (lua_next(L, 1) != 0) { @@ -3928,7 +4039,6 @@ int TLuaInterpreter::setRoomArea(lua_State* L) if (!host.mpMap->mpRoomDB->getRoomIDList().contains(id)) { return warnArgumentValue(L, __func__, csmInvalidRoomID.arg(id)); } - roomIds.append(id); lua_pop(L, 1); } } else { @@ -3940,7 +4050,6 @@ int TLuaInterpreter::setRoomArea(lua_State* L) } int areaId = -1; - QString areaName; if (lua_isnumber(L, 2)) { areaId = static_cast<int>(lua_tonumber(L, 2)); if (areaId < 1) { @@ -3954,7 +4063,7 @@ int TLuaInterpreter::setRoomArea(lua_State* L) return warnArgumentValue(L, __func__, csmInvalidAreaID.arg(areaId)); } } else if (lua_isstring(L, 2)) { - areaName = lua_tostring(L, 2); + const QString areaName{lua_tostring(L, 2)}; // areaId will be zero if not found! if (areaName.isEmpty()) { return warnArgumentValue(L, __func__, "area name cannot be empty"); @@ -3971,6 +4080,17 @@ int TLuaInterpreter::setRoomArea(lua_State* L) return lua_error(L); } + QVector<int> roomIds; + if (lua_isnumber(L, 1)) { + roomIds.append(static_cast<int>(lua_tointeger(L, 1))); + } else { + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + roomIds.append(static_cast<int>(lua_tointeger(L, -1))); + lua_pop(L, 1); + } + } + const bool result = std::all_of(roomIds.begin(), roomIds.end(), [&](int id) { // defer area recalculation on all rooms until the last room (.back()) return host.mpMap->setRoomArea(id, areaId, id != roomIds.back()); @@ -4122,9 +4242,12 @@ int TLuaInterpreter::setRoomUserData(lua_State* L) } const int roomId = getVerifiedInt(L, __func__, 1, "roomID"); - const QString key = getVerifiedString(L, __func__, 2, "key"); - // Ideally should reject empty keys but this could break existing scripts so we can't + if (!checkStringArg(L, __func__, 2, "key")) { + return lua_error(L); + } const QString value = getVerifiedString(L, __func__, 3, "value"); + // Ideally should reject empty keys but this could break existing scripts so we can't + const QString key{lua_tostring(L, 2)}; TRoom* pR = host.mpMap->mpRoomDB->getRoom(roomId); if (!pR) { @@ -4485,7 +4608,9 @@ int TLuaInterpreter::exportAreaImage(lua_State* L) } // filePath parameter is required - const QString filePath = getVerifiedString(L, __func__, 2, "file path"); + if (!checkStringArg(L, __func__, 2, "file path")) { + return lua_error(L); + } std::optional<int> zLevel = std::nullopt; bool exportAllZLevels = false; @@ -4503,6 +4628,8 @@ int TLuaInterpreter::exportAreaImage(lua_State* L) } } + const QString filePath{lua_tostring(L, 2)}; + // NOTE: Zoom parameter temporarily disabled due to blurry room symbol rendering at zoom > 2.0 qreal zoom = 2.0; diff --git a/src/TLuaInterpreterMedia.cpp b/src/TLuaInterpreterMedia.cpp index 2b7f97007..b46d9a812 100644 --- a/src/TLuaInterpreterMedia.cpp +++ b/src/TLuaInterpreterMedia.cpp @@ -34,22 +34,25 @@ #include "TMedia.h" #include "mudlet.h" +// The argument parsers below hold QStrings and TMediaData while they run, so +// they type-check with TLuaInterpreter::check...Arg() and leave the raise until +// the parsing scope has been left - see checkStringArg() + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#receiveMSP int TLuaInterpreter::receiveMSP(lua_State* L) { Host& host = getHostFromLua(L); - std::string msg; if (!host.mTelnet.isMSPEnabled()) { return warnArgumentValue(L, __func__, "MSP is not currently enabled"); } if (!lua_isstring(L, 1)) { - lua_pushfstring(L, "receiveMSP: bad argument #1 type (message as string expected, got %1!)", luaL_typename(L, 1)); + lua_pushfstring(L, "receiveMSP: bad argument #1 type (message as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } - msg = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 1)); + const std::string msg = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 1)); host.mTelnet.setMSPVariables(QByteArray(msg.c_str(), msg.length())); lua_pushboolean(L, true); @@ -60,43 +63,63 @@ int TLuaInterpreter::receiveMSP(lua_State* L) int TLuaInterpreter::loadMediaFileAsOrderedArguments(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - // name[,url]) - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; - } + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); - - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); + // name[,url]) + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; } - mediaData.setMediaFileName(stringValue); - break; - case 2: - stringValue = getVerifiedString(L, func, i, "url"); - mediaData.setMediaUrl(stringValue); - break; + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkStringArg(L, func, i, "url")) { + errorPushed = true; + break; + } + + mediaData.setMediaUrl(QString{lua_tostring(L, i)}); + break; + } + } + + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + return warnArgumentValue(L, func, QLatin1String("missing argument 1 (file to play)")); + } + + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaVolume(TMediaData::MediaVolumePreload); + + host.mpMedia->playMedia(mediaData); } } - if (mediaData.mediaFileName().isEmpty()) { - return warnArgumentValue(L, func, QLatin1String("missing argument 1 (file to play)")); + if (errorPushed) { + return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaVolume(TMediaData::MediaVolumePreload); - - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -105,43 +128,67 @@ int TLuaInterpreter::loadMediaFileAsOrderedArguments(lua_State* L, const char* f int TLuaInterpreter::loadMediaFileAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("url")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : "value for url"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + // read the key from a copy: lua_tostring() on the slot itself converts a + // numeric key in place, which makes the next lua_next() fail + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("url")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : "value for url")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("url") && !value.isEmpty()) { - mediaData.setMediaUrl(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("url") && !value.isEmpty()) { + mediaData.setMediaUrl(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + lua_pushstring(L, R"(loadMusicFile: missing name (add name = "file to play"))"); + errorPushed = true; + } else { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaVolume(TMediaData::MediaVolumePreload); + + host.mpMedia->playMedia(mediaData); + } + } } - if (mediaData.mediaFileName().isEmpty()) { - lua_pushstring(L, R"(loadMusicFile: missing name (add name = "file to play"))"); + if (errorPushed) { return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaVolume(TMediaData::MediaVolumePreload); - - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -196,119 +243,184 @@ int TLuaInterpreter::loadVideoFile(lua_State* L) int TLuaInterpreter::playMusicFileAsOrderedArguments(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - int intValue = 0; - bool boolValue = 0; - // name[,volume][,fadein][,fadeout][,start][,loops][,key][,tag][,continue][,url][,finish] - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; + int intValue = 0; + + // name[,volume][,fadein][,fadeout][,start][,loops][,key][,tag][,continue][,url][,finish] + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; + } + + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkIntArg(L, func, i, "volume")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue == TMediaData::MediaVolumePreload) { + { + } // Volume of 0 supports preloading + } else if (intValue > TMediaData::MediaVolumeMax) { + intValue = TMediaData::MediaVolumeMax; + } else if (intValue < TMediaData::MediaVolumeMin) { + intValue = TMediaData::MediaVolumeMin; + } + + mediaData.setMediaVolume(intValue); + break; + case 3: + if (!checkIntArg(L, func, i, "fadein")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFadeIn(intValue); + break; + case 4: + if (!checkIntArg(L, func, i, "fadeout")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(intValue); + break; + case 5: + if (!checkIntArg(L, func, i, "start")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaStart(intValue); + break; + case 6: + if (!checkIntArg(L, func, i, "loops")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < TMediaData::MediaLoopsRepeat || intValue == 0) { + intValue = TMediaData::MediaLoopsDefault; + } + + mediaData.setMediaLoops(intValue); + break; + case 7: + if (!checkStringArg(L, func, i, "key")) { + errorPushed = true; + break; + } + + mediaData.setMediaKey(QString{lua_tostring(L, i)}); + break; + case 8: + if (!checkStringArg(L, func, i, "tag")) { + errorPushed = true; + break; + } + + mediaData.setMediaTag(QString{lua_tostring(L, i)}); + break; + case 9: + if (!checkBoolArg(L, func, i, "continue")) { + errorPushed = true; + break; + } + + mediaData.setMediaContinue(lua_toboolean(L, i)); + break; + case 10: + if (!checkStringArg(L, func, i, "url")) { + errorPushed = true; + break; + } + + mediaData.setMediaUrl(QString{lua_tostring(L, i)}); + break; + case 11: + if (!checkIntArg(L, func, i, "finish")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFinish(intValue); + break; + } } - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); - - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + return warnArgumentValue(L, func, QLatin1String("missing argument 1 (file to play)")); } - mediaData.setMediaFileName(stringValue); - break; - case 2: - intValue = getVerifiedInt(L, func, i, "volume"); - - if (intValue == TMediaData::MediaVolumePreload) { - { - } // Volume of 0 supports preloading - } else if (intValue > TMediaData::MediaVolumeMax) { - intValue = TMediaData::MediaVolumeMax; - } else if (intValue < TMediaData::MediaVolumeMin) { - intValue = TMediaData::MediaVolumeMin; - } - - mediaData.setMediaVolume(intValue); - break; - case 3: - intValue = getVerifiedInt(L, func, i, "fadein"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", intValue); - return lua_error(L); - } - - mediaData.setMediaFadeIn(intValue); - break; - case 4: - intValue = getVerifiedInt(L, func, i, "fadeout"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); - return lua_error(L); - } - - mediaData.setMediaFadeOut(intValue); - break; - case 5: - intValue = getVerifiedInt(L, func, i, "start"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", intValue); - return lua_error(L); - } - - mediaData.setMediaStart(intValue); - break; - case 6: - intValue = getVerifiedInt(L, func, i, "loops"); - - if (intValue < TMediaData::MediaLoopsRepeat || intValue == 0) { - intValue = TMediaData::MediaLoopsDefault; - } - - mediaData.setMediaLoops(intValue); - break; - case 7: - stringValue = getVerifiedString(L, func, i, "key"); - mediaData.setMediaKey(stringValue); - break; - case 8: - stringValue = getVerifiedString(L, func, i, "tag"); - mediaData.setMediaTag(stringValue); - break; - case 9: - boolValue = getVerifiedBool(L, func, i, "continue"); - mediaData.setMediaContinue(boolValue); - break; - case 10: - stringValue = getVerifiedString(L, func, i, "url"); - mediaData.setMediaUrl(stringValue); - break; - case 11: - intValue = getVerifiedInt(L, func, i, "finish"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", intValue); - return lua_error(L); - } - - mediaData.setMediaFinish(intValue); - break; + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + host.mpMedia->playMedia(mediaData); } } - if (mediaData.mediaFileName().isEmpty()) { - return warnArgumentValue(L, func, QLatin1String("missing argument 1 (file to play)")); + if (errorPushed) { + return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -317,116 +429,151 @@ int TLuaInterpreter::playMusicFileAsOrderedArguments(lua_State* L, const char* f int TLuaInterpreter::playMusicFileAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("url") || key == QLatin1String("key") || key == QLatin1String("tag") || key == QLatin1String("caption")) { - QString value = getVerifiedString(L, - func, - -1, - key == QLatin1String("name") ? "value for name" - : key == QLatin1String("key") ? "value for key" - : key == QLatin1String("tag") ? "value for tag" - : key == QLatin1String("caption") ? "value for caption" - : "value for url"); - - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); - } - - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("url") && !value.isEmpty()) { - mediaData.setMediaUrl(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); - } else if (key == QLatin1String("caption") && !value.isEmpty()) { - mediaData.setMediaCaption(value); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; } - } else if (key == QLatin1String("volume") || key == QLatin1String("fadein") || key == QLatin1String("fadeout") || key == QLatin1String("start") || key == QLatin1String("finish") - || key == QLatin1String("loops")) { - int value = getVerifiedInt(L, - func, - -1, - key == QLatin1String("volume") ? "value for volume" - : key == QLatin1String("fadein") ? "value for fadein" - : key == QLatin1String("fadeout") ? "value for fadeout" - : key == QLatin1String("start") ? "value for start" - : key == QLatin1String("finish") ? "value for finish" - : "value for loops"); - if (key == QLatin1String("volume")) { - if (value == TMediaData::MediaVolumePreload) { - { - } // Volume of 0 supports preloading - } else if (value > TMediaData::MediaVolumeMax) { - value = TMediaData::MediaVolumeMax; - } else if (value < TMediaData::MediaVolumeMin) { - value = TMediaData::MediaVolumeMin; + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("url") || key == QLatin1String("key") || key == QLatin1String("tag") || key == QLatin1String("caption")) { + if (!checkStringArg(L, + func, + -1, + key == QLatin1String("name") ? "value for name" + : key == QLatin1String("key") ? "value for key" + : key == QLatin1String("tag") ? "value for tag" + : key == QLatin1String("caption") ? "value for caption" + : "value for url")) { + errorPushed = true; + break; } - mediaData.setMediaVolume(value); - } else if (key == QLatin1String("fadein")) { - if (value < 0) { - lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", value); - return lua_error(L); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("url") && !value.isEmpty()) { + mediaData.setMediaUrl(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } else if (key == QLatin1String("caption") && !value.isEmpty()) { + mediaData.setMediaCaption(value); + } + } else if (key == QLatin1String("volume") || key == QLatin1String("fadein") || key == QLatin1String("fadeout") || key == QLatin1String("start") || key == QLatin1String("finish") + || key == QLatin1String("loops")) { + if (!checkIntArg(L, + func, + -1, + key == QLatin1String("volume") ? "value for volume" + : key == QLatin1String("fadein") ? "value for fadein" + : key == QLatin1String("fadeout") ? "value for fadeout" + : key == QLatin1String("start") ? "value for start" + : key == QLatin1String("finish") ? "value for finish" + : "value for loops")) { + errorPushed = true; + break; } - mediaData.setMediaFadeIn(value); - } else if (key == QLatin1String("fadeout")) { - if (value < 0) { - lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); - return lua_error(L); + int value = static_cast<int>(lua_tointeger(L, -1)); + + if (key == QLatin1String("volume")) { + if (value == TMediaData::MediaVolumePreload) { + { + } // Volume of 0 supports preloading + } else if (value > TMediaData::MediaVolumeMax) { + value = TMediaData::MediaVolumeMax; + } else if (value < TMediaData::MediaVolumeMin) { + value = TMediaData::MediaVolumeMin; + } + + mediaData.setMediaVolume(value); + } else if (key == QLatin1String("fadein")) { + if (value < 0) { + lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", value); + errorPushed = true; + break; + } + + mediaData.setMediaFadeIn(value); + } else if (key == QLatin1String("fadeout")) { + if (value < 0) { + lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(value); + } else if (key == QLatin1String("start")) { + if (value < 0) { + lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", value); + errorPushed = true; + break; + } + + mediaData.setMediaStart(value); + } else if (key == QLatin1String("finish")) { + if (value < 0) { + lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", value); + errorPushed = true; + break; + } + + mediaData.setMediaFinish(value); + } else if (key == QLatin1String("loops")) { + if (value < TMediaData::MediaLoopsRepeat || value == 0) { + value = TMediaData::MediaLoopsDefault; + } + + mediaData.setMediaLoops(value); + } + } else if (key == QLatin1String("continue")) { + if (!checkBoolArg(L, func, -1, "value for continue")) { + errorPushed = true; + break; } - mediaData.setMediaFadeOut(value); - } else if (key == QLatin1String("start")) { - if (value < 0) { - lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", value); - return lua_error(L); - } - - mediaData.setMediaStart(value); - } else if (key == QLatin1String("finish")) { - if (value < 0) { - lua_pushfstring(L, "playMusicFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", value); - return lua_error(L); - } - - mediaData.setMediaFinish(value); - } else if (key == QLatin1String("loops")) { - if (value < TMediaData::MediaLoopsRepeat || value == 0) { - value = TMediaData::MediaLoopsDefault; - } - - mediaData.setMediaLoops(value); + mediaData.setMediaContinue(lua_toboolean(L, -1)); } - } else if (key == QLatin1String("continue")) { - const bool value = getVerifiedBool(L, func, -1, "value for continue must be boolean"); - mediaData.setMediaContinue(value); + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + lua_pushstring(L, R"(playMusicFile: missing name (add name = "file to play"))"); + errorPushed = true; + } else { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + host.mpMedia->playMedia(mediaData); + } + } } - if (mediaData.mediaFileName().isEmpty()) { - lua_pushstring(L, R"(playMusicFile: missing name (add name = "file to play"))"); + if (errorPushed) { return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -450,126 +597,193 @@ int TLuaInterpreter::playMusicFile(lua_State* L) int TLuaInterpreter::playSoundFileAsOrderedArguments(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - int intValue = 0; - // name[,volume][,fadein][,fadeout][,start][,loops][,key][,tag][,priority][,url][,finish] - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; + int intValue = 0; + + // name[,volume][,fadein][,fadeout][,start][,loops][,key][,tag][,priority][,url][,finish] + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; + } + + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkIntArg(L, func, i, "volume")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue == TMediaData::MediaVolumePreload) { + { + } // Volume of 0 supports preloading + } else if (intValue > TMediaData::MediaVolumeMax) { + intValue = TMediaData::MediaVolumeMax; + } else if (intValue < TMediaData::MediaVolumeMin) { + intValue = TMediaData::MediaVolumeMin; + } + + mediaData.setMediaVolume(intValue); + break; + case 3: + if (!checkIntArg(L, func, i, "fadein")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFadeIn(intValue); + break; + case 4: + if (!checkIntArg(L, func, i, "fadeout")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(intValue); + break; + case 5: + if (!checkIntArg(L, func, i, "start")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaStart(intValue); + break; + case 6: + if (!checkIntArg(L, func, i, "loops")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < TMediaData::MediaLoopsRepeat || intValue == 0) { + intValue = TMediaData::MediaLoopsDefault; + } + + mediaData.setMediaLoops(intValue); + break; + case 7: + if (!checkStringArg(L, func, i, "key")) { + errorPushed = true; + break; + } + + mediaData.setMediaKey(QString{lua_tostring(L, i)}); + break; + case 8: + if (!checkStringArg(L, func, i, "tag")) { + errorPushed = true; + break; + } + + mediaData.setMediaTag(QString{lua_tostring(L, i)}); + break; + case 9: + if (!checkIntArg(L, func, i, "priority")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue > TMediaData::MediaPriorityMax) { + intValue = TMediaData::MediaPriorityMax; + } else if (intValue < TMediaData::MediaPriorityMin) { + intValue = TMediaData::MediaPriorityMin; + } + + mediaData.setMediaPriority(intValue); + break; + case 10: + if (!checkStringArg(L, func, i, "url")) { + errorPushed = true; + break; + } + + mediaData.setMediaUrl(QString{lua_tostring(L, i)}); + break; + case 11: + if (!checkIntArg(L, func, i, "finish")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFinish(intValue); + break; + } } - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); - - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + return warnArgumentValue(L, func, QLatin1String("missing argument 1 (file to play)")); } - mediaData.setMediaFileName(stringValue); - break; - case 2: - intValue = getVerifiedInt(L, func, i, "volume"); + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); - if (intValue == TMediaData::MediaVolumePreload) { - { - } // Volume of 0 supports preloading - } else if (intValue > TMediaData::MediaVolumeMax) { - intValue = TMediaData::MediaVolumeMax; - } else if (intValue < TMediaData::MediaVolumeMin) { - intValue = TMediaData::MediaVolumeMin; - } - - mediaData.setMediaVolume(intValue); - break; - case 3: - intValue = getVerifiedInt(L, func, i, "fadein"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", intValue); - return lua_error(L); - } - - mediaData.setMediaFadeIn(intValue); - break; - case 4: - intValue = getVerifiedInt(L, func, i, "fadeout"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); - return lua_error(L); - } - - mediaData.setMediaFadeOut(intValue); - break; - case 5: - intValue = getVerifiedInt(L, func, i, "start"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", intValue); - return lua_error(L); - } - - mediaData.setMediaStart(intValue); - break; - case 6: - intValue = getVerifiedInt(L, func, i, "loops"); - - if (intValue < TMediaData::MediaLoopsRepeat || intValue == 0) { - intValue = TMediaData::MediaLoopsDefault; - } - - mediaData.setMediaLoops(intValue); - break; - case 7: - stringValue = getVerifiedString(L, func, i, "key"); - mediaData.setMediaKey(stringValue); - break; - case 8: - stringValue = getVerifiedString(L, func, i, "tag"); - mediaData.setMediaTag(stringValue); - break; - case 9: - intValue = getVerifiedInt(L, func, i, "priority"); - - if (intValue > TMediaData::MediaPriorityMax) { - intValue = TMediaData::MediaPriorityMax; - } else if (intValue < TMediaData::MediaPriorityMin) { - intValue = TMediaData::MediaPriorityMin; - } - - mediaData.setMediaPriority(intValue); - break; - case 10: - stringValue = getVerifiedString(L, func, i, "url"); - mediaData.setMediaUrl(stringValue); - break; - case 11: - intValue = getVerifiedInt(L, func, i, "finish"); - - if (intValue < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", intValue); - return lua_error(L); - } - - mediaData.setMediaFinish(intValue); - break; + host.mpMedia->playMedia(mediaData); } } - if (mediaData.mediaFileName().isEmpty()) { - return warnArgumentValue(L, func, QLatin1String("missing argument 1 (file to play)")); + if (errorPushed) { + return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); - - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -578,123 +792,154 @@ int TLuaInterpreter::playSoundFileAsOrderedArguments(lua_State* L, const char* f int TLuaInterpreter::playSoundFileAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("url") || key == QLatin1String("key") || key == QLatin1String("tag") || key == QLatin1String("caption")) { - QString value = getVerifiedString(L, - func, - -1, - key == QLatin1String("name") ? "value for name" - : key == QLatin1String("key") ? "value for key" - : key == QLatin1String("tag") ? "value for tag" - : key == QLatin1String("caption") ? "value for caption" - : "value for url"); - - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); - } - - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("url") && !value.isEmpty()) { - mediaData.setMediaUrl(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); - } else if (key == QLatin1String("caption") && !value.isEmpty()) { - mediaData.setMediaCaption(value); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; } - } else if (key == QLatin1String("volume") || key == QLatin1String("fadein") || key == QLatin1String("fadeout") || key == QLatin1String("start") || key == QLatin1String("finish") - || key == QLatin1String("loops") || key == QLatin1String("priority")) { - int value = getVerifiedInt(L, - func, - -1, - key == QLatin1String("volume") ? "value for volume" - : key == QLatin1String("fadein") ? "value for fadein" - : key == QLatin1String("fadeout") ? "value for fadeout" - : key == QLatin1String("start") ? "value for start" - : key == QLatin1String("finish") ? "value for finish" - : key == QLatin1String("loops") ? "value for loops" - : "value for priority"); - if (key == QLatin1String("volume")) { - if (value == TMediaData::MediaVolumePreload) { - { - } // Volume of 0 supports preloading - } else if (value > TMediaData::MediaVolumeMax) { - value = TMediaData::MediaVolumeMax; - } else if (value < TMediaData::MediaVolumeMin) { - value = TMediaData::MediaVolumeMin; + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("url") || key == QLatin1String("key") || key == QLatin1String("tag") || key == QLatin1String("caption")) { + if (!checkStringArg(L, + func, + -1, + key == QLatin1String("name") ? "value for name" + : key == QLatin1String("key") ? "value for key" + : key == QLatin1String("tag") ? "value for tag" + : key == QLatin1String("caption") ? "value for caption" + : "value for url")) { + errorPushed = true; + break; } - mediaData.setMediaVolume(value); - } else if (key == QLatin1String("fadein")) { - if (value < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", value); - return lua_error(L); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("url") && !value.isEmpty()) { + mediaData.setMediaUrl(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } else if (key == QLatin1String("caption") && !value.isEmpty()) { + mediaData.setMediaCaption(value); + } + } else if (key == QLatin1String("volume") || key == QLatin1String("fadein") || key == QLatin1String("fadeout") || key == QLatin1String("start") || key == QLatin1String("finish") + || key == QLatin1String("loops") || key == QLatin1String("priority")) { + if (!checkIntArg(L, + func, + -1, + key == QLatin1String("volume") ? "value for volume" + : key == QLatin1String("fadein") ? "value for fadein" + : key == QLatin1String("fadeout") ? "value for fadeout" + : key == QLatin1String("start") ? "value for start" + : key == QLatin1String("finish") ? "value for finish" + : key == QLatin1String("loops") ? "value for loops" + : "value for priority")) { + errorPushed = true; + break; } - mediaData.setMediaFadeIn(value); - } else if (key == QLatin1String("fadeout")) { - if (value < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); - return lua_error(L); - } + int value = static_cast<int>(lua_tointeger(L, -1)); - mediaData.setMediaFadeOut(value); - } else if (key == QLatin1String("start")) { - if (value < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", value); - return lua_error(L); - } + if (key == QLatin1String("volume")) { + if (value == TMediaData::MediaVolumePreload) { + { + } // Volume of 0 supports preloading + } else if (value > TMediaData::MediaVolumeMax) { + value = TMediaData::MediaVolumeMax; + } else if (value < TMediaData::MediaVolumeMin) { + value = TMediaData::MediaVolumeMin; + } - mediaData.setMediaStart(value); - } else if (key == QLatin1String("finish")) { - if (value < 0) { - lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", value); - return lua_error(L); - } + mediaData.setMediaVolume(value); + } else if (key == QLatin1String("fadein")) { + if (value < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadein", value); + errorPushed = true; + break; + } - mediaData.setMediaFinish(value); - } else if (key == QLatin1String("loops")) { - if (value < TMediaData::MediaLoopsRepeat || value == 0) { - value = TMediaData::MediaLoopsDefault; - } + mediaData.setMediaFadeIn(value); + } else if (key == QLatin1String("fadeout")) { + if (value < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); + errorPushed = true; + break; + } - mediaData.setMediaLoops(value); - } else if (key == QLatin1String("priority")) { - if (value > TMediaData::MediaPriorityMax) { - value = TMediaData::MediaPriorityMax; - } else if (value < TMediaData::MediaPriorityMin) { - value = TMediaData::MediaPriorityMin; - } + mediaData.setMediaFadeOut(value); + } else if (key == QLatin1String("start")) { + if (value < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", value); + errorPushed = true; + break; + } - mediaData.setMediaPriority(value); + mediaData.setMediaStart(value); + } else if (key == QLatin1String("finish")) { + if (value < 0) { + lua_pushfstring(L, "playSoundFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", value); + errorPushed = true; + break; + } + + mediaData.setMediaFinish(value); + } else if (key == QLatin1String("loops")) { + if (value < TMediaData::MediaLoopsRepeat || value == 0) { + value = TMediaData::MediaLoopsDefault; + } + + mediaData.setMediaLoops(value); + } else if (key == QLatin1String("priority")) { + if (value > TMediaData::MediaPriorityMax) { + value = TMediaData::MediaPriorityMax; + } else if (value < TMediaData::MediaPriorityMin) { + value = TMediaData::MediaPriorityMin; + } + + mediaData.setMediaPriority(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + lua_pushstring(L, R"(playSoundFile: missing name (add name = "file to play"))"); + errorPushed = true; + } else { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); + + host.mpMedia->playMedia(mediaData); + } + } } - if (mediaData.mediaFileName().isEmpty()) { - lua_pushstring(L, R"(playSoundFile: missing name (add name = "file to play"))"); + if (errorPushed) { return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); - - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -718,98 +963,140 @@ int TLuaInterpreter::playSoundFile(lua_State* L) int TLuaInterpreter::playVideoFileAsTableArgument(lua_State* L, const char* func) { Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (!key.compare(QLatin1String("name"), Qt::CaseInsensitive) || !key.compare(QLatin1String("url"), Qt::CaseInsensitive) || !key.compare(QLatin1String("key"), Qt::CaseInsensitive) - || !key.compare(QLatin1String("tag"), Qt::CaseInsensitive)) { - QString value = getVerifiedString(L, - func, - -1, - !key.compare(QLatin1String("name"), Qt::CaseInsensitive) ? "value for name" - : !key.compare(QLatin1String("key"), Qt::CaseInsensitive) ? "value for key" - : !key.compare(QLatin1String("tag"), Qt::CaseInsensitive) ? "value for tag" - : "value for url"); - - if (!key.compare(QLatin1String("name"), Qt::CaseInsensitive) && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); - } - - mediaData.setMediaFileName(value); - } else if (!key.compare(QLatin1String("url"), Qt::CaseInsensitive) && !value.isEmpty()) { - mediaData.setMediaUrl(value); - } else if (!key.compare(QLatin1String("key"), Qt::CaseInsensitive) && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (!key.compare(QLatin1String("tag"), Qt::CaseInsensitive) && !value.isEmpty()) { - mediaData.setMediaTag(value); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; } - } else if (!key.compare(QLatin1String("volume"), Qt::CaseInsensitive) || !key.compare(QLatin1String("start"), Qt::CaseInsensitive) || !key.compare(QLatin1String("finish"), Qt::CaseInsensitive) - || !key.compare(QLatin1String("loops"), Qt::CaseInsensitive)) { - int value = getVerifiedInt(L, - func, - -1, - !key.compare(QLatin1String("volume"), Qt::CaseInsensitive) ? "value for volume" - : !key.compare(QLatin1String("start"), Qt::CaseInsensitive) ? "value for start" - : !key.compare(QLatin1String("finish"), Qt::CaseInsensitive) ? "value for finish" - : "value for loops"); - if (!key.compare(QLatin1String("volume"), Qt::CaseInsensitive)) { - if (value != TMediaData::MediaVolumePreload) { - value = qBound(static_cast<int>(TMediaData::MediaVolumeMin), value, static_cast<int>(TMediaData::MediaVolumeMax)); + lua_pushvalue(L, -2); + const QString key{lua_tostring(L, -1)}; + lua_pop(L, 1); + + if (!key.compare(QLatin1String("name"), Qt::CaseInsensitive) || !key.compare(QLatin1String("url"), Qt::CaseInsensitive) || !key.compare(QLatin1String("key"), Qt::CaseInsensitive) + || !key.compare(QLatin1String("tag"), Qt::CaseInsensitive)) { + if (!checkStringArg(L, + func, + -1, + !key.compare(QLatin1String("name"), Qt::CaseInsensitive) ? "value for name" + : !key.compare(QLatin1String("key"), Qt::CaseInsensitive) ? "value for key" + : !key.compare(QLatin1String("tag"), Qt::CaseInsensitive) ? "value for tag" + : "value for url")) { + errorPushed = true; + break; } - mediaData.setMediaVolume(value); - } else if (!key.compare(QLatin1String("start"), Qt::CaseInsensitive)) { - if (value < 0) { - lua_pushfstring(L, "playVideoFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", value); - return lua_error(L); + QString value{lua_tostring(L, -1)}; + + if (!key.compare(QLatin1String("name"), Qt::CaseInsensitive) && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (!key.compare(QLatin1String("url"), Qt::CaseInsensitive) && !value.isEmpty()) { + mediaData.setMediaUrl(value); + } else if (!key.compare(QLatin1String("key"), Qt::CaseInsensitive) && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (!key.compare(QLatin1String("tag"), Qt::CaseInsensitive) && !value.isEmpty()) { + mediaData.setMediaTag(value); + } + } else if (!key.compare(QLatin1String("volume"), Qt::CaseInsensitive) || !key.compare(QLatin1String("start"), Qt::CaseInsensitive) + || !key.compare(QLatin1String("finish"), Qt::CaseInsensitive) || !key.compare(QLatin1String("loops"), Qt::CaseInsensitive)) { + if (!checkIntArg(L, + func, + -1, + !key.compare(QLatin1String("volume"), Qt::CaseInsensitive) ? "value for volume" + : !key.compare(QLatin1String("start"), Qt::CaseInsensitive) ? "value for start" + : !key.compare(QLatin1String("finish"), Qt::CaseInsensitive) ? "value for finish" + : "value for loops")) { + errorPushed = true; + break; } - mediaData.setMediaStart(value); - } else if (!key.compare(QLatin1String("finish"), Qt::CaseInsensitive)) { - if (value < 0) { - lua_pushfstring(L, "playVideoFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", value); - return lua_error(L); + int value = static_cast<int>(lua_tointeger(L, -1)); + + if (!key.compare(QLatin1String("volume"), Qt::CaseInsensitive)) { + if (value != TMediaData::MediaVolumePreload) { + value = qBound(static_cast<int>(TMediaData::MediaVolumeMin), value, static_cast<int>(TMediaData::MediaVolumeMax)); + } + + mediaData.setMediaVolume(value); + } else if (!key.compare(QLatin1String("start"), Qt::CaseInsensitive)) { + if (value < 0) { + lua_pushfstring(L, "playVideoFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "start", value); + errorPushed = true; + break; + } + + mediaData.setMediaStart(value); + } else if (!key.compare(QLatin1String("finish"), Qt::CaseInsensitive)) { + if (value < 0) { + lua_pushfstring(L, "playVideoFile: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "finish", value); + errorPushed = true; + break; + } + + mediaData.setMediaFinish(value); + } else if (!key.compare(QLatin1String("loops"), Qt::CaseInsensitive)) { + if (value < TMediaData::MediaLoopsRepeat || value == 0) { + value = TMediaData::MediaLoopsDefault; + } + + mediaData.setMediaLoops(value); + } + } else if (!key.compare(QLatin1String("continue"), Qt::CaseInsensitive)) { + if (!checkBoolArg(L, func, -1, "value for continue")) { + errorPushed = true; + break; } - mediaData.setMediaFinish(value); - } else if (!key.compare(QLatin1String("loops"), Qt::CaseInsensitive)) { - if (value < TMediaData::MediaLoopsRepeat || value == 0) { - value = TMediaData::MediaLoopsDefault; + mediaData.setMediaContinue(lua_toboolean(L, -1)); + } else if (!key.compare(QLatin1String("stream"), Qt::CaseInsensitive)) { + if (!checkBoolArg(L, func, -1, "value for stream")) { + errorPushed = true; + break; } - mediaData.setMediaLoops(value); + mediaData.setMediaInput(lua_toboolean(L, -1) ? TMediaData::MediaInputStream : TMediaData::MediaInputNotSet); + } else if (!key.compare(QLatin1String("close"), Qt::CaseInsensitive)) { + if (!checkBoolArg(L, func, -1, "value for close")) { + errorPushed = true; + break; + } + + mediaData.setMediaClose(lua_toboolean(L, -1) ? TMediaData::MediaCloseEnabled : TMediaData::MediaCloseDefault); } - } else if (!key.compare(QLatin1String("continue"), Qt::CaseInsensitive)) { - bool value = getVerifiedBool(L, func, -1, "value for continue must be boolean"); - mediaData.setMediaContinue(value); - } else if (!key.compare(QLatin1String("stream"), Qt::CaseInsensitive)) { - bool value = getVerifiedBool(L, func, -1, "value for stream must be boolean"); - mediaData.setMediaInput(value ? TMediaData::MediaInputStream : TMediaData::MediaInputNotSet); - } else if (!key.compare(QLatin1String("close"), Qt::CaseInsensitive)) { - bool value = getVerifiedBool(L, func, -1, "value for close must be boolean"); - mediaData.setMediaClose(value ? TMediaData::MediaCloseEnabled : TMediaData::MediaCloseDefault); + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + if (mediaData.mediaFileName().isEmpty()) { + lua_pushstring(L, R"(playVideoFile: missing name (add name = "file to play"))"); + errorPushed = true; + } else { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeVideo); + host.mpMedia->playMedia(mediaData); + } + } } - if (mediaData.mediaFileName().isEmpty()) { - lua_pushstring(L, R"(playVideoFile: missing name (add name = "file to play"))"); + if (errorPushed) { return lua_error(L); } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeVideo); - host.mpMedia->playMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -883,91 +1170,134 @@ void TLuaInterpreter::processPlayingMediaTable(lua_State* L, TMediaData& mediaDa // Private int TLuaInterpreter::getPlayingMusicAsOrderedArguments(lua_State* L, const char* func) { - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - // values as ordered args: name[,key][,tag] - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; - } + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); - - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); + // values as ordered args: name[,key][,tag] + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; } - mediaData.setMediaFileName(stringValue); - break; - case 2: - stringValue = getVerifiedString(L, func, i, "key"); - mediaData.setMediaKey(stringValue); - break; - case 3: - stringValue = getVerifiedString(L, func, i, "tag"); - mediaData.setMediaTag(stringValue); - break; + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkStringArg(L, func, i, "key")) { + errorPushed = true; + break; + } + + mediaData.setMediaKey(QString{lua_tostring(L, i)}); + break; + case 3: + if (!checkStringArg(L, func, i, "tag")) { + errorPushed = true; + break; + } + + mediaData.setMediaTag(QString{lua_tostring(L, i)}); + break; + } + } + + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + + processPlayingMediaTable(L, mediaData); } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); + if (errorPushed) { + return lua_error(L); + } - processPlayingMediaTable(L, mediaData); return 1; } // Private int TLuaInterpreter::getPlayingMusicAsTableArgument(lua_State* L, const char* func) { - TMediaData mediaData{}; + bool errorPushed = false; + { + TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + + processPlayingMediaTable(L, mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); + if (errorPushed) { + return lua_error(L); + } - processPlayingMediaTable(L, mediaData); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getPlayingMusic int TLuaInterpreter::getPlayingMusic(lua_State* L) { - TMediaData mediaData{}; - if (lua_gettop(L)) { if (lua_istable(L, 1)) { return getPlayingMusicAsTableArgument(L, __func__); @@ -976,7 +1306,7 @@ int TLuaInterpreter::getPlayingMusic(lua_State* L) return getPlayingMusicAsOrderedArguments(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeMusic); @@ -987,113 +1317,166 @@ int TLuaInterpreter::getPlayingMusic(lua_State* L) // Private int TLuaInterpreter::getPlayingSoundsAsOrderedArguments(lua_State* L, const char* func) { - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - int intValue = 0; - // values as ordered args: name[,key][,tag][,priority]) - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; + int intValue = 0; + + // values as ordered args: name[,key][,tag][,priority]) + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; + } + + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkStringArg(L, func, i, "key")) { + errorPushed = true; + break; + } + + mediaData.setMediaKey(QString{lua_tostring(L, i)}); + break; + case 3: + if (!checkStringArg(L, func, i, "tag")) { + errorPushed = true; + break; + } + + mediaData.setMediaTag(QString{lua_tostring(L, i)}); + break; + case 4: + if (!checkIntArg(L, func, i, "priority")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue > TMediaData::MediaPriorityMax) { + intValue = TMediaData::MediaPriorityMax; + } else if (intValue < TMediaData::MediaPriorityMin) { + intValue = TMediaData::MediaPriorityMin; + } + + mediaData.setMediaPriority(intValue); + break; + } } - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); - } - - mediaData.setMediaFileName(stringValue); - break; - case 2: - stringValue = getVerifiedString(L, func, i, "key"); - mediaData.setMediaKey(stringValue); - break; - case 3: - stringValue = getVerifiedString(L, func, i, "tag"); - mediaData.setMediaTag(stringValue); - break; - case 4: - intValue = getVerifiedInt(L, func, i, "priority"); - - if (intValue > TMediaData::MediaPriorityMax) { - intValue = TMediaData::MediaPriorityMax; - } else if (intValue < TMediaData::MediaPriorityMin) { - intValue = TMediaData::MediaPriorityMin; - } - - mediaData.setMediaPriority(intValue); - break; + processPlayingMediaTable(L, mediaData); } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); + if (errorPushed) { + return lua_error(L); + } - processPlayingMediaTable(L, mediaData); return 1; } // Private int TLuaInterpreter::getPlayingSoundsAsTableArgument(lua_State* L, const char* func) { - TMediaData mediaData{}; + bool errorPushed = false; + { + TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); - } - } else if (key == QLatin1String("priority")) { - int value = getVerifiedInt(L, func, -1, "value for priority must be integer"); + QString value{lua_tostring(L, -1)}; - if (value > TMediaData::MediaPriorityMax) { - value = TMediaData::MediaPriorityMax; - } else if (value < TMediaData::MediaPriorityMin) { - value = TMediaData::MediaPriorityMin; + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } + } else if (key == QLatin1String("priority")) { + if (!checkIntArg(L, func, -1, "value for priority")) { + errorPushed = true; + break; + } + + int value = static_cast<int>(lua_tointeger(L, -1)); + + if (value > TMediaData::MediaPriorityMax) { + value = TMediaData::MediaPriorityMax; + } else if (value < TMediaData::MediaPriorityMin) { + value = TMediaData::MediaPriorityMin; + } + + mediaData.setMediaPriority(value); } - mediaData.setMediaPriority(value); + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); + + processPlayingMediaTable(L, mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); + if (errorPushed) { + return lua_error(L); + } - processPlayingMediaTable(L, mediaData); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getPlayingSounds int TLuaInterpreter::getPlayingSounds(lua_State* L) { - TMediaData mediaData{}; - if (lua_gettop(L)) { if (lua_istable(L, 1)) { return getPlayingSoundsAsTableArgument(L, __func__); @@ -1102,7 +1485,7 @@ int TLuaInterpreter::getPlayingSounds(lua_State* L) return getPlayingSoundsAsOrderedArguments(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeSound); @@ -1113,48 +1496,67 @@ int TLuaInterpreter::getPlayingSounds(lua_State* L) // Private int TLuaInterpreter::getPlayingVideosAsTableArgument(lua_State* L, const char* func) { - TMediaData mediaData{}; + bool errorPushed = false; + { + TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeVideo); + + processPlayingMediaTable(L, mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeVideo); + if (errorPushed) { + return lua_error(L); + } - processPlayingMediaTable(L, mediaData); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getPlayingVideos int TLuaInterpreter::getPlayingVideos(lua_State* L) { - TMediaData mediaData{}; - if (lua_gettop(L)) { if (!lua_istable(L, 1)) { lua_pushfstring(L, "%s: needs to be a table", __func__); @@ -1164,7 +1566,7 @@ int TLuaInterpreter::getPlayingVideos(lua_State* L) return getPlayingVideosAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeVideo); @@ -1225,48 +1627,67 @@ void TLuaInterpreter::processPausedMediaTable(lua_State* L, TMediaData& mediaDat // Private int TLuaInterpreter::getPausedSoundsAsTableArgument(lua_State* L, const char* func) { - TMediaData mediaData{}; + bool errorPushed = false; + { + TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); + + processPausedMediaTable(L, mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); + if (errorPushed) { + return lua_error(L); + } - processPausedMediaTable(L, mediaData); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getPausedSounds int TLuaInterpreter::getPausedSounds(lua_State* L) { - TMediaData mediaData{}; - if (lua_gettop(L)) { if (!lua_istable(L, 1)) { lua_pushfstring(L, "%s: needs to be a table", __func__); @@ -1276,7 +1697,7 @@ int TLuaInterpreter::getPausedSounds(lua_State* L) return getPausedSoundsAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeSound); @@ -1287,48 +1708,67 @@ int TLuaInterpreter::getPausedSounds(lua_State* L) // Private int TLuaInterpreter::getPausedMusicAsTableArgument(lua_State* L, const char* func) { - TMediaData mediaData{}; + bool errorPushed = false; + { + TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + + processPausedMediaTable(L, mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); + if (errorPushed) { + return lua_error(L); + } - processPausedMediaTable(L, mediaData); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getPausedMusic int TLuaInterpreter::getPausedMusic(lua_State* L) { - TMediaData mediaData{}; - if (lua_gettop(L)) { if (!lua_istable(L, 1)) { lua_pushfstring(L, "%s: needs to be a table", __func__); @@ -1338,7 +1778,7 @@ int TLuaInterpreter::getPausedMusic(lua_State* L) return getPausedMusicAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeMusic); @@ -1349,48 +1789,67 @@ int TLuaInterpreter::getPausedMusic(lua_State* L) // Private int TLuaInterpreter::getPausedVideosAsTableArgument(lua_State* L, const char* func) { - TMediaData mediaData{}; + bool errorPushed = false; + { + TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeVideo); + + processPausedMediaTable(L, mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeVideo); + if (errorPushed) { + return lua_error(L); + } - processPausedMediaTable(L, mediaData); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getPausedVideos int TLuaInterpreter::getPausedVideos(lua_State* L) { - TMediaData mediaData{}; - if (lua_gettop(L)) { if (!lua_istable(L, 1)) { lua_pushfstring(L, "%s: needs to be a table", __func__); @@ -1400,7 +1859,7 @@ int TLuaInterpreter::getPausedVideos(lua_State* L) return getPausedVideosAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeVideo); @@ -1412,59 +1871,92 @@ int TLuaInterpreter::getPausedVideos(lua_State* L) int TLuaInterpreter::stopMusicAsOrderedArguments(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - bool boolValue; - int intValue; - // values as ordered args: name[,key][,tag][,fadeaway][,fadeout] - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; + int intValue = 0; + + // values as ordered args: name[,key][,tag][,fadeaway][,fadeout] + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; + } + + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkStringArg(L, func, i, "key")) { + errorPushed = true; + break; + } + + mediaData.setMediaKey(QString{lua_tostring(L, i)}); + break; + case 3: + if (!checkStringArg(L, func, i, "tag")) { + errorPushed = true; + break; + } + + mediaData.setMediaTag(QString{lua_tostring(L, i)}); + break; + case 4: + if (!checkBoolArg(L, func, i, "fadeaway")) { + errorPushed = true; + break; + } + + mediaData.setMediaFadeAway(lua_toboolean(L, i)); + break; + case 5: + if (!checkIntArg(L, func, i, "fadeout")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "stopMusic: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(intValue); + break; + } } - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); - } - - mediaData.setMediaFileName(stringValue); - break; - case 2: - stringValue = getVerifiedString(L, func, i, "key"); - mediaData.setMediaKey(stringValue); - break; - case 3: - stringValue = getVerifiedString(L, func, i, "tag"); - mediaData.setMediaTag(stringValue); - break; - case 4: - boolValue = getVerifiedBool(L, func, i, "fadeaway"); - mediaData.setMediaFadeAway(boolValue); - break; - case 5: - intValue = getVerifiedInt(L, func, i, "fadeout"); - - if (intValue < 0) { - lua_pushfstring(L, "stopMusic: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); - return lua_error(L); - } - - mediaData.setMediaFadeOut(intValue); - break; + host.mpMedia->stopMedia(mediaData); } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->stopMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1473,52 +1965,84 @@ int TLuaInterpreter::stopMusicAsOrderedArguments(lua_State* L, const char* func) int TLuaInterpreter::stopMusicAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); - } - } else if (key == QLatin1String("fadeaway")) { - const bool value = getVerifiedBool(L, func, -1, "value for fadeaway must be boolean"); - mediaData.setMediaFadeAway(value); - } else if (key == QLatin1String("fadeout")) { - int value = getVerifiedInt(L, func, -1, "value for fadeout"); + QString value{lua_tostring(L, -1)}; - if (value < 0) { - lua_pushfstring(L, "stopMusic: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); - return lua_error(L); + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } + } else if (key == QLatin1String("fadeaway")) { + if (!checkBoolArg(L, func, -1, "value for fadeaway")) { + errorPushed = true; + break; + } + + mediaData.setMediaFadeAway(lua_toboolean(L, -1)); + } else if (key == QLatin1String("fadeout")) { + if (!checkIntArg(L, func, -1, "value for fadeout")) { + errorPushed = true; + break; + } + + const int value = static_cast<int>(lua_tointeger(L, -1)); + + if (value < 0) { + lua_pushfstring(L, "stopMusic: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(value); } - mediaData.setMediaFadeOut(value); + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + + host.mpMedia->stopMedia(mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->stopMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1527,7 +2051,6 @@ int TLuaInterpreter::stopMusicAsTableArgument(lua_State* L, const char* func) int TLuaInterpreter::stopMusic(lua_State* L) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; if (lua_gettop(L)) { if (lua_istable(L, 1)) { @@ -1537,7 +2060,7 @@ int TLuaInterpreter::stopMusic(lua_State* L) return stopMusicAsOrderedArguments(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeMusic); @@ -1550,70 +2073,108 @@ int TLuaInterpreter::stopMusic(lua_State* L) int TLuaInterpreter::stopSoundsAsOrderedArguments(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; const int numArgs = lua_gettop(L); - QString stringValue; - bool boolValue; - int intValue = 0; - // values as ordered args: name[,key][,tag][,priority][,fadeaway][,fadeout]) - for (int i = 1; i <= numArgs; i++) { - if (lua_isnil(L, i)) { - continue; + bool errorPushed = false; + { + TMediaData mediaData{}; + QString stringValue; + int intValue = 0; + + // values as ordered args: name[,key][,tag][,priority][,fadeaway][,fadeout]) + for (int i = 1; i <= numArgs && !errorPushed; i++) { + if (lua_isnil(L, i)) { + continue; + } + + switch (i) { + case 1: + if (!checkStringArg(L, func, i, "name")) { + errorPushed = true; + break; + } + + stringValue = lua_tostring(L, i); + + if (QDir::homePath().contains('\\')) { + stringValue.replace('/', R"(\)"); + } else { + stringValue.replace('\\', "/"); + } + + mediaData.setMediaFileName(stringValue); + break; + case 2: + if (!checkStringArg(L, func, i, "key")) { + errorPushed = true; + break; + } + + mediaData.setMediaKey(QString{lua_tostring(L, i)}); + break; + case 3: + if (!checkStringArg(L, func, i, "tag")) { + errorPushed = true; + break; + } + + mediaData.setMediaTag(QString{lua_tostring(L, i)}); + break; + case 4: + if (!checkIntArg(L, func, i, "priority")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue > TMediaData::MediaPriorityMax) { + intValue = TMediaData::MediaPriorityMax; + } else if (intValue < TMediaData::MediaPriorityMin) { + intValue = TMediaData::MediaPriorityMin; + } + + mediaData.setMediaPriority(intValue); + break; + case 5: + if (!checkBoolArg(L, func, i, "fadeaway")) { + errorPushed = true; + break; + } + + mediaData.setMediaFadeAway(lua_toboolean(L, i)); + break; + case 6: + if (!checkIntArg(L, func, i, "fadeout")) { + errorPushed = true; + break; + } + + intValue = static_cast<int>(lua_tointeger(L, i)); + + if (intValue < 0) { + lua_pushfstring(L, "stopSounds: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(intValue); + break; + } } - switch (i) { - case 1: - stringValue = getVerifiedString(L, func, i, "name"); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); - if (QDir::homePath().contains('\\')) { - stringValue.replace('/', R"(\)"); - } else { - stringValue.replace('\\', "/"); - } - - mediaData.setMediaFileName(stringValue); - break; - case 2: - stringValue = getVerifiedString(L, func, i, "key"); - mediaData.setMediaKey(stringValue); - break; - case 3: - stringValue = getVerifiedString(L, func, i, "tag"); - mediaData.setMediaTag(stringValue); - break; - case 4: - intValue = getVerifiedInt(L, func, i, "priority"); - - if (intValue > TMediaData::MediaPriorityMax) { - intValue = TMediaData::MediaPriorityMax; - } else if (intValue < TMediaData::MediaPriorityMin) { - intValue = TMediaData::MediaPriorityMin; - } - - mediaData.setMediaPriority(intValue); - break; - case 5: - boolValue = getVerifiedBool(L, func, i, "fadeaway"); - mediaData.setMediaFadeAway(boolValue); - break; - case 6: - intValue = getVerifiedInt(L, func, i, "fadeout"); - - if (intValue < 0) { - lua_pushfstring(L, "stopSounds: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", intValue); - return lua_error(L); - } - - mediaData.setMediaFadeOut(intValue); - break; + host.mpMedia->stopMedia(mediaData); } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->stopMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1622,34 +2183,52 @@ int TLuaInterpreter::stopSoundsAsOrderedArguments(lua_State* L, const char* func int TLuaInterpreter::stopSoundsAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); - } - } else if (key == QLatin1String("priority")) { - int value = getVerifiedInt(L, func, -1, "value for priority must be integer"); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } + } else if (key == QLatin1String("priority")) { + if (!checkIntArg(L, func, -1, "value for priority")) { + errorPushed = true; + break; + } + + int value = static_cast<int>(lua_tointeger(L, -1)); - if (key == QLatin1String("priority")) { if (value > TMediaData::MediaPriorityMax) { value = TMediaData::MediaPriorityMax; } else if (value < TMediaData::MediaPriorityMin) { @@ -1657,29 +2236,46 @@ int TLuaInterpreter::stopSoundsAsTableArgument(lua_State* L, const char* func) } mediaData.setMediaPriority(value); - } - } else if (key == QLatin1String("fadeaway")) { - const bool value = getVerifiedBool(L, func, -1, "value for fadeaway must be boolean"); - mediaData.setMediaFadeAway(value); - } else if (key == QLatin1String("fadeout")) { - int value = getVerifiedInt(L, func, -1, "value for fadeout"); + } else if (key == QLatin1String("fadeaway")) { + if (!checkBoolArg(L, func, -1, "value for fadeaway")) { + errorPushed = true; + break; + } - if (value < 0) { - lua_pushfstring(L, "stopSounds: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); - return lua_error(L); + mediaData.setMediaFadeAway(lua_toboolean(L, -1)); + } else if (key == QLatin1String("fadeout")) { + if (!checkIntArg(L, func, -1, "value for fadeout")) { + errorPushed = true; + break; + } + + const int value = static_cast<int>(lua_tointeger(L, -1)); + + if (value < 0) { + lua_pushfstring(L, "stopSounds: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(value); } - mediaData.setMediaFadeOut(value); + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); + + host.mpMedia->stopMedia(mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->stopMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1688,7 +2284,6 @@ int TLuaInterpreter::stopSoundsAsTableArgument(lua_State* L, const char* func) int TLuaInterpreter::stopSounds(lua_State* L) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; if (lua_gettop(L)) { if (lua_istable(L, 1)) { @@ -1698,7 +2293,7 @@ int TLuaInterpreter::stopSounds(lua_State* L) return stopSoundsAsOrderedArguments(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeSound); @@ -1711,52 +2306,84 @@ int TLuaInterpreter::stopSounds(lua_State* L) int TLuaInterpreter::stopVideosAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); - } - } else if (key == QLatin1String("fadeaway")) { - const bool value = getVerifiedBool(L, func, -1, "value for fadeaway must be boolean"); - mediaData.setMediaFadeAway(value); - } else if (key == QLatin1String("fadeout")) { - int value = getVerifiedInt(L, func, -1, "value for fadeout"); + QString value{lua_tostring(L, -1)}; - if (value < 0) { - lua_pushfstring(L, "stopVideos: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); - return lua_error(L); + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } + } else if (key == QLatin1String("fadeaway")) { + if (!checkBoolArg(L, func, -1, "value for fadeaway")) { + errorPushed = true; + break; + } + + mediaData.setMediaFadeAway(lua_toboolean(L, -1)); + } else if (key == QLatin1String("fadeout")) { + if (!checkIntArg(L, func, -1, "value for fadeout")) { + errorPushed = true; + break; + } + + const int value = static_cast<int>(lua_tointeger(L, -1)); + + if (value < 0) { + lua_pushfstring(L, "stopVideos: bad argument range for %s (values must be greater than or equal to 0, got value: %d)", "fadeout", value); + errorPushed = true; + break; + } + + mediaData.setMediaFadeOut(value); } - mediaData.setMediaFadeOut(value); + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeVideo); + + host.mpMedia->stopMedia(mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeVideo); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->stopMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1765,7 +2392,6 @@ int TLuaInterpreter::stopVideosAsTableArgument(lua_State* L, const char* func) int TLuaInterpreter::stopVideos(lua_State* L) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; if (lua_gettop(L)) { if (!lua_istable(L, 1)) { @@ -1776,7 +2402,7 @@ int TLuaInterpreter::stopVideos(lua_State* L) return stopVideosAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeVideo); @@ -1789,40 +2415,62 @@ int TLuaInterpreter::stopVideos(lua_State* L) int TLuaInterpreter::pauseSoundsAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeSound); + + host.mpMedia->pauseMedia(mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeSound); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->pauseMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1831,7 +2479,6 @@ int TLuaInterpreter::pauseSoundsAsTableArgument(lua_State* L, const char* func) int TLuaInterpreter::pauseSounds(lua_State* L) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; if (lua_gettop(L)) { if (!lua_istable(L, 1)) { @@ -1842,7 +2489,7 @@ int TLuaInterpreter::pauseSounds(lua_State* L) return pauseSoundsAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeSound); @@ -1855,40 +2502,62 @@ int TLuaInterpreter::pauseSounds(lua_State* L) int TLuaInterpreter::pauseMusicAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeMusic); + + host.mpMedia->pauseMedia(mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeMusic); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->pauseMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1897,7 +2566,6 @@ int TLuaInterpreter::pauseMusicAsTableArgument(lua_State* L, const char* func) int TLuaInterpreter::pauseMusic(lua_State* L) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; if (lua_gettop(L)) { if (!lua_istable(L, 1)) { @@ -1908,7 +2576,7 @@ int TLuaInterpreter::pauseMusic(lua_State* L) return pauseMusicAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeMusic); @@ -1921,40 +2589,62 @@ int TLuaInterpreter::pauseMusic(lua_State* L) int TLuaInterpreter::pauseVideosAsTableArgument(lua_State* L, const char* func) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; - lua_pushnil(L); - while (lua_next(L, 1) != 0) { - // key at index -2 and value at index -1 - QString key = getVerifiedString(L, func, -2, "table keys"); - key = key.toLower(); + bool errorPushed = false; + { + TMediaData mediaData{}; - if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { - QString value = getVerifiedString(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag"); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + // key at index -2 and value at index -1 + if (!checkStringArg(L, func, -2, "table keys")) { + errorPushed = true; + break; + } - if (key == QLatin1String("name") && !value.isEmpty()) { - if (QDir::homePath().contains('\\')) { - value.replace('/', R"(\)"); - } else { - value.replace('\\', "/"); + lua_pushvalue(L, -2); + const QString key = QString{lua_tostring(L, -1)}.toLower(); + lua_pop(L, 1); + + if (key == QLatin1String("name") || key == QLatin1String("key") || key == QLatin1String("tag")) { + if (!checkStringArg(L, func, -1, key == QLatin1String("name") ? "value for name" : key == QLatin1String("key") ? "value for key" : "value for tag")) { + errorPushed = true; + break; } - mediaData.setMediaFileName(value); - } else if (key == QLatin1String("key") && !value.isEmpty()) { - mediaData.setMediaKey(value); - } else if (key == QLatin1String("tag") && !value.isEmpty()) { - mediaData.setMediaTag(value); + QString value{lua_tostring(L, -1)}; + + if (key == QLatin1String("name") && !value.isEmpty()) { + if (QDir::homePath().contains('\\')) { + value.replace('/', R"(\)"); + } else { + value.replace('\\', "/"); + } + + mediaData.setMediaFileName(value); + } else if (key == QLatin1String("key") && !value.isEmpty()) { + mediaData.setMediaKey(value); + } else if (key == QLatin1String("tag") && !value.isEmpty()) { + mediaData.setMediaTag(value); + } } + + // removes value, but keeps key for next iteration + lua_pop(L, 1); } - // removes value, but keeps key for next iteration - lua_pop(L, 1); + if (!errorPushed) { + mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); + mediaData.setMediaType(TMediaData::MediaTypeVideo); + + host.mpMedia->pauseMedia(mediaData); + } } - mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); - mediaData.setMediaType(TMediaData::MediaTypeVideo); + if (errorPushed) { + return lua_error(L); + } - host.mpMedia->pauseMedia(mediaData); lua_pushboolean(L, true); return 1; } @@ -1963,7 +2653,6 @@ int TLuaInterpreter::pauseVideosAsTableArgument(lua_State* L, const char* func) int TLuaInterpreter::pauseVideos(lua_State* L) { const Host& host = getHostFromLua(L); - TMediaData mediaData{}; if (lua_gettop(L)) { if (!lua_istable(L, 1)) { @@ -1974,7 +2663,7 @@ int TLuaInterpreter::pauseVideos(lua_State* L) return pauseVideosAsTableArgument(L, __func__); } - // no args + TMediaData mediaData{}; mediaData.setMediaProtocol(TMediaData::MediaProtocolAPI); mediaData.setMediaType(TMediaData::MediaTypeVideo); @@ -1987,7 +2676,12 @@ int TLuaInterpreter::pauseVideos(lua_State* L) int TLuaInterpreter::purgeMediaCache(lua_State* L) { Host& host = getHostFromLua(L); - host.mTelnet.purgeMediaCache(); + const auto [purged, message] = host.mTelnet.purgeMediaCache(); + + if (!purged) { + return warnArgumentValue(L, __func__, message); + } + lua_pushboolean(L, true); return 1; } diff --git a/src/TLuaInterpreterMudletObjects.cpp b/src/TLuaInterpreterMudletObjects.cpp index c00f3427e..d4bcaf6cc 100644 --- a/src/TLuaInterpreterMudletObjects.cpp +++ b/src/TLuaInterpreterMudletObjects.cpp @@ -31,6 +31,7 @@ #include "TLuaInterpreter.h" #include "EAction.h" +#include "EventLoopPump.h" #include "Host.h" #include "TAlias.h" #include "TArea.h" @@ -60,6 +61,10 @@ #include "glwidget_integration.h" #endif +#include <QScopeGuard> + +#include <algorithm> +#include <cmath> #include <limits> #include <math.h> @@ -109,6 +114,21 @@ static bool isMain(const QString& name) return false; } +// Both timer creators turn the delay into the timer's interval with +// QTime(0, 0, 0, 0).addMSecs(qRound(time * 1000)), which wraps around the 24 +// hour clock: a negative delay would silently give a timer firing almost a day +// later, and a whole day one with no interval at all - firing on every event +// loop turn, were it repeating. It is the rounded milliseconds that have to be +// bounded and not the delay itself, as 86399.9995 seconds is under the day yet +// rounds up onto it. Repeating the rounding here in the double domain keeps a +// huge delay from overflowing the int conversion qRound() would do first, and +// the comparison is written so that a NaN delay is rejected as well: +static bool timerDelayFits(const double time) +{ + const double msec = std::floor(time * 1000.0 + 0.5); + return msec >= 0 && msec < 86400000; +} + #define WINDOW_NAME(ARG_L, ARG_pos) \ ({ \ int pos_ = (ARG_pos); \ @@ -184,12 +204,16 @@ static bool isMain(const QString& name) int TLuaInterpreter::addCmdLineSuggestion(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + // The mandatory text is last, but with no arguments at all that would be + // index 0 - not a valid Lua stack index, and Lua 5.1 hands back the first + // free slot for it rather than complaining: + const int textIndex = qMax(n, 1); + const char* name = "main"; if (n > 1) { name = CMDLINE_NAME(L, 1); } - const QString text = getVerifiedString(L, __func__, n, "suggestion text"); - auto pN = COMMANDLINE(L, name); + const QString text = getVerifiedString(L, __func__, textIndex, "suggestion text"); + auto pN = COMMANDLINE(L, QString{name}); pN->addSuggestion(text); return 0; } @@ -223,13 +247,15 @@ int TLuaInterpreter::adjustStopWatch(lua_State* L) int TLuaInterpreter::appendCmdLine(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + // See addCmdLineSuggestion() on why the index is clamped: + const int textIndex = qMax(n, 1); + const char* name = "main"; if (n > 1) { name = CMDLINE_NAME(L, 1); } - const QString text = getVerifiedString(L, __func__, n, "text to set on command line"); - auto pN = COMMANDLINE(L, name); + const QString text = getVerifiedString(L, __func__, textIndex, "text to set on command line"); + auto pN = COMMANDLINE(L, QString{name}); const QString curText = pN->toPlainText(); pN->setPlainText(curText + text); @@ -245,11 +271,11 @@ int TLuaInterpreter::appendCmdLine(lua_State* L) int TLuaInterpreter::clearCmdLine(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + const char* name = "main"; if (n >= 1) { name = CMDLINE_NAME(L, 1); } - auto pN = COMMANDLINE(L, name); + auto pN = COMMANDLINE(L, QString{name}); pN->clear(); pN->adjustHeight(); return 0; @@ -259,11 +285,11 @@ int TLuaInterpreter::clearCmdLine(lua_State* L) int TLuaInterpreter::clearCmdLineSuggestions(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + const char* name = "main"; if (n == 1) { name = CMDLINE_NAME(L, 1); } - auto pN = COMMANDLINE(L, name); + auto pN = COMMANDLINE(L, QString{name}); pN->clearSuggestions(); return 0; } @@ -271,7 +297,7 @@ int TLuaInterpreter::clearCmdLineSuggestions(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#createStopWatch int TLuaInterpreter::createStopWatch(lua_State* L) { - QString name; + bool hasName = false; bool autoStart = true; const int n = lua_gettop(L); int s = 1; @@ -280,7 +306,7 @@ int TLuaInterpreter::createStopWatch(lua_State* L) autoStart = lua_toboolean(L, s); } else if (lua_type(L, s) == LUA_TSTRING) { autoStart = false; - name = lua_tostring(L, 1); + hasName = true; } else if (lua_type(L, s) == LUA_TNIL) { ; // fallthrough for compatibility with old-style stopwatches in case createStopWatch(nil) is passed // note that 'nil' will still count towards the stack's gettop amount @@ -294,6 +320,7 @@ int TLuaInterpreter::createStopWatch(lua_State* L) } } + const QString name = hasName ? QString{lua_tostring(L, 1)} : QString(); Host& host = getHostFromLua(L); QPair<int, QString> const result = host.createStopWatch(name); @@ -337,12 +364,14 @@ int TLuaInterpreter::deleteStopWatch(lua_State* L) int TLuaInterpreter::removeCmdLineSuggestion(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + // See addCmdLineSuggestion() on why the index is clamped: + const int textIndex = qMax(n, 1); + const char* name = "main"; if (n > 1) { name = CMDLINE_NAME(L, 1); } - const QString text = getVerifiedString(L, __func__, n, "suggestion text"); - auto pN = COMMANDLINE(L, name); + const QString text = getVerifiedString(L, __func__, textIndex, "suggestion text"); + auto pN = COMMANDLINE(L, QString{name}); pN->removeSuggestion(text); return 0; } @@ -474,10 +503,14 @@ int TLuaInterpreter::enableTrigger(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#exists int TLuaInterpreter::exists(lua_State* L) { + if (!checkStringOrIntegerArg(L, __func__, 1, "itemID or item name") || !checkStringArg(L, __func__, 2, "item type")) { + return lua_error(L); + } + auto [isId, nameOrId] = getVerifiedStringOrInteger(L, __func__, 1, "itemID or item name"); // Although we only use 6 ASCII strings the user may not enter a purely // ASCII value which we might have to report... - QString type = getVerifiedString(L, __func__, 2, "item type").toLower(); + QString type = QString{lua_tostring(L, 2)}.toLower(); bool isOk = false; const int id = nameOrId.toInt(&isOk); if (isId && (!isOk || id < 0)) { @@ -570,8 +603,7 @@ int TLuaInterpreter::getKeyCode(lua_State* L) } if (!pT) { - const QString errorMsg = isId ? qsl("keybind ID %1 does not exist").arg(nameOrId) - : qsl("keybind '%1' does not exist").arg(nameOrId); + const QString errorMsg = isId ? qsl("keybind ID %1 does not exist").arg(nameOrId) : qsl("keybind '%1' does not exist").arg(nameOrId); return warnArgumentValue(L, __func__, errorMsg); } @@ -607,11 +639,11 @@ int TLuaInterpreter::getButtonState(lua_State* L) int TLuaInterpreter::getCmdLine(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + const char* name = "main"; if (n >= 1) { name = CMDLINE_NAME(L, 1); } - auto commandline = COMMANDLINE(L, name); + auto commandline = COMMANDLINE(L, QString{name}); const QString text = commandline->toPlainText(); lua_pushstring(L, text.toUtf8().constData()); return 1; @@ -871,11 +903,14 @@ int TLuaInterpreter::getStopWatchBrokenDownTime(lua_State* L) int TLuaInterpreter::getScript(lua_State* L) { const int n = lua_gettop(L); + if (!checkStringArg(L, __func__, 1, "script name")) { + return lua_error(L); + } int pos = 1; - const QString name = getVerifiedString(L, __func__, 1, "script name"); if (n > 1) { pos = getVerifiedInt(L, __func__, 2, "script position"); } + const QString name{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); auto ids = host.getScriptUnit()->findItems(name); @@ -897,14 +932,18 @@ int TLuaInterpreter::getScript(lua_State* L) int TLuaInterpreter::invokeFileDialog(lua_State* L) { const int n = lua_gettop(L); + if (!checkBoolArg(L, __func__, 1, "fileOrFolder") || !checkStringArg(L, __func__, 2, "dialogTitle") || (n > 2 && !checkStringArg(L, __func__, 3, "dialogLocation"))) { + return lua_error(L); + } + Host& host = getHostFromLua(L); QString location = mudlet::getMudletPath(enums::profileHomePath, host.getName()); - const bool luaDir = getVerifiedBool(L, __func__, 1, "fileOrFolder"); - const QString title = getVerifiedString(L, __func__, 2, "dialogTitle"); + const bool luaDir = lua_toboolean(L, 1); + const QString title{lua_tostring(L, 2)}; if (n > 2) { - QString target = getVerifiedString(L, __func__, 3, "dialogLocation"); - QDir dir(target); + const QString target{lua_tostring(L, 3)}; + const QDir dir(target); if (dir.exists()) { location = target; @@ -924,23 +963,30 @@ int TLuaInterpreter::invokeFileDialog(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#isActive int TLuaInterpreter::isActive(lua_State* L) { + if (!checkStringOrIntegerArg(L, __func__, 1, "item name or ID") || !checkStringArg(L, __func__, 2, "item type")) { + return lua_error(L); + } + if (lua_type(L, 1) == LUA_TNUMBER) { + bool isOk = false; + const int id = getVerifiedStringOrInteger(L, __func__, 1, "item name or ID").second.toInt(&isOk); + if (!isOk || id < 0) { + // Must be zero or more but doesn't seem to be, must return the + // original supplied argument as a string (rather than the nameOrId + // "number" as the latter will have been rounded to an integer) to + // show what was entered: + return warnArgumentValue(L, __func__, csmInvalidItemID.arg(lua_tostring(L, 1))); + } + } + if (lua_gettop(L) > 2 && !checkBoolArg(L, __func__, 3, "also check ancestors", true)) { + return lua_error(L); + } + auto [isId, nameOrId] = getVerifiedStringOrInteger(L, __func__, 1, "item name or ID"); // Although we only use 4 ASCII strings the user may not enter a purely // ASCII value which we might have to report... - const QString type = getVerifiedString(L, __func__, 2, "item type"); - bool isOk = false; - const int id = nameOrId.toInt(&isOk); - if (isId && (!isOk || id < 0)) { - // Must be zero or more but doesn't seem to be, must return the - // original supplied argument as a string (rather than the nameOrId - // "number" as the latter will have been rounded to an integer) to - // show what was entered: - return warnArgumentValue(L, __func__, csmInvalidItemID.arg(lua_tostring(L, 1))); - } - bool checkAncestors = false; - if (lua_gettop(L) > 2) { - checkAncestors = getVerifiedBool(L, __func__, 3, "also check ancestors", true); - } + const QString type{lua_tostring(L, 2)}; + const bool checkAncestors = (lua_gettop(L) > 2) && lua_toboolean(L, 3); + const int id = nameOrId.toInt(); Host& host = getHostFromLua(L); int cnt = 0; @@ -1094,22 +1140,31 @@ int TLuaInterpreter::killTrigger(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permAlias int TLuaInterpreter::permAlias(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "alias name"); - const QString parent = getVerifiedString(L, __func__, 2, "alias group/parent"); - const QString regex = getVerifiedString(L, __func__, 3, "regexp pattern"); + if (!checkStringArg(L, __func__, 1, "alias name") || !checkStringArg(L, __func__, 2, "alias group/parent") || !checkStringArg(L, __func__, 3, "regexp pattern")) { + return lua_error(L); + } Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permAlias", 4)) { return lua_error(L); } - const QString script{lua_tostring(L, 4)}; - auto [aliasId, message] = pLuaInterpreter->startPermAlias(name, parent, regex, script); - if (aliasId == -1) { - lua_pushfstring(L, "permAlias: cannot create alias (%s)", message.toUtf8().constData()); + int id = -1; + { + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString regex{lua_tostring(L, 3)}; + const QString script{lua_tostring(L, 4)}; + auto [aliasId, message] = pLuaInterpreter->startPermAlias(name, parent, regex, script); + id = aliasId; + if (aliasId == -1) { + lua_pushfstring(L, "permAlias: cannot create alias (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, aliasId); + lua_pushnumber(L, id); return 1; } @@ -1118,214 +1173,278 @@ int TLuaInterpreter::permPromptTrigger(lua_State* L) { Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); - const QString triggerName = getVerifiedString(L, __func__, 1, "trigger name"); - const QString parentName = getVerifiedString(L, __func__, 2, "parent trigger name"); + if (!checkStringArg(L, __func__, 1, "trigger name") || !checkStringArg(L, __func__, 2, "parent trigger name")) { + return lua_error(L); + } if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permPromptTrigger", 3)) { return lua_error(L); } - const QString luaFunction = lua_tostring(L, 3); - auto [triggerID, message] = pLuaInterpreter->startPermPromptTrigger(triggerName, parentName, luaFunction); - if (triggerID == -1) { - lua_pushfstring(L, "permPromptTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + int id = -1; + { + const QString triggerName{lua_tostring(L, 1)}; + const QString parentName{lua_tostring(L, 2)}; + const QString luaFunction{lua_tostring(L, 3)}; + auto [triggerID, message] = pLuaInterpreter->startPermPromptTrigger(triggerName, parentName, luaFunction); + id = triggerID; + if (triggerID == -1) { + lua_pushfstring(L, "permPromptTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, triggerID); + lua_pushnumber(L, id); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permRegexTrigger int TLuaInterpreter::permRegexTrigger(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "trigger name"); - const QString parent = getVerifiedString(L, __func__, 2, "trigger parent"); - - QStringList regList; + if (!checkStringArg(L, __func__, 1, "trigger name") || !checkStringArg(L, __func__, 2, "trigger parent")) { + return lua_error(L); + } if (!lua_istable(L, 3)) { lua_pushfstring(L, "permRegexTrigger: bad argument #3 type (sub-strings list as table expected, got %s!)", luaL_typename(L, 3)); return lua_error(L); } - lua_pushnil(L); - while (lua_next(L, 3) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING) { - regList << lua_tostring(L, -1); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permRegexTrigger", 4)) { return lua_error(L); } - const QString script{lua_tostring(L, 4)}; - auto [triggerId, message] = pLuaInterpreter->startPermRegexTrigger(name, parent, regList, script); - if (triggerId == -1) { - lua_pushfstring(L, "permRegexTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + int id = -1; + { + QStringList regList; + lua_pushnil(L); + while (lua_next(L, 3) != 0) { + // key at index -2 and value at index -1 + if (lua_type(L, -1) == LUA_TSTRING) { + regList << lua_tostring(L, -1); + } + // removes value, but keeps key for next iteration + lua_pop(L, 1); + } + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString script{lua_tostring(L, 4)}; + auto [triggerId, message] = pLuaInterpreter->startPermRegexTrigger(name, parent, regList, script); + id = triggerId; + if (triggerId == -1) { + lua_pushfstring(L, "permRegexTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, triggerId); + lua_pushnumber(L, id); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permBeginOfLineStringTrigger int TLuaInterpreter::permBeginOfLineStringTrigger(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "trigger name"); - const QString parent = getVerifiedString(L, __func__, 2, "trigger parent"); - - QStringList regList; + if (!checkStringArg(L, __func__, 1, "trigger name") || !checkStringArg(L, __func__, 2, "trigger parent")) { + return lua_error(L); + } if (!lua_istable(L, 3)) { lua_pushfstring(L, "permBeginOfLineStringTrigger: bad argument #3 type (sub-strings list as table expected, got %s!)", luaL_typename(L, 3)); return lua_error(L); } - lua_pushnil(L); - while (lua_next(L, 3) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING) { - regList << lua_tostring(L, -1); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permBeginOfLineStringTrigger", 4)) { return lua_error(L); } - const QString script{lua_tostring(L, 4)}; - auto [triggerId, message] = pLuaInterpreter->startPermBeginOfLineStringTrigger(name, parent, regList, script); - if (triggerId == -1) { - lua_pushfstring(L, "permBeginOfLineStringTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + int id = -1; + { + QStringList regList; + lua_pushnil(L); + while (lua_next(L, 3) != 0) { + // key at index -2 and value at index -1 + if (lua_type(L, -1) == LUA_TSTRING) { + regList << lua_tostring(L, -1); + } + // removes value, but keeps key for next iteration + lua_pop(L, 1); + } + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString script{lua_tostring(L, 4)}; + auto [triggerId, message] = pLuaInterpreter->startPermBeginOfLineStringTrigger(name, parent, regList, script); + id = triggerId; + if (triggerId == -1) { + lua_pushfstring(L, "permBeginOfLineStringTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, triggerId); + lua_pushnumber(L, id); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permSubstringTrigger int TLuaInterpreter::permSubstringTrigger(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "trigger name"); - const QString parent = getVerifiedString(L, __func__, 2, "trigger parent"); - QStringList regList; + if (!checkStringArg(L, __func__, 1, "trigger name") || !checkStringArg(L, __func__, 2, "trigger parent")) { + return lua_error(L); + } if (!lua_istable(L, 3)) { lua_pushfstring(L, "permSubstringTrigger: bad argument #3 type (sub-strings list as table expected, got %s!)", luaL_typename(L, 3)); return lua_error(L); } - lua_pushnil(L); - while (lua_next(L, 3) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING) { - regList << lua_tostring(L, -1); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permSubstringTrigger", 4)) { return lua_error(L); } - const QString script{lua_tostring(L, 4)}; - auto [triggerID, message] = pLuaInterpreter->startPermSubstringTrigger(name, parent, regList, script); - if (triggerID == -1) { - lua_pushfstring(L, "permSubstringTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + int id = -1; + { + QStringList regList; + lua_pushnil(L); + while (lua_next(L, 3) != 0) { + // key at index -2 and value at index -1 + if (lua_type(L, -1) == LUA_TSTRING) { + regList << lua_tostring(L, -1); + } + // removes value, but keeps key for next iteration + lua_pop(L, 1); + } + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString script{lua_tostring(L, 4)}; + auto [triggerID, message] = pLuaInterpreter->startPermSubstringTrigger(name, parent, regList, script); + id = triggerID; + if (triggerID == -1) { + lua_pushfstring(L, "permSubstringTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, triggerID); + lua_pushnumber(L, id); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permExactMatchTrigger int TLuaInterpreter::permExactMatchTrigger(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "trigger name"); - const QString parent = getVerifiedString(L, __func__, 2, "trigger parent"); - QStringList patternList; + if (!checkStringArg(L, __func__, 1, "trigger name") || !checkStringArg(L, __func__, 2, "trigger parent")) { + return lua_error(L); + } if (!lua_istable(L, 3)) { lua_pushfstring(L, "permExactMatchTrigger: bad argument #3 type (exact match patterns list as table expected, got %s!)", luaL_typename(L, 3)); return lua_error(L); } - lua_pushnil(L); - while (lua_next(L, 3) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING) { - patternList << lua_tostring(L, -1); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permExactMatchTrigger", 4)) { return lua_error(L); } - const QString script{lua_tostring(L, 4)}; - auto [triggerID, message] = pLuaInterpreter->startPermExactMatchTrigger(name, parent, patternList, script); - if (triggerID == -1) { - lua_pushfstring(L, "permExactMatchTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + int id = -1; + { + QStringList patternList; + lua_pushnil(L); + while (lua_next(L, 3) != 0) { + // key at index -2 and value at index -1 + if (lua_type(L, -1) == LUA_TSTRING) { + patternList << lua_tostring(L, -1); + } + // removes value, but keeps key for next iteration + lua_pop(L, 1); + } + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString script{lua_tostring(L, 4)}; + auto [triggerID, message] = pLuaInterpreter->startPermExactMatchTrigger(name, parent, patternList, script); + id = triggerID; + if (triggerID == -1) { + lua_pushfstring(L, "permExactMatchTrigger: cannot create trigger (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, triggerID); + lua_pushnumber(L, id); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permScript int TLuaInterpreter::permScript(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "script name"); - const QString parent = getVerifiedString(L, __func__, 2, "script parent name"); + if (!checkStringArg(L, __func__, 1, "script name") || !checkStringArg(L, __func__, 2, "script parent name")) { + return lua_error(L); + } Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permScript", 3)) { return lua_error(L); } - const QString luaCode{lua_tostring(L, 3)}; - auto [id, message] = pLuaInterpreter->createPermScript(name, parent, luaCode); - if (id == -1) { - lua_pushfstring(L, "permScript: cannot create script (%s)", message.toUtf8().constData()); + + int scriptId = -1; + { + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString luaCode{lua_tostring(L, 3)}; + auto [id, message] = pLuaInterpreter->createPermScript(name, parent, luaCode); + scriptId = id; + if (id == -1) { + lua_pushfstring(L, "permScript: cannot create script (%s)", message.toUtf8().constData()); + } + } + if (scriptId == -1) { return lua_error(L); } - lua_pushnumber(L, id); + lua_pushnumber(L, scriptId); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permTimer int TLuaInterpreter::permTimer(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "timer name"); - const QString parent = getVerifiedString(L, __func__, 2, "timer parent name"); + if (!checkStringArg(L, __func__, 1, "timer name") || !checkStringArg(L, __func__, 2, "timer parent name")) { + return lua_error(L); + } const double time = getVerifiedDouble(L, __func__, 3, "time in seconds"); + if (!timerDelayFits(time)) { + lua_pushfstring(L, "permTimer: bad argument #3 value (time in seconds must be at least 0 and less than 86400, got %f)", time); + return lua_error(L); + } Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "permTimer", 4)) { return lua_error(L); } - const QString luaCode{lua_tostring(L, 4)}; - auto [id, message] = pLuaInterpreter->startPermTimer(name, parent, time, luaCode); - if (id == -1) { - lua_pushfstring(L, "permTimer: cannot create timer (%s)", message.toUtf8().constData()); + + int timerId = -1; + { + const QString name{lua_tostring(L, 1)}; + const QString parent{lua_tostring(L, 2)}; + const QString luaCode{lua_tostring(L, 4)}; + auto [id, message] = pLuaInterpreter->startPermTimer(name, parent, time, luaCode); + timerId = id; + if (id == -1) { + lua_pushfstring(L, "permTimer: cannot create timer (%s)", message.toUtf8().constData()); + } + } + if (timerId == -1) { return lua_error(L); } - lua_pushnumber(L, id); + lua_pushnumber(L, timerId); return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#permKey int TLuaInterpreter::permKey(lua_State* L) { - QString keyName = getVerifiedString(L, __func__, 1, "key name"); - QString parentGroup = getVerifiedString(L, __func__, 2, "key parent group"); + if (!checkStringArg(L, __func__, 1, "key name") || !checkStringArg(L, __func__, 2, "key parent group")) { + return lua_error(L); + } uint_fast8_t argIndex = 3; int keyModifier = Qt::NoModifier; @@ -1341,13 +1460,21 @@ int TLuaInterpreter::permKey(lua_State* L) return lua_error(L); } - QString luaFunction{lua_tostring(L, argIndex)}; - auto [keyID, message] = pLuaInterpreter->startPermKey(keyName, parentGroup, keyCode, keyModifier, luaFunction); - if (keyID == -1) { - lua_pushfstring(L, "permKey: cannot create key (%s)", message.toUtf8().constData()); + int id = -1; + { + QString keyName{lua_tostring(L, 1)}; + QString parentGroup{lua_tostring(L, 2)}; + QString luaFunction{lua_tostring(L, argIndex)}; + auto [keyID, message] = pLuaInterpreter->startPermKey(keyName, parentGroup, keyCode, keyModifier, luaFunction); + id = keyID; + if (keyID == -1) { + lua_pushfstring(L, "permKey: cannot create key (%s)", message.toUtf8().constData()); + } + } + if (id == -1) { return lua_error(L); } - lua_pushnumber(L, keyID); + lua_pushnumber(L, id); return 1; } @@ -1355,13 +1482,15 @@ int TLuaInterpreter::permKey(lua_State* L) int TLuaInterpreter::printCmdLine(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + // See addCmdLineSuggestion() on why the index is clamped: + const int textIndex = qMax(n, 1); + const char* name = "main"; if (n > 1) { name = CMDLINE_NAME(L, 1); } - const QString text = getVerifiedString(L, __func__, n, "text to set on command line"); + const QString text = getVerifiedString(L, __func__, textIndex, "text to set on command line"); - auto pN = COMMANDLINE(L, name); + auto pN = COMMANDLINE(L, QString{name}); pN->setPlainText(text); QTextCursor cur = pN->textCursor(); cur.clearSelection(); @@ -1431,6 +1560,157 @@ int TLuaInterpreter::raiseEvent(lua_State* L) return 1; } +// A gone Host, or a mudlet singleton already past its destructor, is further +// along than the flags rather than healthier, so the nulls count as shutting +// down too. +static bool shuttingDown(const QPointer<Host>& pHost) +{ + mudlet* pMudlet = mudlet::self(); + return !pHost || pHost->isClosingDown() || !pMudlet || pMudlet->isGoingDown(); +} + +// No documentation available in wiki - internal, test-only function +// Blocks the calling Lua code until the named event is raised, returning the +// event name and its arguments exactly as an event handler would receive them, +// or nil and an error message. Timers and networking run on meanwhile. +int TLuaInterpreter::waitForEvent(lua_State* L) +{ + if (!qEnvironmentVariableIsSet("MUDLET_TEST_MODE")) { + lua_pushnil(L); + lua_pushstring(L, "waitForEvent: only available in test mode (set the MUDLET_TEST_MODE environment variable)"); + return 2; + } + + if (!checkStringArg(L, __func__, 1, "event name")) { + return lua_error(L); + } + const char* eventNameArg = lua_tostring(L, 1); + if (*eventNameArg == '\0') { + return warnArgumentValue(L, __func__, "event name cannot be empty"); + } + + // Keep well below busted's per-spec CI timeout of one minute so a runaway + // wait fails as a normal timeout rather than killing the whole suite. + constexpr int defaultTimeoutMs = 3000; + constexpr int maximumTimeoutMs = 30000; + int timeoutMs = defaultTimeoutMs; + if (!lua_isnoneornil(L, 2)) { + timeoutMs = getVerifiedInt(L, __func__, 2, "timeout in milliseconds", true); + } + timeoutMs = std::clamp(timeoutMs, 0, maximumTimeoutMs); + const QString eventName{eventNameArg}; + + Host& host = getHostFromLua(L); + TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); + + // A reset recreates this lua_State and a shutdown destroys the interpreter, + // either of which frees the state L runs on mid-wait. resetProfile_phase1() + // guards the mirror case, a reset asked for once we are already blocked. + if (host.profileResetInProgress() || host.isClosingDown()) { + lua_pushnil(L); + lua_pushstring(L, "waitForEvent: cannot wait while the profile is being reset or Mudlet is closing"); + return 2; + } + + TEventWait wait; + wait.mName = eventName; + pLuaInterpreter->mPendingEventWaits.append(&wait); + + const QPointer<Host> pHost(&host); + const bool stoppedEarly = EventLoopPump::pumpFor(timeoutMs, [&wait, &pHost]() { + return wait.mCaptured || shuttingDown(pHost); + }); + + pLuaInterpreter->mPendingEventWaits.removeAll(&wait); + + if (!wait.mCaptured) { + lua_pushnil(L); + if (stoppedEarly) { + lua_pushstring(L, qsl("waitForEvent: gave up waiting for event '%1', Mudlet is shutting down").arg(eventName).toUtf8().constData()); + } else { + lua_pushstring(L, qsl("waitForEvent: timed out after %1ms waiting for event '%2'").arg(QString::number(timeoutMs), eventName).toUtf8().constData()); + } + return 2; + } + + lua_rawgeti(L, LUA_REGISTRYINDEX, wait.mArgsRef); + lua_getfield(L, -1, "n"); + const int argCount = static_cast<int>(lua_tointeger(L, -1)); + lua_pop(L, 1); + const int argsTableIndex = lua_gettop(L); + // A lua_CFunction is only guaranteed LUA_MINSTACK slots; an event can carry + // up to LUA_FUNCTION_MAX_ARGS arguments, so grow the stack before pushing. + // luaL_checkstack() would raise here, stranding eventName, wait.mName and + // the registry reference below, so report it the way a timeout is reported + if (!lua_checkstack(L, argCount + 1)) { + lua_remove(L, argsTableIndex); + luaL_unref(L, LUA_REGISTRYINDEX, wait.mArgsRef); + lua_pushnil(L); + lua_pushstring(L, "waitForEvent: too many event arguments to return"); + return 2; + } + for (int i = 1; i <= argCount; ++i) { + lua_rawgeti(L, argsTableIndex, i); + } + lua_remove(L, argsTableIndex); + luaL_unref(L, LUA_REGISTRYINDEX, wait.mArgsRef); + return argCount; +} + +// No documentation available in wiki - internal, test-only function +// Keeps Mudlet delivering events for the given number of milliseconds: the +// sleep a spec wants to let queued work run when there is no named event to +// wait for. +int TLuaInterpreter::pumpEvents(lua_State* L) +{ + if (!qEnvironmentVariableIsSet("MUDLET_TEST_MODE")) { + lua_pushnil(L); + lua_pushstring(L, "pumpEvents: only available in test mode (set the MUDLET_TEST_MODE environment variable)"); + return 2; + } + + // The ceiling matches waitForEvent()'s: below busted's per-spec CI timeout, + // so a runaway pump fails on its own rather than taking the suite with it. + constexpr int defaultTimeoutMs = 50; + constexpr int maximumTimeoutMs = 30000; + int timeoutMs = defaultTimeoutMs; + if (!lua_isnoneornil(L, 1)) { + timeoutMs = getVerifiedInt(L, __func__, 1, "duration in milliseconds", true); + } + timeoutMs = std::clamp(timeoutMs, 0, maximumTimeoutMs); + + Host& host = getHostFromLua(L); + TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); + + // Same use-after-free waitForEvent() guards, and worse here: the pump is + // itself what delivers the zero-timer phase2 is armed on. + if (host.profileResetInProgress() || host.isClosingDown()) { + lua_pushnil(L); + lua_pushstring(L, "pumpEvents: cannot pump while the profile is being reset or Mudlet is closing"); + return 2; + } + + const QPointer<Host> pHost(&host); + ++pLuaInterpreter->mEventPumpDepth; + const auto pumpGuard = qScopeGuard([pLuaInterpreter]() { + --pLuaInterpreter->mEventPumpDepth; + }); + const bool stoppedEarly = EventLoopPump::pumpFor(timeoutMs, [&pHost]() { + return shuttingDown(pHost); + }); + + if (stoppedEarly) { + // Ran short, so whatever the caller queued may not have happened - a + // spec flushing a profile save needs to hear that, not just get true. + lua_pushnil(L); + lua_pushstring(L, "pumpEvents: stopped early, Mudlet is shutting down"); + return 2; + } + + lua_pushboolean(L, true); + return 1; +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#raiseGlobalEvent int TLuaInterpreter::raiseGlobalEvent(lua_State* L) { @@ -1599,7 +1879,7 @@ int TLuaInterpreter::setConsoleBufferSize(lua_State* L) { int s = 1; const int n = lua_gettop(L); - QString windowName; + const char* windowName = ""; if (n > 2) { windowName = WINDOW_NAME(L, s++); } @@ -1615,7 +1895,7 @@ int TLuaInterpreter::setConsoleBufferSize(lua_State* L) // The macro will have returned with a nil + error message if the windowName // was not found: - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); Host& host = getHostFromLua(L); if (useMaximum) { @@ -1671,23 +1951,39 @@ int TLuaInterpreter::setProfileIcon(lua_State* L) int TLuaInterpreter::setScript(lua_State* L) { const int n = lua_gettop(L); - int pos = 1; - QString name = getVerifiedString(L, __func__, 1, "script name"); + // The name and the code stay the Lua-owned strings anchored at stack indexes + // 1 and 2 until every check has passed: lua_error() longjmps past C++ + // destructors, so a QString built from an earlier argument would be stranded + // by a later argument's failure - see checkStringArg() + if (!checkStringArg(L, __func__, 1, "script name")) { + return lua_error(L); + } Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); if (pLuaInterpreter->reportInvalidLuaCodeParam(L, "setScript", 2)) { return lua_error(L); } - const QString luaCode{lua_tostring(L, 2)}; + int pos = 1; if (n > 2) { - pos = getVerifiedInt(L, __func__, 3, "script position"); + if (!checkIntArg(L, __func__, 3, "script position")) { + return lua_error(L); + } + pos = static_cast<int>(lua_tointeger(L, 3)); } - auto [id, message] = pLuaInterpreter->setScriptCode(name, luaCode, --pos); + int id = -1; + { + // scoped so that this failure message, and the QStrings handed to + // setScriptCode(), are all destroyed before the raise below + auto [scriptId, message] = pLuaInterpreter->setScriptCode(QString{lua_tostring(L, 1)}, QString{lua_tostring(L, 2)}, --pos); + id = scriptId; + if (id == -1) { + lua_pushfstring(L, "setScript: cannot set script (%s)", message.toUtf8().constData()); + } + } if (id == -1) { - lua_pushfstring(L, "setScript: cannot set script (%s)", message.toUtf8().constData()); return lua_error(L); } lua_pushnumber(L, id); @@ -1702,6 +1998,10 @@ int TLuaInterpreter::setStopWatchName(lua_State* L) return lua_error(L); } + if (!checkStringArg(L, __func__, 2, "stopwatch new name")) { + return lua_error(L); + } + int watchId = 0; Host& host = getHostFromLua(L); QString currentName; @@ -1712,7 +2012,7 @@ int TLuaInterpreter::setStopWatchName(lua_State* L) currentName = lua_tostring(L, 1); } - const QString newName = getVerifiedString(L, __func__, 2, "stopwatch new name"); + const QString newName{lua_tostring(L, 2)}; QPair<bool, QString> result; if (currentName.isNull()) { @@ -1758,14 +2058,14 @@ int TLuaInterpreter::setStopWatchPersistence(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTriggerStayOpen int TLuaInterpreter::setTriggerStayOpen(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { windowName = WINDOW_NAME(L, s++); } const double b = getVerifiedDouble(L, __func__, s, "number of lines"); Host& host = getHostFromLua(L); - host.getTriggerUnit()->setTriggerStayOpen(windowName, static_cast<int>(b)); + host.getTriggerUnit()->setTriggerStayOpen(QString{windowName}, static_cast<int>(b)); return 0; } @@ -1856,7 +2156,6 @@ int TLuaInterpreter::tempAnsiColorTrigger(lua_State* L) Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); - QString code; int ansiFgColor = TTrigger::scmIgnored; int ansiBgColor = TTrigger::scmIgnored; int s = 0; @@ -1923,11 +2222,7 @@ int TLuaInterpreter::tempAnsiColorTrigger(lua_State* L) } const int codeIndex = ++s; - if (lua_isstring(L, codeIndex)) { - code = QString::fromUtf8(lua_tostring(L, codeIndex)); - } else if (lua_isfunction(L, codeIndex)) { - // leave code as a null QString(), see below - } else { + if (!lua_isstring(L, codeIndex) && !lua_isfunction(L, codeIndex)) { lua_pushfstring(L, "tempAnsiColorTrigger: bad argument #%d type (code to run as a string or a function expected, got %s!)", codeIndex, luaL_typename(L, codeIndex)); return lua_error(L); } @@ -1944,6 +2239,9 @@ int TLuaInterpreter::tempAnsiColorTrigger(lua_State* L) return lua_error(L); } + // a function argument leaves this a null QString(), see below + const QString code = lua_isstring(L, codeIndex) ? QString::fromUtf8(lua_tostring(L, codeIndex)) : QString(); + const int triggerID = pLuaInterpreter->startTempColorTrigger(ansiFgColor, ansiBgColor, code, expiryCount); if (code.isNull()) { auto trigger = host.getTriggerUnit()->getTrigger(triggerID); @@ -1967,7 +2265,15 @@ int TLuaInterpreter::tempAnsiColorTrigger(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#tempAlias int TLuaInterpreter::tempAlias(lua_State* L) { - const QString regex = getVerifiedString(L, __func__, 1, "regex-type pattern"); + if (!checkStringArg(L, __func__, 1, "regex-type pattern")) { + return lua_error(L); + } + if (!lua_isstring(L, 2) && !lua_isfunction(L, 2)) { + lua_pushfstring(L, "tempAlias: bad argument #2 type (lua script as string or function expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + + const QString regex{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); @@ -1994,10 +2300,6 @@ int TLuaInterpreter::tempAlias(lua_State* L) return 1; } - if (!lua_isstring(L, 2)) { - lua_pushfstring(L, "tempAlias: bad argument #2 type (lua script as string or function expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); - } const QString script{lua_tostring(L, 2)}; lua_pushnumber(L, pLuaInterpreter->startTempAlias(regex, script)); @@ -2011,7 +2313,9 @@ int TLuaInterpreter::tempBeginOfLineTrigger(lua_State* L) TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); int triggerID; int expiryCount = -1; - const QString pattern = getVerifiedString(L, __func__, 1, "pattern"); + if (!checkStringArg(L, __func__, 1, "pattern")) { + return lua_error(L); + } if (lua_isnumber(L, 3)) { expiryCount = static_cast<int>(lua_tonumber(L, 3)); @@ -2024,9 +2328,15 @@ int TLuaInterpreter::tempBeginOfLineTrigger(lua_State* L) return lua_error(L); } + if (!lua_isstring(L, 2) && !lua_isfunction(L, 2)) { + lua_pushfstring(L, "tempBeginOfLineTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + + const QString pattern{lua_tostring(L, 1)}; if (lua_isstring(L, 2)) { triggerID = pLuaInterpreter->startTempBeginOfLineTrigger(pattern, QString(lua_tostring(L, 2)), expiryCount); - } else if (lua_isfunction(L, 2)) { + } else { triggerID = pLuaInterpreter->startTempBeginOfLineTrigger(pattern, QString(), expiryCount); auto trigger = host.getTriggerUnit()->getTrigger(triggerID); @@ -2041,9 +2351,6 @@ int TLuaInterpreter::tempBeginOfLineTrigger(lua_State* L) lua_pushlightuserdata(L, trigger); lua_pushvalue(L, 2); lua_settable(L, LUA_REGISTRYINDEX); - } else { - lua_pushfstring(L, "tempBeginOfLineTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); } lua_pushnumber(L, triggerID); @@ -2057,14 +2364,15 @@ int TLuaInterpreter::tempButton(lua_State* L) const QString cmdButtonUp = ""; const QString cmdButtonDown = ""; const QString script = ""; - QString toolbar; - QStringList nameL; - nameL << toolbar; - toolbar = getVerifiedString(L, __func__, 1, "toolbar name"); - const QString name = getVerifiedString(L, __func__, 2, "button text"); + if (!checkStringArg(L, __func__, 1, "toolbar name") || !checkStringArg(L, __func__, 2, "button text")) { + return lua_error(L); + } const int orientation = getVerifiedInt(L, __func__, 3, "orientation"); + const QString toolbar{lua_tostring(L, 1)}; + const QString name{lua_tostring(L, 2)}; + Host& host = getHostFromLua(L); TAction* pP = host.getActionUnit()->findAction(toolbar); if (!pP) { @@ -2111,17 +2419,18 @@ int TLuaInterpreter::tempButton(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#tempButtonToolbar int TLuaInterpreter::tempButtonToolbar(lua_State* L) { - QString name; const QString cmdButtonUp = ""; const QString cmdButtonDown = ""; const QString script = ""; - QStringList nameL; - nameL << name; - name = getVerifiedString(L, __func__, 1, "name"); + if (!checkStringArg(L, __func__, 1, "name")) { + return lua_error(L); + } int location = getVerifiedInt(L, __func__, 2, "location"); const int orientation = getVerifiedInt(L, __func__, 3, "orientation"); + const QString name{lua_tostring(L, 1)}; + if (location > 0) { location++; } @@ -2136,8 +2445,6 @@ int TLuaInterpreter::tempButtonToolbar(lua_State* L) pT = new TAction(name, &host); pT->setCommandButtonUp(cmdButtonUp); - QStringList nl; - nl << name; pT->setName(name); pT->setCommandButtonUp(cmdButtonUp); @@ -2269,8 +2576,9 @@ int TLuaInterpreter::tempColorTrigger(lua_State* L) int TLuaInterpreter::tempComplexRegexTrigger(lua_State* L) { Host& host = getHostFromLua(L); - const QString triggerName = getVerifiedString(L, __func__, 1, "trigger name create or add to"); - const QString pattern = getVerifiedString(L, __func__, 2, "regex pattern to match"); + if (!checkStringArg(L, __func__, 1, "trigger name create or add to") || !checkStringArg(L, __func__, 2, "regex pattern to match")) { + return lua_error(L); + } if (!lua_isstring(L, 3) && !lua_isfunction(L, 3)) { lua_pushfstring(L, "tempComplexRegexTrigger: bad argument #3 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 3)); @@ -2298,6 +2606,22 @@ int TLuaInterpreter::tempComplexRegexTrigger(lua_State* L) const int fireLength = getVerifiedInt(L, __func__, 12, "fire length"); const int lineDelta = getVerifiedInt(L, __func__, 13, "line delta"); + int expiryCount = -1; + + if (lua_isnumber(L, 14)) { + expiryCount = static_cast<int>(lua_tonumber(L, 14)); + + if (expiryCount < 1) { + return warnArgumentValue(L, __func__, qsl("trigger expiration count must be nil or greater than zero, got %1").arg(expiryCount)); + } + } else if (!lua_isnoneornil(L, 14)) { + lua_pushfstring(L, "tempComplexRegexTrigger: bad argument #14 value (trigger expiration count must be nil or a number, got %s!)", luaL_typename(L, 14)); + return lua_error(L); + } + + const QString triggerName{lua_tostring(L, 1)}; + const QString pattern{lua_tostring(L, 2)}; + bool colorTrigger; QString fgColor; if (lua_isnumber(L, 5)) { @@ -2347,19 +2671,6 @@ int TLuaInterpreter::tempComplexRegexTrigger(lua_State* L) playSound = false; } - int expiryCount = -1; - - if (lua_isnumber(L, 14)) { - expiryCount = static_cast<int>(lua_tonumber(L, 14)); - - if (expiryCount < 1) { - return warnArgumentValue(L, __func__, qsl("trigger expiration count must be nil or greater than zero, got %1").arg(expiryCount)); - } - } else if (!lua_isnoneornil(L, 14)) { - lua_pushfstring(L, "tempComplexRegexTrigger: bad argument #14 value (trigger expiration count must be nil or a number, got %s!)", luaL_typename(L, 14)); - return lua_error(L); - } - QStringList patterns; QList<int> propertyList; TTrigger* pP = host.getTriggerUnit()->findTrigger(triggerName); @@ -2418,7 +2729,9 @@ int TLuaInterpreter::tempExactMatchTrigger(lua_State* L) TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); int triggerID; int expiryCount = -1; - const QString exactMatchPattern = getVerifiedString(L, __func__, 1, "exact match pattern"); + if (!checkStringArg(L, __func__, 1, "exact match pattern")) { + return lua_error(L); + } if (lua_isnumber(L, 3)) { expiryCount = static_cast<int>(lua_tonumber(L, 3)); @@ -2431,9 +2744,15 @@ int TLuaInterpreter::tempExactMatchTrigger(lua_State* L) return lua_error(L); } + if (!lua_isstring(L, 2) && !lua_isfunction(L, 2)) { + lua_pushfstring(L, "tempExactMatchTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + + const QString exactMatchPattern{lua_tostring(L, 1)}; if (lua_isstring(L, 2)) { triggerID = pLuaInterpreter->startTempExactMatchTrigger(exactMatchPattern, QString(lua_tostring(L, 2)), expiryCount); - } else if (lua_isfunction(L, 2)) { + } else { triggerID = pLuaInterpreter->startTempExactMatchTrigger(exactMatchPattern, QString(), expiryCount); auto trigger = host.getTriggerUnit()->getTrigger(triggerID); @@ -2448,9 +2767,6 @@ int TLuaInterpreter::tempExactMatchTrigger(lua_State* L) lua_pushlightuserdata(L, trigger); lua_pushvalue(L, 2); lua_settable(L, LUA_REGISTRYINDEX); - } else { - lua_pushfstring(L, "tempExactMatchTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); } lua_pushnumber(L, triggerID); @@ -2593,7 +2909,9 @@ int TLuaInterpreter::tempRegexTrigger(lua_State* L) TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); int triggerID; int expiryCount = -1; - const QString regexPattern = getVerifiedString(L, __func__, 1, "regex pattern"); + if (!checkStringArg(L, __func__, 1, "regex pattern")) { + return lua_error(L); + } if (lua_isnumber(L, 3)) { expiryCount = static_cast<int>(lua_tonumber(L, 3)); @@ -2606,9 +2924,15 @@ int TLuaInterpreter::tempRegexTrigger(lua_State* L) return lua_error(L); } + if (!lua_isstring(L, 2) && !lua_isfunction(L, 2)) { + lua_pushfstring(L, "tempRegexTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + + const QString regexPattern{lua_tostring(L, 1)}; if (lua_isstring(L, 2)) { triggerID = pLuaInterpreter->startTempRegexTrigger(regexPattern, lua_tostring(L, 2), expiryCount); - } else if (lua_isfunction(L, 2)) { + } else { triggerID = pLuaInterpreter->startTempRegexTrigger(regexPattern, QString(), expiryCount); auto trigger = host.getTriggerUnit()->getTrigger(triggerID); @@ -2623,9 +2947,6 @@ int TLuaInterpreter::tempRegexTrigger(lua_State* L) lua_pushlightuserdata(L, trigger); lua_pushvalue(L, 2); lua_settable(L, LUA_REGISTRYINDEX); - } else { - lua_pushfstring(L, "tempRegexTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); } lua_pushnumber(L, triggerID); @@ -2637,6 +2958,10 @@ int TLuaInterpreter::tempTimer(lua_State* L) { bool repeating{}; const double time = getVerifiedDouble(L, __func__, 1, "time in seconds {maybe decimal}"); + if (!timerDelayFits(time)) { + lua_pushfstring(L, "tempTimer: bad argument #1 value (time in seconds must be at least 0 and less than 86400, got %f)", time); + return lua_error(L); + } const int n = lua_gettop(L); Host& host = getHostFromLua(L); @@ -2668,10 +2993,13 @@ int TLuaInterpreter::tempTimer(lua_State* L) return 1; } - const QString luaCode = getVerifiedString(L, __func__, 2, "script or function name"); + if (!checkStringArg(L, __func__, 2, "script or function name")) { + return lua_error(L); + } if (n > 2) { repeating = getVerifiedBool(L, __func__, 3, "repeating", true); } + const QString luaCode{lua_tostring(L, 2)}; QPair<int, QString> const result = pLuaInterpreter->startTempTimer(time, luaCode, repeating); lua_pushnumber(L, result.first); if (result.first == -1) { @@ -2689,7 +3017,9 @@ int TLuaInterpreter::tempTrigger(lua_State* L) TLuaInterpreter* pLuaInterpreter = host.getLuaInterpreter(); int triggerID; int expiryCount = -1; - const QString substringPattern = getVerifiedString(L, __func__, 1, "substring pattern"); + if (!checkStringArg(L, __func__, 1, "substring pattern")) { + return lua_error(L); + } if (lua_isnumber(L, 3)) { expiryCount = static_cast<int>(lua_tonumber(L, 3)); @@ -2702,9 +3032,15 @@ int TLuaInterpreter::tempTrigger(lua_State* L) return lua_error(L); } + if (!lua_isstring(L, 2) && !lua_isfunction(L, 2)) { + lua_pushfstring(L, "tempTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + + const QString substringPattern{lua_tostring(L, 1)}; if (lua_isstring(L, 2)) { triggerID = pLuaInterpreter->startTempTrigger(substringPattern, QString(lua_tostring(L, 2)), expiryCount); - } else if (lua_isfunction(L, 2)) { + } else { triggerID = pLuaInterpreter->startTempTrigger(substringPattern, QString(), expiryCount); auto trigger = host.getTriggerUnit()->getTrigger(triggerID); @@ -2719,9 +3055,6 @@ int TLuaInterpreter::tempTrigger(lua_State* L) lua_pushlightuserdata(L, trigger); lua_pushvalue(L, 2); lua_settable(L, LUA_REGISTRYINDEX); - } else { - lua_pushfstring(L, "tempTrigger: bad argument #2 type (code to run as a string or a function expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); } lua_pushnumber(L, triggerID); @@ -2799,13 +3132,16 @@ int TLuaInterpreter::getProfiles(lua_State* L) int TLuaInterpreter::loadProfile(lua_State* L) { auto& hostManager = mudlet::self()->getHostManager(); - const QString requestedName = getVerifiedString(L, __func__, 1, "profile name"); + if (!checkStringArg(L, __func__, 1, "profile name")) { + return lua_error(L); + } bool offline = false; if (lua_gettop(L) > 1) { offline = getVerifiedBool(L, __func__, 2, "offline mode", true); } + const QString requestedName{lua_tostring(L, 1)}; if (requestedName.isEmpty()) { lua_pushnil(L); lua_pushstring(L, "loadProfile: profile name cannot be empty"); diff --git a/src/TLuaInterpreterNetworking.cpp b/src/TLuaInterpreterNetworking.cpp index 580ed8a98..1777913f5 100644 --- a/src/TLuaInterpreterNetworking.cpp +++ b/src/TLuaInterpreterNetworking.cpp @@ -76,7 +76,9 @@ int TLuaInterpreter::connectToServer(lua_State* L) bool isToSaveToProfile = false; Host& host = getHostFromLua(L); - const QString url = getVerifiedString(L, __func__, 1, "url"); + if (!checkStringArg(L, __func__, 1, "url")) { + return lua_error(L); + } if (!lua_isnoneornil(L, 2)) { port = getVerifiedInt(L, __func__, 2, "port number {default = 23}", true); @@ -90,6 +92,8 @@ int TLuaInterpreter::connectToServer(lua_State* L) isToSaveToProfile = getVerifiedBool(L, __func__, 3, "save host name and port number", true); } + const QString url{lua_tostring(L, 1)}; + if (isToSaveToProfile) { QPair<bool, QString> result = host.writeProfileData(QLatin1String("url"), url); if (!result.first) { @@ -120,8 +124,12 @@ int TLuaInterpreter::disconnect(lua_State* L) int TLuaInterpreter::downloadFile(lua_State* L) { Host& host = getHostFromLua(L); - const QString localFile = getVerifiedString(L, __func__, 1, "local filename"); - const QString urlString = getVerifiedString(L, __func__, 2, "remote url"); + if (!checkStringArg(L, __func__, 1, "local filename") || !checkStringArg(L, __func__, 2, "remote url")) { + return lua_error(L); + } + + const QString localFile{lua_tostring(L, 1)}; + const QString urlString{lua_tostring(L, 2)}; const QUrl url = QUrl::fromUserInput(urlString); if (!url.isValid()) { return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1").arg(url.errorString())); @@ -297,17 +305,19 @@ int TLuaInterpreter::sendATCP(lua_State* L) { Host& host = getHostFromLua(L); if (!lua_isstring(L, 1)) { - lua_pushfstring(L, "sendATCP: bad argument #1 type (message as string expected, got %1!)", luaL_typename(L, 1)); + lua_pushfstring(L, "sendATCP: bad argument #1 type (message as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } + const bool hasWhat = lua_gettop(L) > 1; + if (hasWhat && !lua_isstring(L, 2)) { + lua_pushfstring(L, "sendATCP: bad argument #2 type (what as string is optional, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + const std::string msg = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 1)); std::string what; - if (lua_gettop(L) > 1) { - if (!lua_isstring(L, 2)) { - lua_pushfstring(L, "sendATCP: bad argument #2 type (what as string is optional, got %1!)", luaL_typename(L, 2)); - return lua_error(L); - } + if (hasWhat) { what = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 2)); } @@ -346,17 +356,19 @@ int TLuaInterpreter::sendGMCP(lua_State* L) { Host& host = getHostFromLua(L); if (!lua_isstring(L, 1)) { - lua_pushfstring(L, "sendGMCP: bad argument #1 type (message as string expected, got %1!)", luaL_typename(L, 1)); + lua_pushfstring(L, "sendGMCP: bad argument #1 type (message as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } + const bool hasWhat = lua_gettop(L) > 1; + if (hasWhat && !lua_isstring(L, 2)) { + lua_pushfstring(L, "sendGMCP: bad argument #2 type (what as string is optional, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + const std::string msg = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 1)); std::string what; - if (lua_gettop(L) > 1) { - if (!lua_isstring(L, 2)) { - lua_pushfstring(L, "sendGMCP: bad argument #2 type (what as string is optional, got %1!)", luaL_typename(L, 2)); - return lua_error(L); - } + if (hasWhat) { what = host.mTelnet.encodeAndCookBytes(lua_tostring(L, 2)); } @@ -393,8 +405,12 @@ int TLuaInterpreter::sendGMCP(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#sendIrc int TLuaInterpreter::sendIrc(lua_State* L) { - const QString target = getVerifiedString(L, __func__, 1, "target"); - const QString msg = getVerifiedString(L, __func__, 2, "message"); + if (!checkStringArg(L, __func__, 1, "target") || !checkStringArg(L, __func__, 2, "message")) { + return lua_error(L); + } + + const QString target{lua_tostring(L, 1)}; + const QString msg{lua_tostring(L, 2)}; Host* pHost = &getHostFromLua(L); if (!pHost->mpDlgIRC) { @@ -585,9 +601,11 @@ int TLuaInterpreter::setIrcServer(lua_State* L) const int args = lua_gettop(L); int secure = false; int port = 6667; - QString password; - const std::string addr = getVerifiedString(L, __func__, 1, "hostname").toStdString(); - if (addr.empty()) { + if (!checkStringArg(L, __func__, 1, "hostname")) { + return lua_error(L); + } + const char* hostName = lua_tostring(L, 1); + if (*hostName == '\0') { return warnArgumentValue(L, __func__, "hostname must not be empty"); } if (!lua_isnoneornil(L, 2)) { @@ -599,12 +617,17 @@ int TLuaInterpreter::setIrcServer(lua_State* L) if (args > 2) { secure = getVerifiedBool(L, __func__, 3, "secure {default = false}", true); } + if (args > 3 && !checkStringArg(L, __func__, 4, "server password", true)) { + return lua_error(L); + } + + QString password; if (args > 3) { - password = getVerifiedString(L, __func__, 4, "server password", true); + password = lua_tostring(L, 4); } Host* pHost = &getHostFromLua(L); - QPair<bool, QString> result = dlgIRC::writeIrcHostName(pHost, QString::fromStdString(addr)); + QPair<bool, QString> result = dlgIRC::writeIrcHostName(pHost, QString::fromUtf8(hostName)); if (!result.first) { return warnArgumentValue(L, __func__, qsl("unable to save hostname, reason: %1").arg(result.second)); } @@ -629,40 +652,69 @@ int TLuaInterpreter::setIrcServer(lua_State* L) return 2; } +// Validates the optional headers table at Lua stack index `index`: it must be +// absent/nil, or a table whose keys and values are all strings, otherwise a Lua +// error is raised. This has to run before any QUrl or QNetworkRequest is +// constructed, because lua_error() longjmps past C++ destructors and would +// otherwise leak those heap-owning Qt objects. +/*static*/ void TLuaInterpreter::validateHttpHeaders(lua_State* L, const int index, const char* functionName) +{ + if (!lua_istable(L, index)) { + if (!lua_isnoneornil(L, index)) { + lua_pushfstring(L, "%s: bad argument #%d type (headers as a table expected, got %s!)", functionName, index, luaL_typename(L, index)); + lua_error(L); + } + return; + } + lua_pushnil(L); + while (lua_next(L, index) != 0) { + // key at index -2 and value at index -1 + if (lua_type(L, -1) != LUA_TSTRING || lua_type(L, -2) != LUA_TSTRING) { + lua_pushfstring(L, + "%s: bad argument #%d type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))", + functionName, + index, + luaL_typename(L, -2), + luaL_typename(L, -1)); + lua_error(L); + } + // removes value, but keeps key for next iteration + lua_pop(L, 1); + } +} + +// Applies the already-validated headers table at `index` to `request`. Call +// validateHttpHeaders() first: this assumes every key/value is a string and +// never raises a Lua error, so it is safe to run with a live QNetworkRequest. +/*static*/ void TLuaInterpreter::applyHttpHeaders(lua_State* L, const int index, QNetworkRequest& request) +{ + if (!lua_istable(L, index)) { + return; + } + lua_pushnil(L); + while (lua_next(L, index) != 0) { + request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1))); + lua_pop(L, 1); + } +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getHTTP int TLuaInterpreter::getHTTP(lua_State* L) { auto& host = getHostFromLua(L); - const QString urlString = getVerifiedString(L, __func__, 1, "remote url"); - const QUrl url = QUrl::fromUserInput(urlString); + if (!checkStringArg(L, __func__, 1, "remote url")) { + return lua_error(L); + } + validateHttpHeaders(L, 2, __func__); + + const QUrl url = QUrl::fromUserInput(QString{lua_tostring(L, 1)}); if (!url.isValid()) { return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1").arg(url.errorString())); } QNetworkRequest request = QNetworkRequest(url); mudlet::self()->setNetworkRequestDefaults(url, request); - - if (!lua_istable(L, 2) && !lua_isnoneornil(L, 2)) { - lua_pushfstring(L, "getHTTP: bad argument #2 type (headers as a table expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); - } - if (lua_istable(L, 2)) { - lua_pushnil(L); - while (lua_next(L, 2) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING && lua_type(L, -2) == LUA_TSTRING) { - request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1))); - } else { - lua_pushfstring(L, - "getHTTP: bad argument #2 type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))", - luaL_typename(L, -2), - luaL_typename(L, -1)); - return lua_error(L); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - } + applyHttpHeaders(L, 2, request); host.updateProxySettings(host.mLuaInterpreter.mpFileDownloader); QNetworkReply* reply = host.mLuaInterpreter.mpFileDownloader->get(request); @@ -679,49 +731,32 @@ int TLuaInterpreter::getHTTP(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#postHTTP int TLuaInterpreter::postHTTP(lua_State* L) { - return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PostOperation, qsl("post")); + return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PostOperation, "post"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#putHTTP int TLuaInterpreter::putHTTP(lua_State* L) { - return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PutOperation, qsl("put")); + return performHttpRequest(L, __func__, 0, QNetworkAccessManager::PutOperation, "put"); } // Documentation: https://wiki.mudlet.org/w/Manual:Networking_Functions#deleteHTTP int TLuaInterpreter::deleteHTTP(lua_State* L) { auto& host = getHostFromLua(L); - const QString urlString = getVerifiedString(L, __func__, 1, "remote url"); - const QUrl url = QUrl::fromUserInput(urlString); + if (!checkStringArg(L, __func__, 1, "remote url")) { + return lua_error(L); + } + validateHttpHeaders(L, 2, __func__); + + const QUrl url = QUrl::fromUserInput(QString{lua_tostring(L, 1)}); if (!url.isValid()) { return warnArgumentValue(L, __func__, qsl("url is invalid, reason: %1").arg(url.errorString())); } QNetworkRequest request = QNetworkRequest(url); mudlet::self()->setNetworkRequestDefaults(url, request); - - if (!lua_istable(L, 2) && !lua_isnoneornil(L, 2)) { - lua_pushfstring(L, "deleteHTTP: bad argument #2 type (headers as a table expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); - } - if (lua_istable(L, 2)) { - lua_pushnil(L); - while (lua_next(L, 2) != 0) { - // key at index -2 and value at index -1 - if (lua_type(L, -1) == LUA_TSTRING && lua_type(L, -2) == LUA_TSTRING) { - request.setRawHeader(QByteArray(lua_tostring(L, -2)), QByteArray(lua_tostring(L, -1))); - } else { - lua_pushfstring(L, - "deleteHTTP: bad argument #2 type (custom headers must be strings, got header: %s (should be string) and value: %s (should be string))", - luaL_typename(L, -2), - luaL_typename(L, -1)); - return lua_error(L); - } - // removes value, but keeps key for next iteration - lua_pop(L, 1); - } - } + applyHttpHeaders(L, 2, request); host.updateProxySettings(host.mLuaInterpreter.mpFileDownloader); QNetworkReply* reply = host.mLuaInterpreter.mpFileDownloader->deleteResource(request); @@ -738,6 +773,9 @@ int TLuaInterpreter::deleteHTTP(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#customHTTP int TLuaInterpreter::customHTTP(lua_State* L) { - auto customMethod = getVerifiedString(L, __func__, 1, "http method"); - return performHttpRequest(L, __func__, 1, QNetworkAccessManager::CustomOperation, customMethod); + if (!checkStringArg(L, __func__, 1, "http method")) { + return lua_error(L); + } + + return performHttpRequest(L, __func__, 1, QNetworkAccessManager::CustomOperation, lua_tostring(L, 1)); } diff --git a/src/TLuaInterpreterTextToSpeech.cpp b/src/TLuaInterpreterTextToSpeech.cpp index ae30eb35f..27d75158b 100644 --- a/src/TLuaInterpreterTextToSpeech.cpp +++ b/src/TLuaInterpreterTextToSpeech.cpp @@ -59,6 +59,7 @@ #include "glwidget_integration.h" #endif +#include <chrono> #include <limits> #include <math.h> @@ -67,6 +68,7 @@ #include <QDesktopServices> #include <QFileInfo> #include <QMovie> +#include <QTimer> #include <QVector> #ifdef QT_TEXTTOSPEECH_LIB #include <QTextToSpeech> @@ -79,9 +81,34 @@ bool bSpeechBuilt; bool bSpeechQueueing; int speechState = QTextToSpeech::State::Ready; QString speechCurrent; +// Whether a ttsSpeechStarted has been raised for what speechCurrent holds. The +// events are raised off the engine's state edges, and an engine that is already +// speaking has no edge to report when it is given something else to say. +static bool speechStartAnnounced = false; +// Set while the utterance ttsSpeak() last asked for was started over one that +// was still being spoken. Every engine stops the running utterance inside say(), +// and the Ready that reports that has to be told apart from the engine going +// idle - see ttsSpeak(). +static bool speechInterrupting = false; +// How long an engine is given to start the utterance that interrupted another +// before a Ready held back on its account is taken at face value after all. +static constexpr std::chrono::milliseconds scmInterruptedSpeechGrace{250}; static const QTextToSpeech::State TEXT_TO_SPEECH_ERROR_STATE = QTextToSpeech::State::Error; +// ttsStateChanged() raises this same event whenever the engine reports a state +// edge into Speaking; ttsSpeak() raises it through here for the utterance an +// already-speaking engine starts without any such edge. +static void raiseSpeechStartedEvent(const QString& text) +{ + TEvent event{}; + event.mArgumentList.append(QLatin1String("ttsSpeechStarted")); + event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + event.mArgumentList.append(text); + event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mudlet::self()->getHostManager().postInterHostEvent(nullptr, event, true); +} + // No documentation available in wiki - internal function void TLuaInterpreter::ttsBuild() { @@ -89,7 +116,18 @@ void TLuaInterpreter::ttsBuild() return; } - speechUnit = new QTextToSpeech(); + // Under automated tests always request Qt's deterministic "mock" engine and + // never fall back to a real backend, which would speak aloud and make specs + // host-dependent. When the mock plugin is absent Qt leaves the engine in the + // Error state with no voices, so the TTS specs skip (or fail where the mock + // is mandatory) instead of exercising a developer's real speech engine. This + // also makes a non-empty voice list a reliable proof that the mock was + // selected. Outside test mode the default engine is built exactly as before. + if (qEnvironmentVariableIsSet("MUDLET_TEST_MODE")) { + speechUnit = new QTextToSpeech(qsl("mock")); + } else { + speechUnit = new QTextToSpeech(); + } bSpeechBuilt = true; bSpeechQueueing = false; @@ -103,6 +141,10 @@ int TLuaInterpreter::ttsSkip(lua_State* L) Q_UNUSED(L) TLuaInterpreter::ttsBuild(); + // An explicit stop ends whatever is being spoken outright, so the Ready it + // produces is the engine going idle and must drain the queue as it always + // has - it is not the interruption ttsSpeak() has to defend against. + speechInterrupting = false; speechUnit->stop(); return 0; @@ -111,12 +153,22 @@ int TLuaInterpreter::ttsSkip(lua_State* L) // No documentation available in wiki - internal function void TLuaInterpreter::ttsStateChanged(QTextToSpeech::State state) { + // ttsSpeak() announces an utterance itself when the engine was already + // speaking, because there is then no state edge to announce it. An engine + // that gets round to reporting the interruption afterwards ends up here + // with an edge back into Speaking for that same utterance, which would tell + // a script it started twice. + const bool alreadyAnnounced = (state == QTextToSpeech::State::Speaking && speechStartAnnounced); + if (state != speechState) { speechState = state; TEvent event{}; switch (state) { case QTextToSpeech::State::Paused: event.mArgumentList.append(QLatin1String("ttsSpeechPaused")); + // Resuming has always announced the utterance again, so let it: + // being paused is the end of what was announced before. + speechStartAnnounced = false; break; case QTextToSpeech::State::Speaking: event.mArgumentList.append(QLatin1String("ttsSpeechStarted")); @@ -140,9 +192,37 @@ void TLuaInterpreter::ttsStateChanged(QTextToSpeech::State state) if (state == QTextToSpeech::Speaking) { event.mArgumentList.append(speechCurrent); event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + // The engine has taken up what it was last given, so ttsSpeak() has + // nothing left to announce and any interruption is over. + speechStartAnnounced = true; + speechInterrupting = false; } - mudlet::self()->getHostManager().postInterHostEvent(NULL, event, true); + if (!alreadyAnnounced) { + mudlet::self()->getHostManager().postInterHostEvent(NULL, event, true); + } + } + + if (state == QTextToSpeech::State::Ready && speechInterrupting) { + // Not the engine falling idle but the utterance ttsSpeak() spoke over + // ending inside say(). Draining here would speak a queued line on top of + // the one the script has just asked for, and that one is then never + // heard at all (#9659). The engine reports Speaking for the requested + // utterance next, which clears this above. + speechInterrupting = false; + bSpeechQueueing = false; + // Unless it does not: an engine that rejects an utterance outright, or + // that replaces one without ever reporting a state change, leaves this + // as the last word on the matter, and a queue waiting on a Ready that + // is never coming waits forever. Look again once the engine has had its + // chance to start speaking - if it is still idle, that Ready did mean + // idle and the queue is free to go. + QTimer::singleShot(scmInterruptedSpeechGrace, qApp, []() { + if (!speechUnit.isNull() && speechUnit->state() == QTextToSpeech::State::Ready && !speechQueue.isEmpty()) { + TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Ready); + } + }); + return; } if (state != QTextToSpeech::State::Ready || speechQueue.empty()) { @@ -150,11 +230,13 @@ void TLuaInterpreter::ttsStateChanged(QTextToSpeech::State state) return; } - QString textToSay; - textToSay = speechQueue.takeFirst(); + const QString textToSay = speechQueue.takeFirst(); - speechUnit->say(textToSay); + // recorded before say() because the engine can switch to Speaking inside + // that call, and this function reports speechCurrent with the event speechCurrent = textToSay; + speechStartAnnounced = false; + speechUnit->say(textToSay); return; } @@ -168,7 +250,7 @@ int TLuaInterpreter::ttsClearQueue(lua_State* L) int index = getVerifiedInt(L, __func__, 1, "index"); index--; if (index < 0 || index >= speechQueue.size()) { - return warnArgumentValue(L, __func__, qsl("index %1 out of bounds for queue size %2").arg(index + 1, speechQueue.size())); + return warnArgumentValue(L, __func__, qsl("index %1 out of bounds for queue size %2").arg(index + 1).arg(speechQueue.size())); } speechQueue.remove(index); @@ -220,7 +302,7 @@ int TLuaInterpreter::ttsGetQueue(lua_State* L) if (lua_gettop(L) > 0) { int index = getVerifiedInt(L, __func__, 1, "index"); index--; - if (index < 0 || index > speechQueue.size()) { + if (index < 0 || index >= speechQueue.size()) { lua_pushboolean(L, false); return 1; } @@ -311,10 +393,22 @@ int TLuaInterpreter::ttsPause(lua_State* L) int TLuaInterpreter::ttsQueue(lua_State* L) { TLuaInterpreter::ttsBuild(); - QString inputText = getVerifiedString(L, __func__, 1, "input").trimmed(); - if (inputText.isEmpty()) { // there's nothing more to say. discussion: https://github.com/Mudlet/Mudlet/issues/4688 - return warnArgumentValue(L, __func__, qsl("skipped empty text to speak (TTS)")); + if (!checkStringArg(L, __func__, 1, "input")) { + return lua_error(L); } + // the empty-input refusal has to stay ahead of the argument #2 check, as it + // did before, or ttsQueue("", <bad index>) would raise instead of returning + // nil and a message + { + const QString trimmedText = QString{lua_tostring(L, 1)}.trimmed(); + if (trimmedText.isEmpty()) { // there's nothing more to say. discussion: https://github.com/Mudlet/Mudlet/issues/4688 + return warnArgumentValue(L, __func__, qsl("skipped empty text to speak (TTS)")); + } + } + if (lua_gettop(L) > 1 && !checkIntArg(L, __func__, 2, "index")) { + return lua_error(L); + } + QString inputText = QString{lua_tostring(L, 1)}.trimmed(); std::vector<QString> const dontSpeak = {"<", ">", "<", ">"}; // discussion: https://github.com/Mudlet/Mudlet/issues/4689 for (const QString& dropThis : dontSpeak) { @@ -329,7 +423,7 @@ int TLuaInterpreter::ttsQueue(lua_State* L) int index; if (lua_gettop(L) > 1) { - index = getVerifiedInt(L, __func__, 2, "index"); + index = static_cast<int>(lua_tointeger(L, 2)); index--; if (index < 0) { index = 0; @@ -355,6 +449,9 @@ int TLuaInterpreter::ttsQueue(lua_State* L) if (speechQueue.size() == 1 && speechUnit->state() == QTextToSpeech::State::Ready && !bSpeechQueueing) { bSpeechQueueing = true; + // The engine says it is idle, so nothing is left of any utterance an + // earlier ttsSpeak() interrupted and this queued line is free to start. + speechInterrupting = false; TLuaInterpreter::ttsStateChanged(speechUnit->state()); } @@ -392,8 +489,24 @@ int TLuaInterpreter::ttsSpeak(lua_State* L) } } - speechUnit->say(textToSay); + // recorded before say() because the engine can switch to Speaking inside + // that call, and ttsStateChanged() reports speechCurrent with the event speechCurrent = textToSay; + speechStartAnnounced = false; + // Every engine stops what it is saying inside say() and reports Ready for + // it, some during the call and some shortly after. That Ready says the + // interrupted utterance ended, not that the engine has nothing to do, so + // ttsStateChanged() must not drain the queue over the top of this one. + speechInterrupting = (speechUnit->state() == QTextToSpeech::State::Speaking); + speechUnit->say(textToSay); + + // An engine that was already speaking stays in the Speaking state through + // all of that, and the events are raised off state edges - so without this + // nothing tells a script that what is being spoken has changed (#9659). + if (!speechStartAnnounced && speechUnit->state() == QTextToSpeech::State::Speaking) { + speechStartAnnounced = true; + raiseSpeechStartedEvent(textToSay); + } return 0; } @@ -511,7 +624,6 @@ int TLuaInterpreter::ttsSetVoiceByName(lua_State* L) for (const auto& voice : speechVoices) { if (voice.name() == nextVoice) { speechUnit->setVoice(voice); - lua_pushboolean(L, true); TEvent event{}; event.mArgumentList.append(QLatin1String("ttsVoiceChanged")); @@ -520,6 +632,7 @@ int TLuaInterpreter::ttsSetVoiceByName(lua_State* L) event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); mudlet::self()->getHostManager().postInterHostEvent(NULL, event, true); + lua_pushboolean(L, true); return 1; } } diff --git a/src/TLuaInterpreterUI.cpp b/src/TLuaInterpreterUI.cpp index 5aefdafb0..a28704d4b 100644 --- a/src/TLuaInterpreterUI.cpp +++ b/src/TLuaInterpreterUI.cpp @@ -59,6 +59,7 @@ #include "glwidget_integration.h" #endif +#include <array> #include <limits> #include <math.h> @@ -164,17 +165,20 @@ static bool isMain(const QString& name) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#addCommandLineMenuEvent int TLuaInterpreter::addCommandLineMenuEvent(lua_State* L) { - int args = 1; const int argsCount = lua_gettop(L); + const bool hasCommandLineName = (argsCount >= 3); + const int menuLabelPos = hasCommandLineName ? 2 : 1; - QString commandLineName; - if (argsCount >= 3) { - commandLineName = getVerifiedString(L, __func__, args++, "command line name"); - } else { - commandLineName = qsl("main"); + if (hasCommandLineName && !checkStringArg(L, __func__, 1, "command line name")) { + return lua_error(L); } - auto menuLabel = getVerifiedString(L, __func__, args++, "menu label"); - auto eventName = getVerifiedString(L, __func__, args++, "event name"); + if (!checkStringArg(L, __func__, menuLabelPos, "menu label") || !checkStringArg(L, __func__, menuLabelPos + 1, "event name")) { + return lua_error(L); + } + + const QString commandLineName = hasCommandLineName ? QString{lua_tostring(L, 1)} : qsl("main"); + const QString menuLabel{lua_tostring(L, menuLabelPos)}; + const QString eventName{lua_tostring(L, menuLabelPos + 1)}; const auto& commandline = COMMANDLINE(L, commandLineName); commandline->contextMenuItems.insert(menuLabel, eventName); @@ -187,13 +191,19 @@ int TLuaInterpreter::addCommandLineMenuEvent(lua_State* L) int TLuaInterpreter::addMouseEvent(lua_State* L) { Host& host = getHostFromLua(L); - QStringList actionInfo; - const QString uniqueName = getVerifiedString(L, __func__, 1, "uniquename"); - if (host.mConsoleActions.contains(uniqueName)) { + if (!checkStringArg(L, __func__, 1, "uniquename")) { + return lua_error(L); + } + if (const QString uniqueName{lua_tostring(L, 1)}; host.mConsoleActions.contains(uniqueName)) { return warnArgumentValue(L, __func__, qsl("mouse event '%1' already exists").arg(uniqueName)); } + if (!checkStringArg(L, __func__, 2, "event name", false)) { + return lua_error(L); + } - actionInfo << getVerifiedString(L, __func__, 2, "event name", false); + const QString uniqueName{lua_tostring(L, 1)}; + QStringList actionInfo; + actionInfo << QString{lua_tostring(L, 2)}; // Display name if (!lua_isstring(L, 3)) { @@ -234,7 +244,12 @@ int TLuaInterpreter::calcFontSize(lua_State* L) // font name and size are passed in as arguments if (lua_gettop(L) == 2) { - auto font = QFont(getVerifiedString(L, __func__, 2, "font name"), getVerifiedInt(L, __func__, 1, "font size"), QFont::Normal); + // hoisted because the order the two QFont arguments were evaluated in is + // unspecified, so which failure got reported was up to the compiler + if (!checkIntArg(L, __func__, 1, "font size") || !checkStringArg(L, __func__, 2, "font name")) { + return lua_error(L); + } + auto font = QFont(QString{lua_tostring(L, 2)}, static_cast<int>(lua_tointeger(L, 1)), QFont::Normal); auto fontMetrics = QFontMetrics(font); size = QSize(fontMetrics.averageCharWidth(), fontMetrics.height()); @@ -308,29 +323,23 @@ int TLuaInterpreter::createBuffer(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#createCommandLine int TLuaInterpreter::createCommandLine(lua_State* L) { - QString windowName = QLatin1String("main"); const int n = lua_gettop(L); int counter = 1; + const bool hasParentWindow = (n > 5); - if (n > 5) { + if (hasParentWindow) { if (lua_type(L, 1) != LUA_TSTRING) { lua_pushfstring(L, "createCommandLine: bad argument #1 type (parent window name as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } - windowName = lua_tostring(L, 1); counter++; - if (isMain(windowName)) { - // createCommandLine only accepts the empty name as the main window - windowName.clear(); - } } - if (lua_type(L, counter) != LUA_TSTRING) { - lua_pushfstring(L, "createCommandLine: bad argument #%d type (commandLine name as string expected, got %s!)", counter, luaL_typename(L, counter)); + const int commandLineNamePos = counter++; + if (lua_type(L, commandLineNamePos) != LUA_TSTRING) { + lua_pushfstring(L, "createCommandLine: bad argument #%d type (commandLine name as string expected, got %s!)", commandLineNamePos, luaL_typename(L, commandLineNamePos)); return lua_error(L); } - const QString commandLineName{lua_tostring(L, counter)}; - counter++; const int x = getVerifiedInt(L, __func__, counter, "commandline x-coordinate"); counter++; const int y = getVerifiedInt(L, __func__, counter, "commandline y-coordinate"); @@ -340,6 +349,16 @@ int TLuaInterpreter::createCommandLine(lua_State* L) const int height = getVerifiedInt(L, __func__, counter, "commandline height"); counter++; + QString windowName = qsl("main"); + if (hasParentWindow) { + windowName = lua_tostring(L, 1); + if (isMain(windowName)) { + // createCommandLine only accepts the empty name as the main window + windowName.clear(); + } + } + const QString commandLineName{lua_tostring(L, commandLineNamePos)}; + const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->createCommandLine(windowName, commandLineName, x, y, width, height); !success) { return warnArgumentValue(L, __func__, message); @@ -352,46 +371,37 @@ int TLuaInterpreter::createCommandLine(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#createLabel int TLuaInterpreter::createLabel(lua_State* L) { - QString labelName; - QString windowName = QLatin1String("main"); - if (lua_type(L, 1) != LUA_TSTRING) { lua_pushfstring(L, "createLabel: bad argument #1 type (label or parent window name as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } - if ((lua_type(L, 1) == LUA_TSTRING) && (lua_type(L, 2) == LUA_TSTRING)) { - windowName = lua_tostring(L, 1); - labelName = lua_tostring(L, 2); - createLabelUserWindow(L, windowName, labelName); - } else if ((lua_type(L, 1) == LUA_TSTRING) && (lua_type(L, 2) == LUA_TNUMBER)) { - labelName = lua_tostring(L, 1); - createLabelMainWindow(L, labelName); - } else { - lua_pushfstring(L, "createLabel: bad argument #2 type (label name as string or label x-coordinate as number expected, got %s!)", luaL_typename(L, 2)); - return lua_error(L); + if (lua_type(L, 2) == LUA_TSTRING) { + return createLabelUserWindow(L, lua_tostring(L, 1), lua_tostring(L, 2)); + } + if (lua_type(L, 2) == LUA_TNUMBER) { + return createLabelMainWindow(L, lua_tostring(L, 1)); } - return 1; + lua_pushfstring(L, "createLabel: bad argument #2 type (label name as string or label x-coordinate as number expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#createMiniConsole int TLuaInterpreter::createMiniConsole(lua_State* L) { - QString name = ""; int counter = 3; //make the windowname optional by using counter. If windowname "main" add to main console - QString windowName = getVerifiedString(L, __func__, 1, "miniconsole name"); - if (isMain(windowName)) { - // createMiniConsole only accepts the empty name as the main window - windowName.clear(); + if (!checkStringArg(L, __func__, 1, "miniconsole name")) { + return lua_error(L); } - if (!lua_isnumber(L, 2) && lua_gettop(L) >= 2) { - name = getVerifiedString(L, __func__, 2, "miniconsole name"); + const bool hasParentWindow = (!lua_isnumber(L, 2) && lua_gettop(L) >= 2); + if (hasParentWindow) { + if (!checkStringArg(L, __func__, 2, "miniconsole name")) { + return lua_error(L); + } } else { - name = windowName; - windowName.clear(); counter = 2; } @@ -403,6 +413,22 @@ int TLuaInterpreter::createMiniConsole(lua_State* L) counter++; const int height = getVerifiedInt(L, __func__, counter, "miniconsole height"); + QString windowName; + QString name; + if (hasParentWindow) { + windowName = lua_tostring(L, 1); + if (isMain(windowName)) { + // createMiniConsole only accepts the empty name as the main window + windowName.clear(); + } + name = lua_tostring(L, 2); + } else { + name = lua_tostring(L, 1); + if (isMain(name)) { + name.clear(); + } + } + Host& host = getHostFromLua(L); if (auto [success, message] = host.createMiniConsole(windowName, name, x, y, width, height); !success) { return warnArgumentValue(L, __func__, message, true); @@ -415,21 +441,19 @@ int TLuaInterpreter::createMiniConsole(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#createScrollBox int TLuaInterpreter::createScrollBox(lua_State* L) { - QString name = ""; int counter = 3; // make the windowname optional by using counter. If windowname "main" - add to main console - QString windowName = getVerifiedString(L, __func__, 1, "scrollBox name"); - if (isMain(windowName)) { - // createScrollBox only accepts the empty name as the main window - windowName.clear(); + if (!checkStringArg(L, __func__, 1, "scrollBox name")) { + return lua_error(L); } - if (!lua_isnumber(L, 2) && lua_gettop(L) >= 2) { - name = getVerifiedString(L, __func__, 2, "scrollBox name"); + const bool hasParentWindow = (!lua_isnumber(L, 2) && lua_gettop(L) >= 2); + if (hasParentWindow) { + if (!checkStringArg(L, __func__, 2, "scrollBox name")) { + return lua_error(L); + } } else { - name = windowName; - windowName.clear(); counter = 2; } @@ -441,6 +465,22 @@ int TLuaInterpreter::createScrollBox(lua_State* L) counter++; const int height = getVerifiedInt(L, __func__, counter, "scrollBox height"); + QString windowName; + QString name; + if (hasParentWindow) { + windowName = lua_tostring(L, 1); + if (isMain(windowName)) { + // createScrollBox only accepts the empty name as the main window + windowName.clear(); + } + name = lua_tostring(L, 2); + } else { + name = lua_tostring(L, 1); + if (isMain(name)) { + name.clear(); + } + } + const Host& host = getHostFromLua(L); if (auto [success, message] = host.createScrollBox(windowName, name, x, y, width, height); !success) { return warnArgumentValue(L, __func__, message, true); @@ -507,29 +547,23 @@ int TLuaInterpreter::deleteCommandLine(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#createTextEdit int TLuaInterpreter::createTextEdit(lua_State* L) { - QString windowName = QLatin1String("main"); const int n = lua_gettop(L); int counter = 1; + const bool hasParentWindow = (n > 5); - if (n > 5) { + if (hasParentWindow) { if (lua_type(L, 1) != LUA_TSTRING) { lua_pushfstring(L, "createTextEdit: bad argument #1 type (parent window name as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } - windowName = lua_tostring(L, 1); counter++; - if (isMain(windowName)) { - // createTextEdit only accepts the empty name as the main window - windowName.clear(); - } } - if (lua_type(L, counter) != LUA_TSTRING) { - lua_pushfstring(L, "createTextEdit: bad argument #%d type (text edit name as string expected, got %s!)", counter, luaL_typename(L, counter)); + const int textEditNamePos = counter++; + if (lua_type(L, textEditNamePos) != LUA_TSTRING) { + lua_pushfstring(L, "createTextEdit: bad argument #%d type (text edit name as string expected, got %s!)", textEditNamePos, luaL_typename(L, textEditNamePos)); return lua_error(L); } - const QString textEditName{lua_tostring(L, counter)}; - counter++; const int x = getVerifiedInt(L, __func__, counter, "text edit x-coordinate"); counter++; const int y = getVerifiedInt(L, __func__, counter, "text edit y-coordinate"); @@ -539,6 +573,16 @@ int TLuaInterpreter::createTextEdit(lua_State* L) const int height = getVerifiedInt(L, __func__, counter, "text edit height"); counter++; + QString windowName = qsl("main"); + if (hasParentWindow) { + windowName = lua_tostring(L, 1); + if (isMain(windowName)) { + // createTextEdit only accepts the empty name as the main window + windowName.clear(); + } + } + const QString textEditName{lua_tostring(L, textEditNamePos)}; + const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->createTextBox(windowName, textEditName, x, y, width, height); !success) { return warnArgumentValue(L, __func__, message); @@ -583,8 +627,11 @@ int TLuaInterpreter::getTextEditText(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditText int TLuaInterpreter::setTextEditText(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const QString text = getVerifiedString(L, __func__, 2, "text"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -616,8 +663,11 @@ int TLuaInterpreter::clearTextEdit(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditReadOnly int TLuaInterpreter::setTextEditReadOnly(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const bool readOnly = getVerifiedBool(L, __func__, 2, "read only state"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -633,8 +683,11 @@ int TLuaInterpreter::setTextEditReadOnly(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditPlaceholder int TLuaInterpreter::setTextEditPlaceholder(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const QString placeholder = getVerifiedString(L, __func__, 2, "placeholder text"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -650,8 +703,11 @@ int TLuaInterpreter::setTextEditPlaceholder(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditStyleSheet int TLuaInterpreter::setTextEditStyleSheet(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const QString css = getVerifiedString(L, __func__, 2, "stylesheet"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -667,8 +723,11 @@ int TLuaInterpreter::setTextEditStyleSheet(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditFont int TLuaInterpreter::setTextEditFont(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const QString fontName = getVerifiedString(L, __func__, 2, "font name"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -686,8 +745,11 @@ int TLuaInterpreter::setTextEditFont(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditFontSize int TLuaInterpreter::setTextEditFontSize(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const int size = getVerifiedInt(L, __func__, 2, "font size"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -705,8 +767,11 @@ int TLuaInterpreter::setTextEditFontSize(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setTextEditTabMovesFocus int TLuaInterpreter::setTextEditTabMovesFocus(lua_State* L) { - const QString textEditName = getVerifiedString(L, __func__, 1, "text edit name"); + if (!checkStringArg(L, __func__, 1, "text edit name")) { + return lua_error(L); + } const bool tabMovesFocus = getVerifiedBool(L, __func__, 2, "tab moves focus state"); + const QString textEditName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); auto pT = host.mpConsole->mTextBoxMap.value(textEditName); @@ -833,54 +898,50 @@ int TLuaInterpreter::disableTimeStamps(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#echoLink int TLuaInterpreter::echoLink(lua_State* L) { + const int n = lua_gettop(L); + // with exactly four arguments the last one is either the format flag - and + // then there is no window name - or the hint + const bool hasWindowName = (n > 4) || (n == 4 && !lua_isboolean(L, 4)); + const bool hasFormatFlag = (n > 4) || (n == 4 && lua_isboolean(L, 4)); + + int s = 0; + const int windowNamePos = hasWindowName ? ++s : 0; + if (hasWindowName && !checkStringArg(L, __func__, windowNamePos, "window name")) { + return lua_error(L); + } + const int textPos = ++s; + if (!checkStringArg(L, __func__, textPos, "text")) { + return lua_error(L); + } + int commandPos = ++s; + if (!checkCommandOrFunctionArg(L, __func__, commandPos)) { + return lua_error(L); + } + const int hintPos = ++s; + if (!checkStringArg(L, __func__, hintPos, "hint")) { + return lua_error(L); + } + const int formatPos = hasFormatFlag ? ++s : 0; + if (hasFormatFlag && !checkBoolArg(L, __func__, formatPos, "useCurrentFormat")) { + return lua_error(L); + } + + QString command; + int luaReference = 0; + parseCommandOrFunction(L, __func__, commandPos, command, luaReference); + QStringList commandList; QStringList hintList; QVector<int> luaReferences; - const int n = lua_gettop(L); - int s = 0; - int luaReference = 0; - bool useCurrentFormat = false; - QString windowName = qsl("main"); - QString hint; - QString command; - QString text; - - if (n < 4) { - // (string) text, (string) command/function, (string) hint - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - hint = getVerifiedString(L, __func__, ++s, "hint"); - - } else { - if (n == 4) { - // EITHER: (string) text, (string) command/function, (string) hint, (bool) standard/NotDefaultFormat - // OR: (string) windowName, (string) text, (string) command/function, (string) hint - if (!lua_isboolean(L, 4)) { - windowName = getVerifiedString(L, __func__, ++s, "window name"); - } - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - hint = getVerifiedString(L, __func__, ++s, "hint"); - if (lua_isboolean(L, 4)) { - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } else { - // n > 4: - // (string) windowName, (string) text, (string) command/function, (string) hint, (bool) standard/NotDefaultFormat - windowName = getVerifiedString(L, __func__, ++s, "window name"); - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - hint = getVerifiedString(L, __func__, ++s, "hint"); - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } - commandList << command; luaReferences << luaReference; - hintList << hint; + hintList << QString{lua_tostring(L, hintPos)}; + + const QString windowName = hasWindowName ? QString{lua_tostring(L, windowNamePos)} : qsl("main"); + const bool useCurrentFormat = hasFormatFlag && lua_toboolean(L, formatPos); auto console = CONSOLE(L, windowName); - console->echoLink(text, commandList, hintList, useCurrentFormat, luaReferences); + console->echoLink(QString{lua_tostring(L, textPos)}, commandList, hintList, useCurrentFormat, luaReferences); lua_pushboolean(L, true); return 1; } @@ -888,54 +949,44 @@ int TLuaInterpreter::echoLink(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#echoUserWindow int TLuaInterpreter::echoUserWindow(lua_State* L) { - const QString windowName{WINDOW_NAME(L, 1)}; + const char* windowName = WINDOW_NAME(L, 1); const QString text = getVerifiedString(L, __func__, 2, "text"); Host& host = getHostFromLua(L); - host.echoWindow(windowName, text); + host.echoWindow(QString{windowName}, text); return 0; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#echoPopup int TLuaInterpreter::echoPopup(lua_State* L) { + const int n = lua_gettop(L); + // with exactly four arguments the last one is either the format flag - and + // then there is no window name - or the hints table + const bool hasWindowName = (n > 4) || (n == 4 && !lua_isboolean(L, 4)); + const bool hasFormatFlag = (n > 4) || (n == 4 && lua_isboolean(L, 4)); + + int s = 0; + const int windowNamePos = hasWindowName ? ++s : 0; + if (hasWindowName && !checkStringArg(L, __func__, windowNamePos, "window name")) { + return lua_error(L); + } + const int textPos = ++s; + if (!checkStringArg(L, __func__, textPos, "text")) { + return lua_error(L); + } + int commandPos = ++s; + int hintPos = ++s; + const int formatPos = hasFormatFlag ? ++s : 0; + + if (!checkCommandsOrFunctionsTable(L, __func__, commandPos) || !checkHintsTable(L, __func__, hintPos) || (hasFormatFlag && !checkBoolArg(L, __func__, formatPos, "useCurrentFormat"))) { + return lua_error(L); + } + QStringList commandList; QStringList hintList; QVector<int> luaReferences; - const int n = lua_gettop(L); - int s = 0; - bool useCurrentFormat = false; - QString windowName = qsl("main"); - QString text; - - if (n < 4) { - // (string) text, {table of (string) / (functions) commands}, {table of (strings) hints} - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); - parseHintsTable(L, __func__, ++s, hintList); - - } else { - if (n == 4) { - // EITHER: (string) text, {table of (string) / (functions) commands}, {table of (strings) hints}, (bool) standard/NotDefaultFormat - // OR: (string) windowName, (string) text, {table of (string) / (functions) commands}, {table of (strings) hints} - if (!lua_isboolean(L, 4)) { - windowName = getVerifiedString(L, __func__, ++s, "window name"); - } - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); - parseHintsTable(L, __func__, ++s, hintList); - if (lua_isboolean(L, 4)) { - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } else { - // n > 4: - // (string) windowName, (string) text, {table of (string) / (functions) commands}, {table of (strings) hints}, (bool) standard/NotDefaultFormat - windowName = getVerifiedString(L, __func__, ++s, "window name"); - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); - parseHintsTable(L, __func__, ++s, hintList); - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } + parseCommandsOrFunctionsTable(L, __func__, commandPos, commandList, luaReferences); + parseHintsTable(L, __func__, hintPos, hintList); if ((hintList.size() - commandList.size()) < 0 || (hintList.size() - commandList.size()) > 1) { lua_pushnil(L); @@ -946,8 +997,11 @@ int TLuaInterpreter::echoPopup(lua_State* L) return 2; } + const QString windowName = hasWindowName ? QString{lua_tostring(L, windowNamePos)} : qsl("main"); + const bool useCurrentFormat = hasFormatFlag && lua_toboolean(L, formatPos); + auto console = CONSOLE(L, windowName); - console->echoLink(text, commandList, hintList, useCurrentFormat, luaReferences); + console->echoLink(QString{lua_tostring(L, textPos)}, commandList, hintList, useCurrentFormat, luaReferences); lua_pushboolean(L, true); return 1; } @@ -966,10 +1020,18 @@ int TLuaInterpreter::enableClickthrough(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLinkStyle int TLuaInterpreter::setLinkStyle(lua_State* L) { - const QString labelName = getVerifiedString(L, __func__, 1, "label name"); - const QString linkColor = getVerifiedString(L, __func__, 2, "link color", true); - const QString linkVisitedColor = getVerifiedString(L, __func__, 3, "link visited color", true); - const bool underline = (lua_gettop(L) >= 4) ? getVerifiedBool(L, __func__, 4, "underline", true) : true; + const bool hasUnderline = (lua_gettop(L) >= 4); + if (!checkStringArg(L, __func__, 1, "label name") || !checkStringArg(L, __func__, 2, "link color", true) || !checkStringArg(L, __func__, 3, "link visited color", true)) { + return lua_error(L); + } + if (hasUnderline && !checkBoolArg(L, __func__, 4, "underline", true)) { + return lua_error(L); + } + + const QString labelName{lua_tostring(L, 1)}; + const QString linkColor{lua_tostring(L, 2)}; + const QString linkVisitedColor{lua_tostring(L, 3)}; + const bool underline = hasUnderline ? static_cast<bool>(lua_toboolean(L, 4)) : true; Host& host = getHostFromLua(L); @@ -1055,6 +1117,15 @@ int TLuaInterpreter::enableScrollBar(lua_State* L) return 0; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getScrollBarVisible +int TLuaInterpreter::getScrollBarVisible(lua_State* L) +{ + const QString windowName{WINDOW_NAME(L, 1)}; + auto console = CONSOLE(L, windowName); + lua_pushboolean(L, console->getScrollBarVisible()); + return 1; +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#enableTimeStamps int TLuaInterpreter::enableTimeStamps(lua_State* L) { @@ -1186,7 +1257,7 @@ int TLuaInterpreter::getBorderTop(lua_State* L) int TLuaInterpreter::getBorderColor(lua_State* L) { const Host& host = getHostFromLua(L); - const QColor color = host.mpConsole->mpMainFrame->palette().color(QPalette::Window); + const QColor color = host.mpConsole->borderColor(); lua_pushnumber(L, color.red()); lua_pushnumber(L, color.green()); lua_pushnumber(L, color.blue()); @@ -1378,13 +1449,18 @@ int TLuaInterpreter::getLines(lua_State* L) { const int n = lua_gettop(L); int s = 1; - QString windowName; - if (n > 2) { - windowName = getVerifiedString(L, __func__, s++, "mini console, user window or buffer name {may be omitted for the \"main\" console}", true); + const int windowNamePos = (n > 2) ? s++ : 0; + if (windowNamePos && !checkStringArg(L, __func__, windowNamePos, "mini console, user window or buffer name {may be omitted for the \"main\" console}", true)) { + return lua_error(L); } const int lineFrom = getVerifiedInt(L, __func__, s++, "start line"); const int lineTo = getVerifiedInt(L, __func__, s, "end line"); + QString windowName; + if (windowNamePos) { + windowName = lua_tostring(L, windowNamePos); + } + Host& host = getHostFromLua(L); QPair<bool, QStringList> const result = host.getLines(windowName, lineFrom, lineTo); if (!result.first) { @@ -1675,6 +1751,50 @@ int TLuaInterpreter::getUserWindowSize(lua_State* L) return 2; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getWindowGeometry +int TLuaInterpreter::getWindowGeometry(lua_State* L) +{ + const Host& host = getHostFromLua(L); + const QString windowName = getVerifiedString(L, __func__, 1, "window name"); + + if (auto geometry = host.windowGeometry(windowName)) { + lua_pushnumber(L, geometry->x()); + lua_pushnumber(L, geometry->y()); + lua_pushnumber(L, geometry->width()); + lua_pushnumber(L, geometry->height()); + return 4; + } + + lua_pushnil(L); + lua_pushfstring(L, bad_window_value, windowName.toUtf8().constData()); + return 2; +} + +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#windowVisible +int TLuaInterpreter::windowVisible(lua_State* L) +{ + const Host& host = getHostFromLua(L); + const QString windowName = getVerifiedString(L, __func__, 1, "window name"); + + if (auto visible = host.windowVisible(windowName)) { + lua_pushboolean(L, *visible); + return 1; + } + + lua_pushnil(L); + lua_pushfstring(L, bad_window_value, windowName.toUtf8().constData()); + return 2; +} + +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getLabelText +int TLuaInterpreter::getLabelText(lua_State* L) +{ + const QString labelName = getVerifiedString(L, __func__, 1, "label name"); + auto label = LABEL(L, labelName); + lua_pushstring(L, label->text().toUtf8().constData()); + return 1; +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getWindowWrap int TLuaInterpreter::getWindowWrap(lua_State* L) { @@ -1717,54 +1837,50 @@ int TLuaInterpreter::hideWindow(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#insertLink int TLuaInterpreter::insertLink(lua_State* L) { + const int n = lua_gettop(L); + // with exactly four arguments the last one is either the format flag - and + // then there is no window name - or the hint + const bool hasWindowName = (n > 4) || (n == 4 && !lua_isboolean(L, 4)); + const bool hasFormatFlag = (n > 4) || (n == 4 && lua_isboolean(L, 4)); + + int s = 0; + const int windowNamePos = hasWindowName ? ++s : 0; + if (hasWindowName && !checkStringArg(L, __func__, windowNamePos, "window name")) { + return lua_error(L); + } + const int textPos = ++s; + if (!checkStringArg(L, __func__, textPos, "text")) { + return lua_error(L); + } + int commandPos = ++s; + if (!checkCommandOrFunctionArg(L, __func__, commandPos)) { + return lua_error(L); + } + const int hintPos = ++s; + if (!checkStringArg(L, __func__, hintPos, "hint")) { + return lua_error(L); + } + const int formatPos = hasFormatFlag ? ++s : 0; + if (hasFormatFlag && !checkBoolArg(L, __func__, formatPos, "useCurrentFormat")) { + return lua_error(L); + } + + QString command; + int luaReference = 0; + parseCommandOrFunction(L, __func__, commandPos, command, luaReference); + QStringList commandList; QStringList hintList; QVector<int> luaReferences; - const int n = lua_gettop(L); - int s = 0; - int luaReference = 0; - bool useCurrentFormat = false; - QString windowName = qsl("main"); - QString hint; - QString command; - QString text; - - if (n < 4) { - // (string) text, (string) command/function, (string) hint - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - hint = getVerifiedString(L, __func__, ++s, "hint"); - - } else { - if (n == 4) { - // EITHER: (string) text, (string) command/function, (string) hint, (bool) standard/NotDefaultFormat - // OR: (string) windowName, (string) text, (string) command/function, (string) hint - if (!lua_isboolean(L, 4)) { - windowName = getVerifiedString(L, __func__, ++s, "window name"); - } - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - hint = getVerifiedString(L, __func__, ++s, "hint"); - if (lua_isboolean(L, 4)) { - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } else { - // n > 4: - // (string) windowName, (string) text, (string) command/function, (string) hint, (bool) standard/NotDefaultFormat - windowName = getVerifiedString(L, __func__, ++s, "window name"); - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - hint = getVerifiedString(L, __func__, ++s, "hint"); - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } - commandList << command; luaReferences << luaReference; - hintList << hint; + hintList << QString{lua_tostring(L, hintPos)}; + + const QString windowName = hasWindowName ? QString{lua_tostring(L, windowNamePos)} : qsl("main"); + const bool useCurrentFormat = hasFormatFlag && lua_toboolean(L, formatPos); auto console = CONSOLE(L, windowName); - console->insertLink(text, commandList, hintList, useCurrentFormat, luaReferences); + console->insertLink(QString{lua_tostring(L, textPos)}, commandList, hintList, useCurrentFormat, luaReferences); lua_pushboolean(L, true); return 1; } @@ -1772,44 +1888,34 @@ int TLuaInterpreter::insertLink(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#insertPopup int TLuaInterpreter::insertPopup(lua_State* L) { + const int n = lua_gettop(L); + // with exactly four arguments the last one is either the format flag - and + // then there is no window name - or the hints table + const bool hasWindowName = (n > 4) || (n == 4 && !lua_isboolean(L, 4)); + const bool hasFormatFlag = (n > 4) || (n == 4 && lua_isboolean(L, 4)); + + int s = 0; + const int windowNamePos = hasWindowName ? ++s : 0; + if (hasWindowName && !checkStringArg(L, __func__, windowNamePos, "window name")) { + return lua_error(L); + } + const int textPos = ++s; + if (!checkStringArg(L, __func__, textPos, "text")) { + return lua_error(L); + } + int commandPos = ++s; + int hintPos = ++s; + const int formatPos = hasFormatFlag ? ++s : 0; + + if (!checkCommandsOrFunctionsTable(L, __func__, commandPos) || !checkHintsTable(L, __func__, hintPos) || (hasFormatFlag && !checkBoolArg(L, __func__, formatPos, "useCurrentFormat"))) { + return lua_error(L); + } + QStringList commandList; QStringList hintList; QVector<int> luaReferences; - const int n = lua_gettop(L); - int s = 0; - bool useCurrentFormat = false; - QString windowName = qsl("main"); - QString text; - - if (n < 4) { - // (string) text, {table of (string) / (functions) commands}, {table of (strings) hints} - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); - parseHintsTable(L, __func__, ++s, hintList); - - } else { - if (n == 4) { - // EITHER: (string) text, {table of (string) / (functions) commands}, {table of (strings) hints}, (bool) standard/NotDefaultFormat - // OR: (string) windowName, (string) text, {table of (string) / (functions) commands}, {table of (strings) hints} - if (!lua_isboolean(L, 4)) { - windowName = getVerifiedString(L, __func__, ++s, "window name"); - } - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); - parseHintsTable(L, __func__, ++s, hintList); - if (lua_isboolean(L, 4)) { - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } else { - // n > 4: - // (string) windowName, (string) text, {table of (string) / (functions) commands}, {table of (strings) hints}, (bool) standard/NotDefaultFormat - windowName = getVerifiedString(L, __func__, ++s, "window name"); - text = getVerifiedString(L, __func__, ++s, "text"); - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); - parseHintsTable(L, __func__, ++s, hintList); - useCurrentFormat = getVerifiedBool(L, __func__, ++s, "useCurrentFormat"); - } - } + parseCommandsOrFunctionsTable(L, __func__, commandPos, commandList, luaReferences); + parseHintsTable(L, __func__, hintPos, hintList); if ((hintList.size() - commandList.size()) < 0 || (hintList.size() - commandList.size()) > 1) { lua_pushnil(L); @@ -1820,8 +1926,11 @@ int TLuaInterpreter::insertPopup(lua_State* L) return 2; } + const QString windowName = hasWindowName ? QString{lua_tostring(L, windowNamePos)} : qsl("main"); + const bool useCurrentFormat = hasFormatFlag && lua_toboolean(L, formatPos); + auto console = CONSOLE(L, windowName); - console->insertLink(text, commandList, hintList, useCurrentFormat, luaReferences); + console->insertLink(QString{lua_tostring(L, textPos)}, commandList, hintList, useCurrentFormat, luaReferences); lua_pushboolean(L, true); return 1; } @@ -1829,7 +1938,7 @@ int TLuaInterpreter::insertPopup(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#insertText int TLuaInterpreter::insertText(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 0; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name @@ -1837,7 +1946,7 @@ int TLuaInterpreter::insertText(lua_State* L) } const QString text = getVerifiedString(L, __func__, ++s, "text"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->insertText(text); lua_pushboolean(L, true); return 1; @@ -1854,13 +1963,10 @@ int TLuaInterpreter::isAnsiBgColor(lua_State* L) result = host.mpConsole->getBgColor(windowName); auto it = result.begin(); if (result.size() < 3) { - return 0; + return warnArgumentValue(L, __func__, qsl("current selection invalid in window '%1'").arg(windowName)); } - if (ansiBg < 0) { - return 0; - } - if (ansiBg > 16) { - return 0; + if (ansiBg < 0 || ansiBg > 16) { + return warnArgumentValue(L, __func__, qsl("ANSI color %1 out of range (0 to 16)").arg(ansiBg)); } @@ -1940,7 +2046,7 @@ int TLuaInterpreter::isAnsiBgColor(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#isAnsiFgColor int TLuaInterpreter::isAnsiFgColor(lua_State* L) { - QString windowName = "main"; + QString windowName = qsl("main"); const int ansiFg = getVerifiedInt(L, __func__, 1, "ANSI color"); std::list<int> result; @@ -1948,13 +2054,10 @@ int TLuaInterpreter::isAnsiFgColor(lua_State* L) result = host.mpConsole->getFgColor(windowName); auto it = result.begin(); if (result.size() < 3) { - return 0; + return warnArgumentValue(L, __func__, qsl("current selection invalid in window '%1'").arg(windowName)); } - if (ansiFg < 0) { - return 0; - } - if (ansiFg > 16) { - return 0; + if (ansiFg < 0 || ansiFg > 16) { + return warnArgumentValue(L, __func__, qsl("ANSI color %1 out of range (0 to 16)").arg(ansiFg)); } @@ -2052,7 +2155,7 @@ int TLuaInterpreter::moveCursor(lua_State* L) { int s = 1; const int n = lua_gettop(L); - QString windowName; + const char* windowName = ""; if (n > 2) { windowName = WINDOW_NAME(L, s++); } @@ -2060,7 +2163,7 @@ int TLuaInterpreter::moveCursor(lua_State* L) const int luaFrom = getVerifiedInt(L, __func__, s++, "x"); const int luaTo = getVerifiedInt(L, __func__, s, "y"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); lua_pushboolean(L, console->moveCursor(luaFrom, luaTo)); return 1; } @@ -2077,9 +2180,12 @@ int TLuaInterpreter::moveCursorEnd(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#moveWindow int TLuaInterpreter::moveWindow(lua_State* L) { - const QString text = getVerifiedString(L, __func__, 1, "name"); + if (!checkStringArg(L, __func__, 1, "name")) { + return lua_error(L); + } const double x1 = getVerifiedDouble(L, __func__, 2, "x"); const double y1 = getVerifiedDouble(L, __func__, 3, "y"); + const QString text{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); host.moveWindow(text, static_cast<int>(x1), static_cast<int>(y1)); return 0; @@ -2093,22 +2199,22 @@ int TLuaInterpreter::openUserWindow(lua_State* L) lua_pushfstring(L, "openUserWindow: bad argument #1 type (name as string expected, got %s!)", luaL_typename(L, 1)); return lua_error(L); } - const QString name{lua_tostring(L, 1)}; + if (n > 1 && !checkBoolArg(L, __func__, 2, "loadLayout", true)) { + return lua_error(L); + } + if (n > 2 && !checkBoolArg(L, __func__, 3, "autoDock", true)) { + return lua_error(L); + } + if (n > 3 && lua_type(L, 4) != LUA_TSTRING) { + lua_pushfstring(L, "openUserWindow: bad argument #4 type (area as string expected, got %s!)", luaL_typename(L, 4)); + return lua_error(L); + } - bool loadLayout = true; - if (n > 1) { - loadLayout = getVerifiedBool(L, __func__, 2, "loadLayout", true); - } - bool autoDock = true; - if (n > 2) { - autoDock = getVerifiedBool(L, __func__, 3, "autoDock", true); - } - QString area = QString(); + const QString name{lua_tostring(L, 1)}; + const bool loadLayout = (n > 1) ? static_cast<bool>(lua_toboolean(L, 2)) : true; + const bool autoDock = (n > 2) ? static_cast<bool>(lua_toboolean(L, 3)) : true; + QString area; if (n > 3) { - if (lua_type(L, 4) != LUA_TSTRING) { - lua_pushfstring(L, "openUserWindow: bad argument #4 type (area as string expected, got %s!)", luaL_typename(L, 4)); - return lua_error(L); - } area = lua_tostring(L, 4); } @@ -2138,7 +2244,7 @@ int TLuaInterpreter::paste(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#pauseMovie int TLuaInterpreter::pauseMovie(lua_State* L) { - return movieFunc(L, qsl("pauseMovie")); + return movieFunc(L, "pauseMovie"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#raiseWindow @@ -2153,16 +2259,19 @@ int TLuaInterpreter::raiseWindow(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#removeCommandLineMenuEvent int TLuaInterpreter::removeCommandLineMenuEvent(lua_State* L) { - int args = 1; const int argsCount = lua_gettop(L); + const bool hasCommandLineName = (argsCount >= 2); + const int menuLabelPos = hasCommandLineName ? 2 : 1; - QString commandLineName; - if (argsCount >= 2) { - commandLineName = getVerifiedString(L, __func__, args++, "command line name"); - } else { - commandLineName = qsl("main"); + if (hasCommandLineName && !checkStringArg(L, __func__, 1, "command line name")) { + return lua_error(L); } - auto menuLabel = getVerifiedString(L, __func__, args++, "menu label"); + if (!checkStringArg(L, __func__, menuLabelPos, "menu label")) { + return lua_error(L); + } + + const QString commandLineName = hasCommandLineName ? QString{lua_tostring(L, 1)} : qsl("main"); + const QString menuLabel{lua_tostring(L, menuLabelPos)}; const auto& commandline = COMMANDLINE(L, commandLineName); @@ -2193,14 +2302,14 @@ int TLuaInterpreter::replace(lua_State* L) { const int n = lua_gettop(L); int s = 1; - QString windowName; + const char* windowName = ""; if (n > 1) { windowName = WINDOW_NAME(L, s++); } const QString text = getVerifiedString(L, __func__, s, "with"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->replace(text); return 0; } @@ -2230,8 +2339,8 @@ int TLuaInterpreter::resetBackgroundImage(lua_State* L) bool fullWindow = false; const int n = lua_gettop(L); int counter = 1; - if (n > 0 && lua_type(L, 1) == LUA_TSTRING) { - windowName = getVerifiedString(L, __func__, 1, "console name"); + const bool hasWindowName = (n > 0 && lua_type(L, 1) == LUA_TSTRING); + if (hasWindowName) { counter++; } @@ -2240,6 +2349,10 @@ int TLuaInterpreter::resetBackgroundImage(lua_State* L) counter++; } + if (hasWindowName) { + windowName = lua_tostring(L, 1); + } + if (fullWindow && !(windowName.isEmpty() || windowName.compare(qsl("main"), Qt::CaseSensitive) == 0)) { return warnArgumentValue(L, __func__, qsl("the full window background can only be reset on the main console")); } @@ -2265,9 +2378,12 @@ int TLuaInterpreter::resetFormat(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#resizeWindow int TLuaInterpreter::resizeWindow(lua_State* L) { - const QString text = getVerifiedString(L, __func__, 1, "windowName"); + if (!checkStringArg(L, __func__, 1, "windowName")) { + return lua_error(L); + } const double x1 = getVerifiedDouble(L, __func__, 2, "width"); const double y1 = getVerifiedDouble(L, __func__, 3, "height"); + const QString text{lua_tostring(L, 1)}; Host& host = getHostFromLua(L); host.resizeWindow(text, static_cast<int>(x1), static_cast<int>(y1)); return 0; @@ -2284,7 +2400,7 @@ int TLuaInterpreter::saveWindowLayout(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#scaleMovie int TLuaInterpreter::scaleMovie(lua_State* L) { - return movieFunc(L, qsl("scaleMovie")); + return movieFunc(L, "scaleMovie"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#selectCaptureGroup @@ -2355,12 +2471,13 @@ int TLuaInterpreter::selectCaptureGroup(lua_State* L) int TLuaInterpreter::selectCmdLineText(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; + QString name = qsl("main"); if (n >= 1) { name = CMDLINE_NAME(L, 1); } auto commandline = COMMANDLINE(L, name); commandline->selectAll(); + lua_pushboolean(L, true); return 1; } @@ -2381,7 +2498,7 @@ int TLuaInterpreter::selectCurrentLine(lua_State* L) int TLuaInterpreter::selectSection(lua_State* L) { int s = 1; - QString windowName; + const char* windowName = ""; if (lua_gettop(L) > 2) { windowName = WINDOW_NAME(L, s++); @@ -2389,7 +2506,7 @@ int TLuaInterpreter::selectSection(lua_State* L) const int from = getVerifiedInt(L, __func__, s++, "from position"); const int to = getVerifiedInt(L, __func__, s, "length"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); lua_pushboolean(L, console->selectSection(from, to)); return 1; } @@ -2398,17 +2515,21 @@ int TLuaInterpreter::selectSection(lua_State* L) int TLuaInterpreter::selectString(lua_State* L) { int s = 1; - QString windowName; + const char* windowName = ""; if (lua_gettop(L) > 2) { windowName = WINDOW_NAME(L, s++); } - const QString searchText = getVerifiedString(L, __func__, s++, "text to select"); + const int searchTextPos = s++; + if (!checkStringArg(L, __func__, searchTextPos, "text to select")) { + return lua_error(L); + } // CHECK: Do we need to qualify this for a non-blank string? const auto numOfMatch = getVerifiedInt(L, __func__, s, "match count {1 for first}"); + const QString searchText{lua_tostring(L, searchTextPos)}; - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); lua_pushnumber(L, console->select(searchText, numOfMatch)); return 1; } @@ -2445,12 +2566,18 @@ int TLuaInterpreter::setActiveProfile(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setAppStyleSheet int TLuaInterpreter::setAppStyleSheet(lua_State* L) { - QString styleSheet; - QString tag; const int n = lua_gettop(L); - styleSheet = getVerifiedString(L, __func__, 1, "style sheet"); + if (!checkStringArg(L, __func__, 1, "style sheet")) { + return lua_error(L); + } + if (n > 1 && !checkStringArg(L, __func__, 2, "tag")) { + return lua_error(L); + } + + const QString styleSheet{lua_tostring(L, 1)}; + QString tag; if (n > 1) { - tag = getVerifiedString(L, __func__, 2, "tag"); + tag = lua_tostring(L, 2); } Host& host = getHostFromLua(L); @@ -2471,7 +2598,7 @@ int TLuaInterpreter::setAppStyleSheet(lua_State* L) int TLuaInterpreter::setBackgroundColor(lua_State* L) { Host& host = getHostFromLua(L); - QString windowName; + const char* windowNameArg = ""; int r, alpha; int s = 1; @@ -2480,7 +2607,7 @@ int TLuaInterpreter::setBackgroundColor(lua_State* L) }; if (lua_type(L, s) == LUA_TSTRING) { - windowName = WINDOW_NAME(L, s++); + windowNameArg = WINDOW_NAME(L, s++); r = getVerifiedInt(L, __func__, s, "red value 0-255"); if (!validRange(r)) { return warnArgumentValue(L, __func__, csmInvalidRedValue.arg(r)); @@ -2514,6 +2641,7 @@ int TLuaInterpreter::setBackgroundColor(lua_State* L) } } + const QString windowName{windowNameArg}; if (isMain(windowName)) { host.mBgColor.setRgb(r, g, b, alpha); host.mpConsole->setConsoleBgColor(r, g, b, alpha); @@ -2528,18 +2656,22 @@ int TLuaInterpreter::setBackgroundColor(lua_State* L) int TLuaInterpreter::setBackgroundImage(lua_State* L) { QString windowName = qsl("main"); - QString imgPath; int mode = 1; bool fullWindow = false; int counter = 1; const int n = lua_gettop(L); - if (n > 1 && lua_type(L, 2) == LUA_TSTRING) { - windowName = getVerifiedString(L, __func__, 1, "console or label name"); + const bool hasWindowName = (n > 1 && lua_type(L, 2) == LUA_TSTRING); + if (hasWindowName) { + if (!checkStringArg(L, __func__, 1, "console or label name")) { + return lua_error(L); + } counter++; } - imgPath = getVerifiedString(L, __func__, counter, "image path"); - counter++; + const int imgPathPos = counter++; + if (!checkStringArg(L, __func__, imgPathPos, "image path")) { + return lua_error(L); + } if (counter <= n) { mode = getVerifiedInt(L, __func__, counter, "mode"); @@ -2551,6 +2683,11 @@ int TLuaInterpreter::setBackgroundImage(lua_State* L) counter++; } + if (hasWindowName) { + windowName = lua_tostring(L, 1); + } + QString imgPath{lua_tostring(L, imgPathPos)}; + if (mode < 1 || mode > 5) { return warnArgumentValue(L, __func__, qsl("%1 is not a valid mode! Valid modes are 1 'border', 2 'center', 3 'tile', 4 'style', 5 'cover'").arg(mode)); } @@ -2565,6 +2702,10 @@ int TLuaInterpreter::setBackgroundImage(lua_State* L) Host* host = &getHostFromLua(L); if (!host->setBackgroundImage(windowName, imgPath, mode, fullWindow)) { + if (fullWindow) { + // the console name is already validated above, so this is about the image + return warnArgumentValue(L, __func__, qsl("could not use '%1' as a full window background image").arg(imgPath)); + } return warnArgumentValue(L, __func__, qsl("console or label '%1' not found").arg(windowName)); } @@ -2575,7 +2716,7 @@ int TLuaInterpreter::setBackgroundImage(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setBgColor int TLuaInterpreter::setBgColor(lua_State* L) { - QString windowName; + const char* windowName = ""; int r, g, b, alpha; auto validRange = [](int number) { @@ -2625,7 +2766,7 @@ int TLuaInterpreter::setBgColor(lua_State* L) } } - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setBgColor(r, g, b, alpha); lua_pushboolean(L, true); return 1; @@ -2634,13 +2775,13 @@ int TLuaInterpreter::setBgColor(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setBold int TLuaInterpreter::setBold(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); } const bool isAttributeEnabled = getVerifiedBool(L, __func__, s, "enable bold attribute"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setDisplayAttributes(TChar::Bold, isAttributeEnabled); lua_pushboolean(L, true); return 1; @@ -2663,11 +2804,7 @@ int TLuaInterpreter::setBorderColor(lua_State* L) const int luaGreen = getVerifiedInt(L, __func__, 2, "green"); const int luaBlue = getVerifiedInt(L, __func__, 3, "blue"); const Host& host = getHostFromLua(L); - QPalette framePalette; - framePalette.setColor(QPalette::Text, QColor(Qt::black)); - framePalette.setColor(QPalette::Highlight, QColor(55, 55, 255)); - framePalette.setColor(QPalette::Window, QColor(luaRed, luaGreen, luaBlue, 255)); - host.mpConsole->mpMainFrame->setPalette(framePalette); + host.mpConsole->setBorderColor(QColor(luaRed, luaGreen, luaBlue)); return 0; } @@ -2747,7 +2884,7 @@ int TLuaInterpreter::setFgColor(lua_State* L) auto validRange = [](int number) { return number >= 0 && number <= 255; }; - QString windowName; + const char* windowName = ""; if (n > 3) { windowName = WINDOW_NAME(L, ++s); } @@ -2764,7 +2901,7 @@ int TLuaInterpreter::setFgColor(lua_State* L) return warnArgumentValue(L, __func__, csmInvalidBlueValue.arg(luaBlue)); } - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setFgColor(luaRed, luaGreen, luaBlue); return 0; } @@ -2773,8 +2910,11 @@ int TLuaInterpreter::setFgColor(lua_State* L) int TLuaInterpreter::setButtonStyleSheet(lua_State* L) { //args: name, css text - const QString name = getVerifiedString(L, __func__, 1, "name"); - const QString css = getVerifiedString(L, __func__, 2, "css"); + if (!checkStringArg(L, __func__, 1, "name") || !checkStringArg(L, __func__, 2, "css")) { + return lua_error(L); + } + const QString name{lua_tostring(L, 1)}; + const QString css{lua_tostring(L, 2)}; Host& host = getHostFromLua(L); auto actionIds = host.getActionUnit()->findItems(name); if (actionIds.empty()) { @@ -2805,16 +2945,19 @@ int TLuaInterpreter::setClipboardText(lua_State* L) int TLuaInterpreter::setCmdLineAction(lua_State* L) { Host& host = getHostFromLua(L); - const QString name = getVerifiedString(L, __func__, 1, "command line name"); - if (name.isEmpty()) { - return warnArgumentValue(L, __func__, "command line name cannot be an empty string"); - } - lua_remove(L, 1); - - if (!lua_isfunction(L, 1)) { - lua_pushfstring(L, "setCmdLineAction: bad argument #2 type (function expected, got %s!)", luaL_typename(L, 1)); + if (!checkStringArg(L, __func__, 1, "command line name")) { return lua_error(L); } + if (const QString name{lua_tostring(L, 1)}; name.isEmpty()) { + return warnArgumentValue(L, __func__, "command line name cannot be an empty string"); + } + if (!lua_isfunction(L, 2)) { + lua_pushfstring(L, "setCmdLineAction: bad argument #2 type (function expected, got %s!)", luaL_typename(L, 2)); + return lua_error(L); + } + + const QString name{lua_tostring(L, 1)}; + lua_remove(L, 1); const int func = luaL_ref(L, LUA_REGISTRYINDEX); if (!host.setCmdLineAction(name, func)) { @@ -2830,11 +2973,19 @@ int TLuaInterpreter::setCmdLineAction(lua_State* L) int TLuaInterpreter::setCmdLineStyleSheet(lua_State* L) { const int n = lua_gettop(L); - QString name = "main"; - if (n > 1) { - name = getVerifiedString(L, __func__, 1, "command line name", true); + // The mandatory stylesheet is last, but with no arguments at all that would + // be index 0 - not a valid Lua stack index, and Lua 5.1 hands back the first + // free slot for it rather than complaining: + const int styleSheetIndex = qMax(n, 1); + if (n > 1 && !checkStringArg(L, __func__, 1, "command line name", true)) { + return lua_error(L); } - const QString styleSheet = getVerifiedString(L, __func__, n, "StyleSheet"); + if (!checkStringArg(L, __func__, styleSheetIndex, "StyleSheet")) { + return lua_error(L); + } + + const QString name = (n > 1) ? QString{lua_tostring(L, 1)} : qsl("main"); + const QString styleSheet{lua_tostring(L, styleSheetIndex)}; const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->setCmdLineStyleSheet(name, styleSheet); !success) { @@ -2845,12 +2996,33 @@ int TLuaInterpreter::setCmdLineStyleSheet(lua_State* L) return 1; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getCmdLineStyleSheet +int TLuaInterpreter::getCmdLineStyleSheet(lua_State* L) +{ + // an explicit nil means "the main command line", as it does for the window + // name of every other getter that takes an optional one + const bool hasName = lua_gettop(L) > 0 && !lua_isnil(L, 1); + if (hasName && !checkStringArg(L, __func__, 1, "command line name")) { + return lua_error(L); + } + + const QString name = hasName ? QString{lua_tostring(L, 1)} : qsl("main"); + const Host& host = getHostFromLua(L); + + if (auto styleSheet = host.mpConsole->getCmdLineStyleSheet(name)) { + lua_pushstring(L, styleSheet->toUtf8().constData()); + return 1; + } + + return warnArgumentValue(L, __func__, qsl("command-line name '%1' not found").arg(name)); +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setFont int TLuaInterpreter::setFont(lua_State* L) { Host& host = getHostFromLua(L); - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name @@ -2891,7 +3063,7 @@ int TLuaInterpreter::setFont(lua_State* L) // For Qt 6.9+, emoji font support is handled globally in FontManager::addEmojiFont() #endif - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); if (console == host.mpConsole) { // apply changes to main console and its while-scrolling component too. QFont newFont = host.createFontWithSettings(effectiveFontName, host.getDisplayFont().pointSize()); @@ -2926,7 +3098,7 @@ int TLuaInterpreter::setFontSize(lua_State* L) { Host& host = getHostFromLua(L); - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); @@ -2938,7 +3110,7 @@ int TLuaInterpreter::setFontSize(lua_State* L) return warnArgumentValue(L, __func__, "size cannot be 0 or negative"); } - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); if (console == host.mpConsole) { // get host profile display font and alter it, since that is how it's done in Settings. host.setDisplayFontSize(size); @@ -2952,13 +3124,13 @@ int TLuaInterpreter::setFontSize(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setItalics int TLuaInterpreter::setItalics(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); } const bool isAttributeEnabled = getVerifiedBool(L, __func__, s, "enable italic attribute"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setDisplayAttributes(TChar::Italic, isAttributeEnabled); lua_pushboolean(L, true); return 1; @@ -2967,12 +3139,15 @@ int TLuaInterpreter::setItalics(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelToolTip int TLuaInterpreter::setLabelToolTip(lua_State* L) { - const QString labelName = getVerifiedString(L, __func__, 1, "label name"); - const QString labelToolTip = getVerifiedString(L, __func__, 2, "text"); + if (!checkStringArg(L, __func__, 1, "label name") || !checkStringArg(L, __func__, 2, "text")) { + return lua_error(L); + } double duration = 0; if (lua_gettop(L) > 2) { duration = getVerifiedDouble(L, __func__, 3, "duration"); } + const QString labelName{lua_tostring(L, 1)}; + const QString labelToolTip{lua_tostring(L, 2)}; const Host& host = getHostFromLua(L); @@ -2984,47 +3159,67 @@ int TLuaInterpreter::setLabelToolTip(lua_State* L) return 1; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getLabelToolTip +int TLuaInterpreter::getLabelToolTip(lua_State* L) +{ + const QString labelName = getVerifiedString(L, __func__, 1, "label name"); + if (labelName.isEmpty()) { + return warnArgumentValue(L, __func__, "a label cannot have an empty string as its name"); + } + + const Host& host = getHostFromLua(L); + if (auto toolTip = host.mpConsole->getLabelToolTip(labelName)) { + lua_pushstring(L, toolTip->toUtf8().constData()); + return 1; + } + + return warnArgumentValue(L, __func__, qsl("label name '%1' not found").arg(labelName)); +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelClickCallback int TLuaInterpreter::setLabelClickCallback(lua_State* L) { - return setLabelCallback(L, qsl("setLabelClickCallback")); + return setLabelCallback(L, "setLabelClickCallback"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelDoubleClickCallback int TLuaInterpreter::setLabelDoubleClickCallback(lua_State* L) { - return setLabelCallback(L, qsl("setLabelDoubleClickCallback")); + return setLabelCallback(L, "setLabelDoubleClickCallback"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelMoveCallback int TLuaInterpreter::setLabelMoveCallback(lua_State* L) { - return setLabelCallback(L, qsl("setLabelMoveCallback")); + return setLabelCallback(L, "setLabelMoveCallback"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelOnEnter int TLuaInterpreter::setLabelOnEnter(lua_State* L) { - return setLabelCallback(L, qsl("setLabelOnEnter")); + return setLabelCallback(L, "setLabelOnEnter"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelOnLeave int TLuaInterpreter::setLabelOnLeave(lua_State* L) { - return setLabelCallback(L, qsl("setLabelOnLeave")); + return setLabelCallback(L, "setLabelOnLeave"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelReleaseCallback int TLuaInterpreter::setLabelReleaseCallback(lua_State* L) { - return setLabelCallback(L, qsl("setLabelReleaseCallback")); + return setLabelCallback(L, "setLabelReleaseCallback"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelStyleSheet int TLuaInterpreter::setLabelStyleSheet(lua_State* L) { - const QString labelName = getVerifiedString(L, __func__, 1, "label name"); - const QString stylesheet = getVerifiedString(L, __func__, 2, "stylesheet"); + if (!checkStringArg(L, __func__, 1, "label name") || !checkStringArg(L, __func__, 2, "stylesheet")) { + return lua_error(L); + } + const QString labelName{lua_tostring(L, 1)}; + const QString stylesheet{lua_tostring(L, 2)}; const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->setLabelStyleSheet(labelName, stylesheet); !success) { @@ -3038,8 +3233,11 @@ int TLuaInterpreter::setLabelStyleSheet(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelCursor int TLuaInterpreter::setLabelCursor(lua_State* L) { - const QString labelName = getVerifiedString(L, __func__, 1, "label name"); + if (!checkStringArg(L, __func__, 1, "label name")) { + return lua_error(L); + } const int labelCursor = getVerifiedInt(L, __func__, 2, "cursortype"); + const QString labelName{lua_tostring(L, 1)}; const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->setLabelCursor(labelName, labelCursor); !success) { @@ -3055,14 +3253,18 @@ int TLuaInterpreter::setLabelCustomCursor(lua_State* L) { const int n = lua_gettop(L); int hotX = -1, hotY = -1; - const QString labelName = getVerifiedString(L, __func__, 1, "label name"); - const QString pixmapLocation = getVerifiedString(L, __func__, 2, "custom cursor location"); + if (!checkStringArg(L, __func__, 1, "label name") || !checkStringArg(L, __func__, 2, "custom cursor location")) { + return lua_error(L); + } if (n > 2) { hotX = getVerifiedInt(L, __func__, 3, "hot spot x-coordinate"); hotY = getVerifiedInt(L, __func__, 4, "hot spot y-coordinate"); } + const QString labelName{lua_tostring(L, 1)}; + const QString pixmapLocation{lua_tostring(L, 2)}; + const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->setLabelCustomCursor(labelName, pixmapLocation, hotX, hotY); !success) { @@ -3076,32 +3278,40 @@ int TLuaInterpreter::setLabelCustomCursor(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLabelWheelCallback int TLuaInterpreter::setLabelWheelCallback(lua_State* L) { - return setLabelCallback(L, qsl("setLabelWheelCallback")); + return setLabelCallback(L, "setLabelWheelCallback"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setLink int TLuaInterpreter::setLink(lua_State* L) { - QString windowName = qsl("main"); + const char* windowName = "main"; int s = 0; if (lua_gettop(L) > 2) { windowName = WINDOW_NAME(L, ++s); } + int commandPos = ++s; + if (!checkCommandOrFunctionArg(L, __func__, commandPos)) { + return lua_error(L); + } + const int hintPos = ++s; + if (!checkStringArg(L, __func__, hintPos, "tooltip")) { + return lua_error(L); + } + QString command; int luaReference = 0; - parseCommandOrFunction(L, __func__, ++s, command, luaReference); - const QString hint = getVerifiedString(L, __func__, ++s, "tooltip"); + parseCommandOrFunction(L, __func__, commandPos, command, luaReference); const Host& host = getHostFromLua(L); QStringList commandList; QStringList hintList; QVector<int> luaReferences; commandList << command; - hintList << hint; + hintList << QString{lua_tostring(L, hintPos)}; luaReferences << luaReference; - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setLink(commandList, hintList, luaReferences); if (console != host.mpConsole) { console->mUpperPane->forceUpdate(); @@ -3138,14 +3348,33 @@ int TLuaInterpreter::setMapWindowTitle(lua_State* L) return 1; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getMapWindowTitle +int TLuaInterpreter::getMapWindowTitle(lua_State* L) +{ + const Host& host = getHostFromLua(L); + + if (auto title = host.getMapperTitle()) { + lua_pushstring(L, title->toUtf8().constData()); + return 1; + } + + return warnArgumentValue(L, __func__, "no floating/dockable type map window found"); +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setMovie int TLuaInterpreter::setMovie(lua_State* L) { - const QString labelName = getVerifiedString(L, __func__, 1, "label name"); - if (labelName.isEmpty()) { + if (!checkStringArg(L, __func__, 1, "label name")) { + return lua_error(L); + } + if (const QString labelName{lua_tostring(L, 1)}; labelName.isEmpty()) { return warnArgumentValue(L, __func__, "label name cannot be an empty string"); } - const QString moviePath = getVerifiedString(L, __func__, 2, "movie (gif) path"); + if (!checkStringArg(L, __func__, 2, "movie (gif) path")) { + return lua_error(L); + } + const QString labelName{lua_tostring(L, 1)}; + const QString moviePath{lua_tostring(L, 2)}; Host& host = getHostFromLua(L); if (auto [success, message] = host.setMovie(labelName, moviePath); !success) { @@ -3158,25 +3387,25 @@ int TLuaInterpreter::setMovie(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setMovieFrame int TLuaInterpreter::setMovieFrame(lua_State* L) { - return movieFunc(L, qsl("setMovieFrame")); + return movieFunc(L, "setMovieFrame"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setMovieSpeed int TLuaInterpreter::setMovieSpeed(lua_State* L) { - return movieFunc(L, qsl("setMovieSpeed")); + return movieFunc(L, "setMovieSpeed"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setOverline int TLuaInterpreter::setOverline(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); } const bool isAttributeEnabled = getVerifiedBool(L, __func__, s, "enable overline attribute"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setDisplayAttributes(TChar::Overline, isAttributeEnabled); lua_pushboolean(L, true); return 1; @@ -3185,18 +3414,24 @@ int TLuaInterpreter::setOverline(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setPopup int TLuaInterpreter::setPopup(lua_State* L) { - QString windowName = qsl("main"); + const char* windowName = "main"; int s = 0; if (lua_gettop(L) > 2) { windowName = WINDOW_NAME(L, ++s); } + int commandPos = ++s; + int hintPos = ++s; + if (!checkCommandsOrFunctionsTable(L, __func__, commandPos) || !checkHintsTable(L, __func__, hintPos)) { + return lua_error(L); + } + QStringList commandList; QVector<int> luaReferences; - parseCommandsOrFunctionsTable(L, __func__, ++s, commandList, luaReferences); + parseCommandsOrFunctionsTable(L, __func__, commandPos, commandList, luaReferences); QStringList hintList; - parseHintsTable(L, __func__, ++s, hintList); + parseHintsTable(L, __func__, hintPos, hintList); if ((hintList.size() - commandList.size()) < 0 || (hintList.size() - commandList.size()) > 1) { lua_pushnil(L); @@ -3208,7 +3443,7 @@ int TLuaInterpreter::setPopup(lua_State* L) } const Host& host = getHostFromLua(L); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setLink(commandList, hintList, luaReferences); if (console != host.mpConsole) { console->mUpperPane->forceUpdate(); @@ -3231,13 +3466,13 @@ int TLuaInterpreter::setProfileStyleSheet(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setReverse int TLuaInterpreter::setReverse(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); } const bool isAttributeEnabled = getVerifiedBool(L, __func__, s, "enable reverse attribute"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setDisplayAttributes(TChar::Reverse, isAttributeEnabled); lua_pushboolean(L, true); return 1; @@ -3246,13 +3481,13 @@ int TLuaInterpreter::setReverse(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setStrikeOut int TLuaInterpreter::setStrikeOut(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); } const bool isAttributeEnabled = getVerifiedBool(L, __func__, s, "enable strikeout attribute"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setDisplayAttributes(TChar::StrikeOut, isAttributeEnabled); lua_pushboolean(L, true); return 1; @@ -3265,9 +3500,16 @@ int TLuaInterpreter::setTextFormat(lua_State* L) const int n = lua_gettop(L); - const QString windowName{WINDOW_NAME(L, 1)}; + // Every argument check below can raise a Lua error, and lua_error() longjmps + // past C++ destructors - so nothing holding heap memory may be alive while they + // run: the window name stays the Lua-owned string anchored at stack index 1 and + // the colour components a plain array until the last check has passed. The + // blinkMode QString further down is exempt only because it holds a + // QStringLiteral until after the last raise; give it a computed default and the + // leak comes back + const char* windowNameCString = WINDOW_NAME(L, 1); - QVector<int> colorComponents(6); // 0-2 RGB background, 3-5 RGB foreground + std::array<int, 6> colorComponents{}; // 0-2 RGB background, 3-5 RGB foreground colorComponents[0] = qRound(qBound(0.0, getVerifiedDouble(L, __func__, 2, "red background color component"), 255.0)); colorComponents[1] = qRound(qBound(0.0, getVerifiedDouble(L, __func__, 3, "green background color component"), 255.0)); colorComponents[2] = qRound(qBound(0.0, getVerifiedDouble(L, __func__, 4, "blue background color component"), 255.0)); @@ -3368,8 +3610,8 @@ int TLuaInterpreter::setTextFormat(lua_State* L) | (reverse ? TChar::Reverse : TChar::None) | (strikeout ? TChar::StrikeOut : TChar::None) | (underline ? TChar::Underline : TChar::None) | (fastBlink ? TChar::FastBlink : (slowBlink ? TChar::Blink : TChar::None)); - if (!host.mpConsole->setTextFormat( - windowName, QColor(colorComponents.at(3), colorComponents.at(4), colorComponents.at(5)), QColor(colorComponents.at(0), colorComponents.at(1), colorComponents.at(2)), flags)) { + const QString windowName{windowNameCString}; + if (!host.mpConsole->setTextFormat(windowName, QColor(colorComponents[3], colorComponents[4], colorComponents[5]), QColor(colorComponents[0], colorComponents[1], colorComponents[2]), flags)) { return warnArgumentValue(L, __func__, qsl("window '%1' does not exist").arg(windowName), true); } @@ -3380,13 +3622,13 @@ int TLuaInterpreter::setTextFormat(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setUnderline int TLuaInterpreter::setUnderline(lua_State* L) { - QString windowName; + const char* windowName = ""; int s = 1; if (lua_gettop(L) > 1) { // Have more than one argument so first must be a console name windowName = WINDOW_NAME(L, s++); } const bool isAttributeEnabled = getVerifiedBool(L, __func__, s, "enable underline attribute"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setDisplayAttributes(TChar::Underline, isAttributeEnabled); lua_pushboolean(L, true); return 1; @@ -3395,10 +3637,18 @@ int TLuaInterpreter::setUnderline(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setUserWindowTitle int TLuaInterpreter::setUserWindowTitle(lua_State* L) { - const QString name = getVerifiedString(L, __func__, 1, "name"); + const int n = lua_gettop(L); + if (!checkStringArg(L, __func__, 1, "name")) { + return lua_error(L); + } + if (n > 1 && !checkStringArg(L, __func__, 2, "title", true)) { + return lua_error(L); + } + + const QString name{lua_tostring(L, 1)}; QString title; - if (lua_gettop(L) > 1) { - title = getVerifiedString(L, __func__, 2, "title", true); + if (n > 1) { + title = lua_tostring(L, 2); } const Host& host = getHostFromLua(L); @@ -3410,11 +3660,29 @@ int TLuaInterpreter::setUserWindowTitle(lua_State* L) return 1; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getUserWindowTitle +int TLuaInterpreter::getUserWindowTitle(lua_State* L) +{ + const QString name = getVerifiedString(L, __func__, 1, "name"); + const Host& host = getHostFromLua(L); + + auto [success, result] = host.mpConsole->getUserWindowTitle(name); + if (!success) { + return warnArgumentValue(L, __func__, result); + } + + lua_pushstring(L, result.toUtf8().constData()); + return 1; +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setUserWindowStyleSheet int TLuaInterpreter::setUserWindowStyleSheet(lua_State* L) { - const QString userWindowName = getVerifiedString(L, __func__, 1, "userwindow name"); - const QString userWindowStyleSheet = getVerifiedString(L, __func__, 2, "StyleSheet"); + if (!checkStringArg(L, __func__, 1, "userwindow name") || !checkStringArg(L, __func__, 2, "StyleSheet")) { + return lua_error(L); + } + const QString userWindowName{lua_tostring(L, 1)}; + const QString userWindowStyleSheet{lua_tostring(L, 2)}; const Host& host = getHostFromLua(L); if (auto [success, message] = host.mpConsole->setUserWindowStyleSheet(userWindowName, userWindowStyleSheet); !success) { @@ -3425,6 +3693,23 @@ int TLuaInterpreter::setUserWindowStyleSheet(lua_State* L) return 1; } +// Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#getUserWindowStyleSheet +int TLuaInterpreter::getUserWindowStyleSheet(lua_State* L) +{ + const QString userWindowName = getVerifiedString(L, __func__, 1, "userwindow name"); + if (userWindowName.isEmpty()) { + return warnArgumentValue(L, __func__, "a userwindow cannot have an empty string as its name"); + } + + const Host& host = getHostFromLua(L); + if (auto styleSheet = host.mpConsole->getUserWindowStyleSheet(userWindowName)) { + lua_pushstring(L, styleSheet->toUtf8().constData()); + return 1; + } + + return warnArgumentValue(L, __func__, qsl("userwindow name '%1' not found").arg(userWindowName)); +} + // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setWindow int TLuaInterpreter::setWindow(lua_State* L) { @@ -3432,13 +3717,12 @@ int TLuaInterpreter::setWindow(lua_State* L) int x = 0, y = 0; bool show = true; - const QString windowname{WINDOW_NAME(L, 1)}; + const char* windownameArg = WINDOW_NAME(L, 1); if (lua_type(L, 2) != LUA_TSTRING) { lua_pushfstring(L, "setWindow: bad argument #2 type (element name as string expected, got %s!)", luaL_typename(L, 2)); return lua_error(L); } - const QString name{lua_tostring(L, 2)}; if (n > 2) { x = getVerifiedInt(L, __func__, 3, "x-coordinate"); @@ -3446,6 +3730,9 @@ int TLuaInterpreter::setWindow(lua_State* L) show = getVerifiedBool(L, __func__, 5, "show element"); } + const QString windowname{windownameArg}; + const QString name{lua_tostring(L, 2)}; + Host& host = getHostFromLua(L); if (auto [success, message] = host.setWindow(windowname, name, x, y, show); !success) { return warnArgumentValue(L, __func__, message); @@ -3458,16 +3745,23 @@ int TLuaInterpreter::setWindow(lua_State* L) int TLuaInterpreter::setWindowWrap(lua_State* L) { int s = 1; - QString windowName; + const char* windowName = ""; if (lua_gettop(L) > 1) { windowName = WINDOW_NAME(L, s++); } const int luaFrom = getVerifiedInt(L, __func__, s, "wrapAt"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); + if (luaFrom < 1) { + // a width of zero or less cannot hold a single character, so nothing + // could be displayed in such a window - the preferences dialog does not + // offer these values either + return warnArgumentValue(L, __func__, qsl("wrapAt must be greater than zero, got %1").arg(luaFrom)); + } console->setWrapAt(luaFrom); - // only mirror values the preferences dialog itself accepts into the - // profile, otherwise an invalid width would reach NAWS and get saved - if (luaFrom >= 1 && console->getType() == TConsole::MainConsole) { + // only the main console's width belongs to the profile - it is what the + // preferences dialog shows, what NEW-ENVIRON reports as WORD_WRAP and what + // caps the width NAWS reports to the game + if (console->getType() == TConsole::MainConsole) { Host& host = getHostFromLua(L); const int priorWrapAt = host.mWrapAt; host.mWrapAt = luaFrom; @@ -3476,15 +3770,16 @@ int TLuaInterpreter::setWindowWrap(lua_State* L) } host.updateDisplayDimensions(); } - return 0; + lua_pushboolean(L, true); + return 1; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setWindowWrapIndent int TLuaInterpreter::setWindowWrapIndent(lua_State* L) { - const QString windowName{WINDOW_NAME(L, 1)}; + const char* windowName = WINDOW_NAME(L, 1); const int luaFrom = getVerifiedInt(L, __func__, 2, "wrapTo"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setIndentCount(luaFrom); if (luaFrom >= 0 && console->getType() == TConsole::MainConsole) { Host& host = getHostFromLua(L); @@ -3496,9 +3791,9 @@ int TLuaInterpreter::setWindowWrapIndent(lua_State* L) //Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#setWindowWrapHangingIndent int TLuaInterpreter::setWindowWrapHangingIndent(lua_State* L) { - const QString windowName{WINDOW_NAME(L, 1)}; + const char* windowName = WINDOW_NAME(L, 1); const int luaFrom = getVerifiedInt(L, __func__, 2, "wrapTo"); - auto console = CONSOLE(L, windowName); + auto console = CONSOLE(L, QString{windowName}); console->setHangingIndentCount(luaFrom); if (luaFrom >= 0 && console->getType() == TConsole::MainConsole) { Host& host = getHostFromLua(L); @@ -3519,7 +3814,7 @@ int TLuaInterpreter::showWindow(lua_State* L) // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#startMovie int TLuaInterpreter::startMovie(lua_State* L) { - return movieFunc(L, qsl("startMovie")); + return movieFunc(L, "startMovie"); } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#showToolBar @@ -3536,7 +3831,7 @@ int TLuaInterpreter::showToolBar(lua_State* L) int TLuaInterpreter::setCommandBackgroundColor(lua_State* L) { Host& host = getHostFromLua(L); - QString windowName; + const char* windowNameArg = ""; int r, alpha; int s = 1; @@ -3545,7 +3840,7 @@ int TLuaInterpreter::setCommandBackgroundColor(lua_State* L) }; if (lua_type(L, s) == LUA_TSTRING) { - windowName = WINDOW_NAME(L, s++); + windowNameArg = WINDOW_NAME(L, s++); r = getVerifiedInt(L, __func__, s, "red value 0-255"); if (!validRange(r)) { return warnArgumentValue(L, __func__, csmInvalidRedValue.arg(r)); @@ -3579,6 +3874,7 @@ int TLuaInterpreter::setCommandBackgroundColor(lua_State* L) } } + const QString windowName{windowNameArg}; if (isMain(windowName)) { host.mCommandBgColor.setRgb(r, g, b, alpha); host.mpConsole->setCommandBgColor(r, g, b, alpha); @@ -3593,7 +3889,7 @@ int TLuaInterpreter::setCommandBackgroundColor(lua_State* L) int TLuaInterpreter::setCommandForegroundColor(lua_State* L) { Host& host = getHostFromLua(L); - QString windowName; + const char* windowNameArg = ""; int r, alpha; int s = 1; @@ -3602,7 +3898,7 @@ int TLuaInterpreter::setCommandForegroundColor(lua_State* L) }; if (lua_type(L, s) == LUA_TSTRING) { - windowName = WINDOW_NAME(L, s++); + windowNameArg = WINDOW_NAME(L, s++); r = getVerifiedInt(L, __func__, s, "red value 0-255"); if (!validRange(r)) { return warnArgumentValue(L, __func__, csmInvalidRedValue.arg(r)); @@ -3636,6 +3932,7 @@ int TLuaInterpreter::setCommandForegroundColor(lua_State* L) } } + const QString windowName{windowNameArg}; if (isMain(windowName)) { host.mCommandFgColor.setRgb(r, g, b, alpha); host.mpConsole->setCommandFgColor(r, g, b, alpha); @@ -3655,18 +3952,21 @@ int TLuaInterpreter::scrollTo(lua_State* L) const int n = lua_gettop(L); if (n == 2) { - windowName = getVerifiedString(L, __func__, 1, "window name", true); + if (!checkStringArg(L, __func__, 1, "window name", true)) { + return lua_error(L); + } targetLine = getVerifiedInt(L, __func__, 2, "line to scroll to"); + windowName = lua_tostring(L, 1); } else if (n == 1) { if (lua_isnumber(L, 1)) { - windowName = QLatin1String("main"); targetLine = getVerifiedInt(L, __func__, 1, "line to scroll to"); + windowName = qsl("main"); } else { windowName = getVerifiedString(L, __func__, 1, "window name", true); stopScrolling = true; } } else if (n == 0) { - windowName = QLatin1String("main"); + windowName = qsl("main"); stopScrolling = true; } @@ -3714,19 +4014,19 @@ int TLuaInterpreter::windowType(lua_State* L) } lua_pushnil(L); - lua_pushfstring(L, "'%s' is not a known label, any type of console, nor command line", windowName.toUtf8().constData()); + lua_pushfstring(L, "'%s' is not a known label, any type of console, command line, text edit, nor scroll box", windowName.toUtf8().constData()); return 2; } // Documentation: https://wiki.mudlet.org/w/Manual:Lua_Functions#wrapLine int TLuaInterpreter::wrapLine(lua_State* L) { - int s = 1; - QString windowName = qsl("main"); - if (lua_gettop(L)) { - windowName = getVerifiedString(L, __func__, s++, "window name"); + const bool hasWindowName = (lua_gettop(L) != 0); + if (hasWindowName && !checkStringArg(L, __func__, 1, "window name")) { + return lua_error(L); } - const int lineNumber = getVerifiedInt(L, __func__, s, "line"); + const int lineNumber = getVerifiedInt(L, __func__, hasWindowName ? 2 : 1, "line"); + QString windowName = hasWindowName ? QString{lua_tostring(L, 1)} : qsl("main"); const Host& host = getHostFromLua(L); host.mpConsole->luaWrapLine(windowName, lineNumber); @@ -3751,9 +4051,7 @@ int TLuaInterpreter::enableScrolling(lua_State* L) { const QString windowName{WINDOW_NAME(L, 1)}; if (windowName.compare(qsl("main"), Qt::CaseSensitive) == 0) { - lua_pushnil(L); - lua_pushfstring(L, "scrolling cannot be enabled/disabled for the 'main' window", windowName.toUtf8().constData()); - return 2; + return warnArgumentValue(L, __func__, "scrolling cannot be enabled/disabled for the 'main' window"); } auto console = CONSOLE(L, windowName); @@ -3767,9 +4065,7 @@ int TLuaInterpreter::disableScrolling(lua_State* L) { const QString windowName{WINDOW_NAME(L, 1)}; if (windowName.compare(qsl("main"), Qt::CaseSensitive) == 0) { - lua_pushnil(L); - lua_pushfstring(L, "scrolling cannot be enabled/disabled for the 'main' window", windowName.toUtf8().constData()); - return 2; + return warnArgumentValue(L, __func__, "scrolling cannot be enabled/disabled for the 'main' window"); } auto console = CONSOLE(L, windowName); @@ -3794,42 +4090,62 @@ int TLuaInterpreter::scrollingActive(lua_State* L) } // No documentation available in wiki - internal function -int TLuaInterpreter::movieFunc(lua_State* L, const QString& funcName) +// funcName is not a QString because a QByteArray made from one would still be +// alive inside the raising checks below - see checkStringArg() +int TLuaInterpreter::movieFunc(lua_State* L, const char* funcName) { - const QString labelName = getVerifiedString(L, funcName.toUtf8().constData(), 1, "label name"); - if (labelName.isEmpty()) { - return warnArgumentValue(L, __func__, "label name cannot be an empty string"); + if (!checkStringArg(L, funcName, 1, "label name")) { + return lua_error(L); } - auto pN = LABEL(L, labelName); - auto movie = pN->movie(); - if (!movie) { - return warnArgumentValue(L, __func__, qsl("no movie found at label '%1'").arg(labelName)); + const QLatin1StringView func{funcName}; + + TLabel* pN = nullptr; + QMovie* movie = nullptr; + { + const QString labelName{lua_tostring(L, 1)}; + if (labelName.isEmpty()) { + return warnArgumentValue(L, __func__, "label name cannot be an empty string"); + } + pN = LABEL(L, labelName); + movie = pN->movie(); + if (!movie) { + return warnArgumentValue(L, __func__, qsl("no movie found at label '%1'").arg(labelName)); + } } - if (funcName == qsl("startMovie")) { + if (func == qsl("startMovie")) { movie->start(); - } else if (funcName == qsl("pauseMovie")) { + } else if (func == qsl("pauseMovie")) { movie->setPaused(true); - } else if (funcName == qsl("setMovieFrame")) { - const int frame = getVerifiedInt(L, funcName.toUtf8().constData(), 2, "movie frame number"); - lua_pushboolean(L, movie->jumpToFrame(frame)); + } else if (func == qsl("setMovieFrame")) { + if (!checkIntArg(L, funcName, 2, "movie frame number")) { + return lua_error(L); + } + lua_pushboolean(L, movie->jumpToFrame(static_cast<int>(lua_tointeger(L, 2)))); return 1; - } else if (funcName == qsl("setMovieSpeed")) { - const int speed = getVerifiedInt(L, funcName.toUtf8().constData(), 2, "movie playback speed in %"); - movie->setSpeed(speed); - } else if (funcName == qsl("scaleMovie")) { + } else if (func == qsl("setMovieSpeed")) { + if (!checkIntArg(L, funcName, 2, "movie playback speed in %")) { + return lua_error(L); + } + movie->setSpeed(static_cast<int>(lua_tointeger(L, 2))); + } else if (func == qsl("scaleMovie")) { bool autoScale{true}; const int n = lua_gettop(L); if (n > 1) { - autoScale = getVerifiedBool(L, funcName.toUtf8().constData(), 2, "activate/deactivate scaling movie", true); + if (!checkBoolArg(L, funcName, 2, "activate/deactivate scaling movie", true)) { + return lua_error(L); + } + autoScale = lua_toboolean(L, 2); } movie->setScaledSize(pN->size()); if (autoScale) { - connect(pN, &TLabel::resized, pN, [=] { + connect(pN, &TLabel::resized, movie, [=] { movie->setScaledSize(pN->size()); }); } else { - pN->disconnect(SIGNAL(resized())); + // only drop the movie-scaling connection(s); other consumers of + // the label's resized signal must stay connected + QObject::disconnect(pN, &TLabel::resized, movie, nullptr); } } else { return warnArgumentValue(L, __func__, qsl("'%1' is not a known function name - bug in Mudlet, please report it").arg(funcName)); diff --git a/src/TMainConsole.cpp b/src/TMainConsole.cpp index 327723f60..7485636c9 100644 --- a/src/TMainConsole.cpp +++ b/src/TMainConsole.cpp @@ -40,10 +40,15 @@ #include "mudlet.h" #include "GifTracker.h" +#include <QDialog> +#include <QDockWidget> +#include <QIcon> +#include <QLabel> #include <QLineEdit> #include <QMessageBox> #include <QMimeData> #include <QProgressDialog> +#include <QUiLoader> #include <QScrollBar> #include <QShortcut> #include <QSizePolicy> @@ -82,6 +87,30 @@ TMainConsole::TMainConsole(Host* pH, QWidget* parent) TMainConsole::~TMainConsole() { + // There is one window in which a command line's destroyed() handler is unsafe: + // after this console's members - mSubCommandLineMap among them - have been + // destroyed, but before ~QObject severs incoming connections. The only command + // lines that can be destroyed inside it are the ones QWidget::~QWidget deletes, + // i.e. this console's own children, so sweeping those is enough. Command lines + // created into a user window belong to a TDockWidget reparented onto the main + // window instead, and can only die after ~QObject has already dropped the + // connection. Children rather than map entries, because deleteCommandLine() and + // resetMainConsole() drop the entry while the widget lives on until its + // deferred delete is delivered. + for (auto commandLine : findChildren<TCommandLine*>()) { + disconnect(commandLine, &QObject::destroyed, this, nullptr); + } + mSubCommandLineMap.clear(); + + // Neither is a child of this console: the map dock is reparented onto the main + // window by addDockWidget(), and the unpacking dialog is parentless. So neither + // dies with the console automatically. + if (mpDockableMapWidget) { + mpDockableMapWidget->deleteLater(); + } + if (mpUnpackingDialog) { + mpUnpackingDialog->deleteLater(); + } if (mpHunspell_system) { Hunspell_destroy(mpHunspell_system); mpHunspell_system = nullptr; @@ -131,6 +160,16 @@ std::optional<QSize> TMainConsole::getLabelSizeHint(const QString& name) const return {}; } +std::optional<QString> TMainConsole::getLabelToolTip(const QString& name) const +{ + auto pL = mLabelMap.value(name); + if (!pL) { + return {}; + } + + return {pL->toolTip()}; +} + // NOLINTNEXTLINE(readability-make-member-function-const) std::pair<bool, QString> TMainConsole::setUserWindowStyleSheet(const QString& name, const QString& userWindowStyleSheet) { @@ -146,6 +185,16 @@ std::pair<bool, QString> TMainConsole::setUserWindowStyleSheet(const QString& na return {false, qsl("userwindow name '%1' not found").arg(name)}; } +std::optional<QString> TMainConsole::getUserWindowStyleSheet(const QString& name) const +{ + auto pW = mDockWidgetMap.value(name); + if (!pW) { + return {}; + } + + return {pW->styleSheet()}; +} + std::pair<bool, QString> TMainConsole::setCmdLineStyleSheet(const QString& name, const QString& styleSheet) { if (name.isEmpty() || !name.compare(qsl("main"))) { @@ -161,6 +210,23 @@ std::pair<bool, QString> TMainConsole::setCmdLineStyleSheet(const QString& name, return {false, qsl("command-line name '%1' not found").arg(name)}; } +std::optional<QString> TMainConsole::getCmdLineStyleSheet(const QString& name) const +{ + if (name.isEmpty() || !name.compare(qsl("main"))) { + if (auto pMain = mpHost->mpConsole->mpCommandLine) { + return {pMain->styleSheet()}; + } + return {}; + } + + auto pN = mSubCommandLineMap.value(name); + if (!pN) { + return {}; + } + + return {pN->styleSheet()}; +} + void TMainConsole::toggleLogging(bool isMessageEnabled) { const auto loggingPath = mudlet::getMudletPath(enums::profileDataItemPath, mpHost->getName(), qsl("autolog")); @@ -469,11 +535,10 @@ void TMainConsole::resetMainConsole() itDockWidget.remove(); } - QMutableMapIterator<QString, TCommandLine*> itCommandLine(mSubCommandLineMap); - while (itCommandLine.hasNext()) { - itCommandLine.next(); - itCommandLine.value()->deleteLater(); - itCommandLine.remove(); + const QList<TCommandLine*> commandLines = mSubCommandLineMap.values(); + for (auto commandLine : commandLines) { + deregisterSubCommandLine(commandLine); + commandLine->deleteLater(); } // Remaining SubConsole/Buffer entries (UserWindow ones were already removed above) @@ -688,8 +753,12 @@ std::pair<bool, QString> TMainConsole::deleteCommandLine(const QString& name) return {false, QLatin1String("a command line cannot have an empty string as its name")}; } - auto pCmdLine = mSubCommandLineMap.take(name); + auto pCmdLine = mSubCommandLineMap.value(name); if (pCmdLine) { + // Deregister rather than just take() the entry: the widget outlives this + // call until its deferred delete is delivered, and its destroyed() handler + // must not be left armed for a console that may be gone by then. + deregisterSubCommandLine(pCmdLine); // Using deleteLater() rather than delete as it seems a safer option // given that this item is likely to be linked to some events and // suchlike: @@ -828,7 +897,7 @@ std::pair<bool, QString> TMainConsole::setLabelCustomCursor(const QString& name, std::pair<bool, QString> TMainConsole::createMapper(const QString& windowname, int x, int y, int width, int height) { auto pW = mDockWidgetMap.value(windowname); - auto pM = mpHost->mpDockableMapWidget; + auto pM = mpDockableMapWidget; if (pM) { return {false, qsl("cannot create mapper. Do you already use a map window?")}; } @@ -863,12 +932,15 @@ std::pair<bool, QString> TMainConsole::createMapper(const QString& windowname, i } mpHost->mpMap->pushErrorMessagesToFile(tr("Loading map(2) at %1 report").arg(now.toString(Qt::ISODate)), true); - - TEvent mapOpenEvent{}; - mapOpenEvent.mArgumentList.append(QLatin1String("mapOpenEvent")); - mapOpenEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - mpHost->raiseEvent(mapOpenEvent); + } else { + mpMapper->updateAreaComboBox(); + mpMapper->resetAreaComboBoxToPlayerRoomArea(); } + + TEvent mapOpenEvent{}; + mapOpenEvent.mArgumentList.append(QLatin1String("mapOpenEvent")); + mapOpenEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mpHost->raiseEvent(mapOpenEvent); } mpMapper->resize(width, height); mpMapper->move(x, y); @@ -909,7 +981,7 @@ std::pair<bool, QString> TMainConsole::createCommandLine(const QString& windowna } else { pN = new TCommandLine(mpHost, name, TCommandLine::SubCommandLine, this, mpMainFrame); } - mSubCommandLineMap[name] = pN; + registerSubCommandLine(name, pN); pN->resize(width, height); pN->move(x, y); pN->show(); @@ -918,6 +990,37 @@ std::pair<bool, QString> TMainConsole::createCommandLine(const QString& windowna return {false, QLatin1String("couldn't create commandLine")}; } +void TMainConsole::registerSubCommandLine(const QString& name, TCommandLine* pCommandLine) +{ + if (auto pDisplaced = mSubCommandLineMap.value(name); pDisplaced && pDisplaced != pCommandLine) { + // Would otherwise be left connected but unreachable by name + deregisterSubCommandLine(pDisplaced); + } + mSubCommandLineMap[name] = pCommandLine; + + // A TCommandLine is always a child widget of something else - the miniconsole + // it is embedded in, or the user window / scroll box it was created into - so + // it can be destroyed without deleteCommandLine() ever being called, and this + // map does not hold QPointers. Without this the entry outlives the widget and + // every later lookup of the name reads freed memory; TConsole::setFont() walks + // the whole map, so even changing the display font in Preferences hits it. + connect(pCommandLine, &QObject::destroyed, this, [this, pCommandLine]() { + deregisterSubCommandLine(pCommandLine); + }); +} + +void TMainConsole::deregisterSubCommandLine(TCommandLine* pCommandLine) +{ + // This is the only destroyed() connection made from a command line to this + // console, so severing all of them is severing just that one. + disconnect(pCommandLine, &QObject::destroyed, this, nullptr); + // Erase by value rather than by name: a replacement command line may have been + // registered under the same name in the meantime and must be left in place. + mSubCommandLineMap.removeIf([pCommandLine](const auto& it) { + return it.value() == pCommandLine; + }); +} + std::pair<bool, QString> TMainConsole::createTextBox(const QString& windowname, const QString& name, int x, int y, int width, int height) { if (name.isEmpty()) { @@ -1034,32 +1137,32 @@ bool TMainConsole::lowerWindow(const QString& name) if (pC) { pC->lower(); - mpMainDisplay->lower(); + lowerMainDisplay(); return true; } if (pL) { pL->lower(); - mpMainDisplay->lower(); + lowerMainDisplay(); return true; } if (pM && !name.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) { pM->lower(); - mpMainDisplay->lower(); + lowerMainDisplay(); return true; } if (pS) { pS->lower(); - mpMainDisplay->lower(); + lowerMainDisplay(); return true; } if (pN) { pN->lower(); - mpMainDisplay->lower(); + lowerMainDisplay(); return true; } if (pT) { pT->lower(); - mpMainDisplay->lower(); + lowerMainDisplay(); return true; } return false; @@ -1361,6 +1464,32 @@ std::pair<bool, QString> TMainConsole::setUserWindowTitle(const QString& name, c return {false, qsl("internal error: TConsole \"%1\" is marked as a user window but does not have a TDockWidget to contain it").arg(name)}; } +// The title is in .second when .first is true, otherwise .second is why there +// is none. Mirrors setUserWindowTitle's checks in the same order and words, so +// that a miniconsole sharing the name is not reported as a missing window. +std::pair<bool, QString> TMainConsole::getUserWindowTitle(const QString& name) const +{ + if (name.isEmpty()) { + return {false, qsl("a user window cannot have an empty string as its name")}; + } + + auto pC = mSubConsoleMap.value(name); + if (!pC) { + return {false, qsl("user window name '%1' not found").arg(name)}; + } + + if (pC->getType() != UserWindow) { + return {false, qsl("\"%1\" is not a user window").arg(name)}; + } + + auto pD = mDockWidgetMap.value(name); + if (!pD) { + return {false, qsl("internal error: TConsole \"%1\" is marked as a user window but does not have a TDockWidget to contain it").arg(name)}; + } + + return {true, pD->windowTitle()}; +} + bool TMainConsole::setTextFormat(const QString& name, const QColor& fgColor, const QColor& bgColor, const TChar::AttributeFlags& flags) { if (name.isEmpty() || name.compare(qsl("main"), Qt::CaseSensitive) == 0) { @@ -1696,9 +1825,13 @@ void TMainConsole::showPackageDownloadProgress(const QString& title, const QStri qWarning() << "TMainConsole::showPackageDownloadProgress() WARNING - called with no host; ignoring the download-progress request."; return; } - // a second server-triggered download can arrive mid-download; without the - // close, the first dialog leaks frozen and its Cancel aborts the wrong download + // A second server-triggered download can arrive mid-download (e.g. a + // reconnect re-sends Client.GUI). QProgressDialog::close() emits canceled(), + // so closing the superseded dialog while it is still wired to + // slot_cancelPackageDownload() would abort the download this new dialog is + // about to track; detach it before closing. if (mpPackageDownloadProgressDialog) { + mpPackageDownloadProgressDialog->disconnect(); mpPackageDownloadProgressDialog->close(); } // placeholder range; reset by the first download-progress update @@ -1725,6 +1858,178 @@ void TMainConsole::closePackageDownloadProgress() } } +void TMainConsole::createMapProgressDialog(const QString& title, const QString& label, const QString& cancelButtonText, int minimum, int maximum) +{ + if (mpMapProgressDialog) { + mpMapProgressDialog->hide(); + mpMapProgressDialog->deleteLater(); + } + auto pHost = getHost(); + // If canceled() cannot be wired to the map, omit the cancel button rather + // than show one that does nothing. + const bool cancelWirable = pHost && !pHost->mpMap.isNull(); + // Deliberately not WA_DeleteOnClose: the JSON import keeps updating this + // dialog from a processEvents loop, so it must outlive a mid-operation + // dismissal; we delete it explicitly instead. + mpMapProgressDialog = new QProgressDialog(label, cancelWirable ? cancelButtonText : QString(), minimum, maximum, this); + mpMapProgressDialog->setWindowTitle(title); + mpMapProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png"))); + mpMapProgressDialog->setAutoClose(false); + mpMapProgressDialog->setAutoReset(false); + // QProgressDialog still emits canceled() on Escape or window-close even with + // no cancel button, so only connect it when the operation is cancelable; + // otherwise a non-cancelable import could be aborted by a spurious cancel. + if (cancelWirable && !cancelButtonText.isEmpty()) { + connect(mpMapProgressDialog, &QProgressDialog::canceled, pHost->mpMap.data(), &TMap::slot_mapProgressDialogCancelled); + } +} + +void TMainConsole::showMapTransferProgress(const QString& title, const QString& label, const QString& cancelButtonText) +{ + createMapProgressDialog(title, label, cancelButtonText, 0, 0); + mpMapProgressDialog->setMinimumWidth(300); + mpMapProgressDialog->setMinimumDuration(0); + mpMapProgressDialog->show(); +} + +void TMainConsole::showMapJsonProgress(const QString& title, const QString& label, const QString& cancelButtonText, int maximum) +{ + createMapProgressDialog(title, label, cancelButtonText, 0, maximum); + mpMapProgressDialog->setWindowModality(Qt::NonModal); + mpMapProgressDialog->setMinimumWidth(500); + mpMapProgressDialog->setMinimumDuration(1); +} + +void TMainConsole::setMapProgressDialogLabel(const QString& text) +{ + if (mpMapProgressDialog) { + mpMapProgressDialog->setLabelText(text); + } +} + +void TMainConsole::setMapProgressDialogRange(int minimum, int maximum) +{ + if (mpMapProgressDialog) { + mpMapProgressDialog->setRange(minimum, maximum); + } +} + +void TMainConsole::setMapProgressDialogValue(int value) +{ + if (mpMapProgressDialog) { + mpMapProgressDialog->setValue(value); + } +} + +void TMainConsole::disableMapProgressDialogCancel() +{ + if (mpMapProgressDialog) { + // Taking the button away does not stop a window-close from emitting + // canceled(), so drop the connection as well - by this point the + // operation can no longer be stopped. Only ours goes, leaving + // QProgressDialog's own canceled() -> cancel() wiring intact. + if (auto pHost = getHost(); pHost && !pHost->mpMap.isNull()) { + disconnect(mpMapProgressDialog, &QProgressDialog::canceled, pHost->mpMap.data(), &TMap::slot_mapProgressDialogCancelled); + } + mpMapProgressDialog->setCancelButton(nullptr); + } +} + +void TMainConsole::closeMapProgressDialog() +{ + if (mpMapProgressDialog) { + // hide() rather than close() so we don't re-enter QProgressDialog's + // closeEvent -> cancel() while a cancel is already being handled. + mpMapProgressDialog->hide(); + mpMapProgressDialog->deleteLater(); + // deleteLater() leaves the QPointer set until the event loop gets to + // run, which a synchronous JSON operation will not let it do, so forget + // the dialog now and make any late writes to it no-ops. + mpMapProgressDialog = nullptr; + } +} + +void TMainConsole::createMapperDock(const QString& title, const QString& objectName) +{ + mpDockableMapWidget = new QDockWidget(title); + mpDockableMapWidget->setObjectName(objectName); +} + +void TMainConsole::showMapperScriptReminder() +{ + QUiLoader loader; + QFile file(qsl(":/ui/lacking_mapper_script.ui")); + if (!file.open(QFile::ReadOnly)) { + qWarning() << "TMainConsole::showMapperScriptReminder() WARNING - failed to open lacking_mapper_script.ui for reading:" << file.errorString(); + return; + } + + auto dialog = qobject_cast<QDialog*>(loader.load(&file, mudlet::self())); + file.close(); + if (!dialog) { + qWarning() << "TMainConsole::showMapperScriptReminder() WARNING - could not load the mapping-script reminder dialog."; + return; + } + + connect(dialog, &QDialog::accepted, mudlet::self(), &mudlet::slot_openMappingScriptsPage); + + dialog->show(); + dialog->raise(); + dialog->activateWindow(); +} + +void TMainConsole::showUnpackingProgress(const QString& message, const QString& title) +{ + // deleteLater() not close(): the dialog is parentless with no WA_DeleteOnClose, + // so closing it would leak it once we overwrite the pointer below. + if (mpUnpackingDialog) { + mpUnpackingDialog->deleteLater(); + } + + QUiLoader loader; + QFile uiFile(qsl(":/ui/package_manager_unpack.ui")); + if (!uiFile.open(QFile::ReadOnly)) { + qWarning() << "TMainConsole::showUnpackingProgress() WARNING - failed to open package_manager_unpack.ui for reading:" << uiFile.errorString(); + return; + } + auto* pDialog = qobject_cast<QDialog*>(loader.load(&uiFile, nullptr)); + uiFile.close(); + if (!pDialog) { + qWarning() << "TMainConsole::showUnpackingProgress() WARNING - could not load the unpacking progress dialog."; + return; + } + mpUnpackingDialog = pDialog; + + // Trap: processEvents() below can deliver a re-entrant install (or its + // matching hide) that replaces or clears mpUnpackingDialog and disposes of + // this frame's dialog. Drive a local pointer, never the member, and bail if + // our dialog is taken out from under us. + QPointer<QDialog> dialog = pDialog; + + if (auto* pLabel = dialog->findChild<QLabel*>(qsl("label"))) { + pLabel->setText(message); + } + dialog->hide(); // Must hide to change WindowModality + dialog->setWindowTitle(title); + dialog->setWindowModality(Qt::ApplicationModal); + dialog->show(); + QCoreApplication::processEvents(); + if (!dialog) { + return; + } + dialog->raise(); + dialog->repaint(); // Force a redraw + QCoreApplication::processEvents(); // Try to ensure we are on top of any other dialogs and freshly drawn +} + +void TMainConsole::closeUnpackingProgress() +{ + if (mpUnpackingDialog) { + mpUnpackingDialog->deleteLater(); + mpUnpackingDialog = nullptr; + } +} + void TMainConsole::setupVideoOutput(TMediaPlayer* player, bool& setupSucceeded) { setupSucceeded = false; diff --git a/src/TMainConsole.h b/src/TMainConsole.h index 88358f6ce..403f16906 100644 --- a/src/TMainConsole.h +++ b/src/TMainConsole.h @@ -38,6 +38,8 @@ class TMediaPlayer; class TTextBox; +class QDialog; +class QDockWidget; class QProgressDialog; class TMainConsole : public TConsole @@ -68,14 +70,19 @@ public: QString getCurrentLine(const std::string&); TConsole* createBuffer(const QString& name); std::pair<bool, QString> setUserWindowStyleSheet(const QString& name, const QString& userWindowStyleSheet); + std::optional<QString> getUserWindowStyleSheet(const QString& name) const; std::pair<bool, QString> setUserWindowTitle(const QString& name, const QString& text); + std::pair<bool, QString> getUserWindowTitle(const QString& name) const; bool setTextFormat(const QString& name, const QColor& fgColor, const QColor& bgColor, const TChar::AttributeFlags& flags); TLabel* createLabel(const QString& windowname, const QString& name, int x, int y, int width, int height, bool fillBackground, bool clickThrough = false); std::pair<bool, QString> createMapper(const QString& windowname, int, int, int, int); std::pair<bool, QString> createCommandLine(const QString& windowname, const QString& name, int, int, int, int); + void registerSubCommandLine(const QString& name, TCommandLine* pCommandLine); + void deregisterSubCommandLine(TCommandLine* pCommandLine); std::pair<bool, QString> createTextBox(const QString& windowname, const QString& name, int, int, int, int); QSize getUserWindowSize(const QString& windowname) const; std::pair<bool, QString> setCmdLineStyleSheet(const QString& name, const QString& styleSheet); + std::optional<QString> getCmdLineStyleSheet(const QString& name) const; std::pair<bool, QString> setLabelStyleSheet(const QString& name, const QString& stylesheet); std::optional<QString> getLabelStyleSheet(const QString& name) const; std::optional<QSize> getLabelSizeHint(const QString& name) const; @@ -85,6 +92,7 @@ public: std::pair<bool, QString> deleteTextBox(const QString&); std::pair<bool, QString> deleteScrollBox(const QString&); std::pair<bool, QString> setLabelToolTip(const QString& name, const QString& text, double duration); + std::optional<QString> getLabelToolTip(const QString& name) const; std::pair<bool, QString> setLabelCursor(const QString& name, int shape); std::pair<bool, QString> setLabelCustomCursor(const QString& name, const QString& pixMapLocation, int hotX, int hotY); bool setBackgroundImage(const QString& name, const QString& path); @@ -95,6 +103,17 @@ public: void showPackageDownloadProgress(const QString& title, const QString& cancelText); void updatePackageDownloadProgress(qint64 got, qint64 total); void closePackageDownloadProgress(); + void showMapTransferProgress(const QString& title, const QString& label, const QString& cancelButtonText); + void showMapJsonProgress(const QString& title, const QString& label, const QString& cancelButtonText, int maximum); + void setMapProgressDialogLabel(const QString& text); + void setMapProgressDialogRange(int minimum, int maximum); + void setMapProgressDialogValue(int value); + void disableMapProgressDialogCancel(); + void closeMapProgressDialog(); + void createMapperDock(const QString& title, const QString& objectName); + void showMapperScriptReminder(); + void showUnpackingProgress(const QString& message, const QString& title); + void closeUnpackingProgress(); void setupVideoOutput(TMediaPlayer* player, bool& setupSucceeded); void hideVideoOutput(TMediaPlayer* player); const QString& getSystemSpellDictionary() const { return mSpellDic; } @@ -131,6 +150,12 @@ public: QTextStream mLogStream; bool mLogToLogFile = false; QPointer<QProgressDialog> mpPackageDownloadProgressDialog; + QPointer<QProgressDialog> mpMapProgressDialog; + // Outlives Host::closeMapWidget(), which only hides it, so this being + // non-null says the profile has made a map widget at some point, not that it + // has one on screen - see Host::mapWidget() for the latter. + QPointer<QDockWidget> mpDockableMapWidget; + QPointer<QDialog> mpUnpackingDialog; public slots: @@ -148,6 +173,8 @@ signals: private: + void createMapProgressDialog(const QString& title, const QString& label, const QString& cancelButtonText, int minimum, int maximum); + // Was public in Host class but made private there and cloned to here // (for main TConsole) to prevent it being changed without going through the // process to load in the changed dictionary: diff --git a/src/TMap.cpp b/src/TMap.cpp index 5ea740084..4878691d9 100644 --- a/src/TMap.cpp +++ b/src/TMap.cpp @@ -39,15 +39,14 @@ #include <QBuffer> #include <QDataStream> #include <QElapsedTimer> -#include <QFileDialog> #include <QJsonArray> #include <QJsonDocument> #include <QJsonObject> #include <QJsonParseError> #include <QJsonValue> +#include <QMetaMethod> #include <QPainter> #include <QPixmap> -#include <QProgressDialog> #include <QSaveFile> #include <QSizeF> #include <chrono> @@ -1010,7 +1009,7 @@ bool TMap::findPath(int from, int to) std::vector<cost> d(vertexCount); try { astar_search(g, start, distance_heuristic<mygraph_t, cost, std::vector<location>>(locations, goal), predecessor_map(&p[0]).distance_map(&d[0]).visitor(astar_goal_visitor<vertex>(goal))); - } catch (found_goal) { + } catch (const found_goal&) { qDebug() << "TMap::findPath(" << from << "," << to << ") INFO: time elapsed in A*:" << t.nsecsElapsed() * 1.0e-6 << "ms."; t.restart(); if (!roomidToIndex.contains(to)) { @@ -1158,13 +1157,15 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion) ofs << mCustomEnvColors; ofs << mpRoomDB->hashToRoomID; if (mSaveVersion < 19) { - // Save the data in the map user data for older versions - mUserData.insert(qsl("system.fallback_mapSymbolFont"), mMapSymbolFont.toString()); - mUserData.insert(qsl("system.fallback_mapSymbolFontFudgeFactor"), QString::number(mMapSymbolFontFudgeFactor)); - mUserData.insert(qsl("system.fallback_onlyUseMapSymbolFont"), mIsOnlyMapSymbolFontToBeUsed ? qsl("true") : qsl("false")); - } - ofs << mUserData; - if (mSaveVersion >= 19) { + // Save the data in the map user data for older versions - use a local + // copy so that saving does not modify the live map's user data: + QMap<QString, QString> userData{mUserData}; + userData.insert(qsl("system.fallback_mapSymbolFont"), mMapSymbolFont.toString()); + userData.insert(qsl("system.fallback_mapSymbolFontFudgeFactor"), QString::number(mMapSymbolFontFudgeFactor)); + userData.insert(qsl("system.fallback_onlyUseMapSymbolFont"), mIsOnlyMapSymbolFontToBeUsed ? qsl("true") : qsl("false")); + ofs << userData; + } else { + ofs << mUserData; // Save the data directly in supported format versions (19 and above) ofs << mMapSymbolFont; ofs << mMapSymbolFontFudgeFactor; @@ -1316,16 +1317,6 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion) } ofs << pR->getId(); - if (mSaveVersion <= 19) { - if (!pR->mSymbol.isEmpty()) { - pR->userData.insert(QLatin1String("system.fallback_symbol"), pR->mSymbol); - } - } - if (mSaveVersion < 21) { - if (pR->hidden) { - pR->userData.insert(QLatin1String("system.fallback_hidden"), QLatin1String("true")); - } - } ofs << pR->getArea(); ofs << pR->x(); ofs << pR->y(); @@ -1380,10 +1371,6 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion) if (mSaveVersion >= 21) { ofs << pR->mSymbolColor; - } else { - if (pR->mSymbolColor.isValid()) { - pR->userData.insert(QLatin1String("system.fallback_symbol_color"), pR->mSymbolColor.name()); - } } // Border properties are stored in userData (not binary stream) to avoid map bloat @@ -1398,7 +1385,25 @@ bool TMap::serialize(QDataStream& ofs, int saveVersion) pR->userData.remove(ROOM_UI_BORDERTHICKNESS); } - ofs << pR->userData; + // Formats before 21 carry the hidden flag and symbol color - and + // formats before 19 the symbol - as user data fallbacks; use a local + // copy so that saving does not modify the live room's user data. + // TRoom::restore() strips each key again when loading a format that + // carries it, so none may appear in formats which store the value + // directly in the stream: + QMap<QString, QString> userData{pR->userData}; + if (mSaveVersion < 21) { + if (pR->hidden) { + userData.insert(QLatin1String("system.fallback_hidden"), QLatin1String("true")); + } + if (pR->mSymbolColor.isValid()) { + userData.insert(QLatin1String("system.fallback_symbol_color"), pR->mSymbolColor.name()); + } + } + if (mSaveVersion < 19 && !pR->mSymbol.isEmpty()) { + userData.insert(QLatin1String("system.fallback_symbol"), pR->mSymbol); + } + ofs << userData; if (mSaveVersion >= 20) { // Before version 20 stored the style as an Latin1 string, the color // as a QList<int> for the RGB components and used UPPER case for @@ -1591,6 +1596,7 @@ bool TMap::validatePotentialMapFile(QFile& file, QDataStream& ifs) bool TMap::restore(QString location) { + const MapOperationScope operationScope(this); qDebug().noquote().nospace() << "TMap::restore(\"" << location << "\") INFO: restoring map of Profile: \"" << mProfileName << "\" URL: " << mpHost->getUrl(); QElapsedTimer _time; @@ -1696,6 +1702,12 @@ bool TMap::restore(QString location) } ifs >> mMapSymbolFontFudgeFactor; ifs >> mIsOnlyMapSymbolFontToBeUsed; + // Clean up stale fallback keys that past versions could leave + // behind in the live map's user data (and thus in files saved + // from it) after saving in a format before 19: + mUserData.remove(qsl("system.fallback_mapSymbolFont")); + mUserData.remove(qsl("system.fallback_mapSymbolFontFudgeFactor")); + mUserData.remove(qsl("system.fallback_onlyUseMapSymbolFont")); } else { // Fallback to reading the data from the map user data - and // remove it from the data the user will see: @@ -2487,6 +2499,7 @@ void TMap::pushErrorMessagesToFile(const QString title, const bool isACleanup) void TMap::downloadMap(const QString& remoteUrl, const QString& localFileName) { + const MapOperationScope operationScope(this); Host* pHost = mpHost; if (!pHost) { return; @@ -2500,6 +2513,15 @@ void TMap::downloadMap(const QString& remoteUrl, const QString& localFileName) postMessage(warnMsg); return; } + + if (mMapProgressStandalone) { + //: Shown in the main console when a map download is refused + const QString warnMsg = tr("[ WARN ] - Attempt made to download an XML map while a map import or\n" + "export is already in progress - wait for that operation to complete\n" + "before retrying!"); + postMessage(warnMsg); + return; + } mImportRunning = true; // MUST clear this flag when done under ALL circumstances @@ -2591,6 +2613,23 @@ bool TMap::importMap(QFile& file, QString* errMsg) } return false; } + + if (mMapProgressStandalone) { + // readXmlMapFile() would see the JSON operation's progress dialog as its + // own, skip creating one, and then mapClear() the map out from under it: + if (errMsg) { + //: Error returned by the loadMap() Lua function + *errMsg = tr("loadMap: unable to perform request, a map import or export is\n" + "already in progress."); + } else { + //: Shown in the main console when a map import is refused + const QString warnMsg = tr("[ WARN ] - Attempt made to import an XML map while a map import or\n" + "export is already in progress - wait for that operation to complete\n" + "before retrying!"); + postMessage(warnMsg); + } + return false; + } mImportRunning = true; // MUST clear this flag when done under ALL circumstances @@ -2602,6 +2641,7 @@ bool TMap::importMap(QFile& file, QString* errMsg) bool TMap::readXmlMapFile(QFile& file, QString* errMsg) { + const MapOperationScope operationScope(this); Host* pHost = mpHost; bool isLocalImport = false; if (!pHost) { @@ -2828,23 +2868,18 @@ void TMap::createTransferProgress(const QString& title, const QString& label, bo return; } - mpProgressDialog = new QProgressDialog(label, cancelable ? tr("Abort") : QString(), 0, 0); - mpProgressDialog->setWindowTitle(title); - mpProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png"))); - mpProgressDialog->setMinimumWidth(300); - mpProgressDialog->setAutoClose(false); - mpProgressDialog->setAutoReset(false); - mpProgressDialog->setMinimumDuration(0); // Normally waits for 4 seconds before showing - if (cancelable) { - connect(mpProgressDialog, &QProgressDialog::canceled, this, &TMap::slot_downloadCancel); - } - mpProgressDialog->show(); + mMapProgressStandalone = true; + mMapProgressIsTransfer = true; + mMapProgressCancelRequested = false; + mMapProgressStandaloneMaximum = 0; + warnIfMapProgressUnwired(__func__, true); + emit signal_mapTransferProgressStart(title, label, cancelable ? tr("Abort") : QString()); } void TMap::updateTransferProgressLabel(const QString& text) { - if (mpProgressDialog) { - mpProgressDialog->setLabelText(text); + if (mMapProgressStandalone) { + emit signal_mapProgressSetLabel(text); } else if (mpMapper) { mpMapper->setMapProgressLabel(text); } @@ -2852,8 +2887,9 @@ void TMap::updateTransferProgressLabel(const QString& text) void TMap::updateTransferProgressRange(int minimum, int maximum) { - if (mpProgressDialog) { - mpProgressDialog->setRange(minimum, maximum); + if (mMapProgressStandalone) { + mMapProgressStandaloneMaximum = maximum; + emit signal_mapProgressSetRange(minimum, maximum); } else if (mpMapper) { mpMapper->setMapProgressRange(minimum, maximum); } @@ -2861,8 +2897,8 @@ void TMap::updateTransferProgressRange(int minimum, int maximum) void TMap::updateTransferProgressValue(int value) { - if (mpProgressDialog) { - mpProgressDialog->setValue(value); + if (mMapProgressStandalone) { + emit signal_mapProgressSetValue(value); } else if (mpMapper) { mpMapper->setMapProgressValue(value); } @@ -2870,8 +2906,8 @@ void TMap::updateTransferProgressValue(int value) int TMap::transferProgressMaximum() const { - if (mpProgressDialog) { - return mpProgressDialog->maximum(); + if (mMapProgressStandalone) { + return mMapProgressStandaloneMaximum; } if (mpMapper) { return mpMapper->mapProgressMaximum(); @@ -2881,13 +2917,13 @@ int TMap::transferProgressMaximum() const bool TMap::hasActiveTransferProgress() const { - return mpProgressDialog != nullptr || (mpMapper && mpMapper->isMapProgressVisible()); + return mMapProgressStandalone || (mpMapper && mpMapper->isMapProgressVisible()); } void TMap::disableTransferProgressCancel() { - if (mpProgressDialog) { - mpProgressDialog->setCancelButton(nullptr); + if (mMapProgressStandalone) { + emit signal_mapProgressDisableCancel(); } else if (mpMapper) { mpMapper->setMapProgressCancelable(false); } @@ -2895,9 +2931,12 @@ void TMap::disableTransferProgressCancel() void TMap::clearTransferProgress() { - if (mpProgressDialog) { - mpProgressDialog->deleteLater(); - mpProgressDialog = nullptr; + // Only close a transfer-owned standalone dialog: a concurrent JSON + // import/export owns the standalone progress state and must keep it. + if (mMapProgressStandalone && mMapProgressIsTransfer) { + mMapProgressStandalone = false; + mMapProgressIsTransfer = false; + emit signal_mapProgressClose(); return; } if (mpMapper) { @@ -2906,6 +2945,45 @@ void TMap::clearTransferProgress() } } +void TMap::requestMapOperationAbort() +{ + if (!mMapOperationDepth || mMapOperationAbortRequested) { + return; + } + mMapOperationAbortRequested = true; + // Deliberately not slot_mapProgressDialogCancelled(): that is the user + // pressing Abort and says so in the console, whereas this is the profile + // going away and has no console left to say it to. What it does share is + // the flag the JSON import and export poll at their next progress step, and + // dropping a download that would otherwise hold the close up on the network. + mMapProgressCancelRequested = true; + if (mMapProgressIsTransfer && mpNetworkReply) { + mpNetworkReply->abort(); + } +} + +void TMap::slot_mapProgressDialogCancelled() +{ + // The JSON path polls mMapProgressCancelRequested in its increment loop; the + // transfer path needs its network reply aborted here. + mMapProgressCancelRequested = true; + if (mMapProgressIsTransfer) { + slot_downloadCancel(); + } +} + +void TMap::warnIfMapProgressUnwired(const char* context, const bool transferPath) +{ + static const QMetaMethod transferStart = QMetaMethod::fromSignal(&TMap::signal_mapTransferProgressStart); + static const QMetaMethod jsonStart = QMetaMethod::fromSignal(&TMap::signal_mapJsonProgressStart); + static const QMetaMethod progressClose = QMetaMethod::fromSignal(&TMap::signal_mapProgressClose); + if (isSignalConnected(transferPath ? transferStart : jsonStart) && isSignalConnected(progressClose)) { + return; + } + qWarning().nospace() << "TMap::" << context + << "() WARNING - no frontend is connected to show the map progress dialog; the operation will run without a visible progress dialog and cannot be canceled from one."; +} + QHash<QString, QSet<int>> TMap::roomSymbolsHash() { QHash<QString, QSet<int>> results; @@ -2974,6 +3052,7 @@ void TMap::setRoomNamesShown(bool shown) */ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest) { + const MapOperationScope operationScope(this); QString destination{dest}; if (destination.isEmpty()) { @@ -2989,7 +3068,7 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest) destination.append(QLatin1String(".json")); } - if (mpProgressDialog) { + if (mMapProgressStandalone) { return {false, qsl("import or export already in progress")}; } @@ -3002,35 +3081,31 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest) } } - mpProgressDialog = new QProgressDialog(tr("Exporting JSON map data from %1\n" - "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") - .arg(mProfileName, - QLatin1String("0"), - QString::number(mProgressDialogAreasTotal), - QLatin1String("0"), - QString::number(mProgressDialogRoomsTotal), - QLatin1String("0"), - QString::number(mProgressDialogLabelsTotal)), - tr("Abort"), - 0, - mProgressDialogRoomsTotal, - mpHost->mpConsole); - mpProgressDialog->setValue(0); - mpProgressDialog->setWindowModality(Qt::NonModal); + mMapProgressStandalone = true; + mMapProgressIsTransfer = false; + mMapProgressCancelRequested = false; + mMapProgressStandaloneMaximum = static_cast<int>(mProgressDialogRoomsTotal); + warnIfMapProgressUnwired(__func__, false); //: This is a title of a progress window. - mpProgressDialog->setWindowTitle(tr("Map JSON export")); - mpProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png"))); - mpProgressDialog->setMinimumWidth(500); - mpProgressDialog->setAutoClose(false); - mpProgressDialog->setAutoReset(false); - mpProgressDialog->setMinimumDuration(1); // Normally waits for 4 seconds before showing + emit signal_mapJsonProgressStart(tr("Map JSON export"), + tr("Exporting JSON map data from %1\n" + "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") + .arg(mProfileName, + QLatin1String("0"), + QString::number(mProgressDialogAreasTotal), + QLatin1String("0"), + QString::number(mProgressDialogRoomsTotal), + QLatin1String("0"), + QString::number(mProgressDialogLabelsTotal)), + tr("Abort"), + static_cast<int>(mProgressDialogRoomsTotal)); + emit signal_mapProgressSetValue(0); qApp->processEvents(); QSaveFile file(destination); if (!file.open(QFile::OpenMode(QFile::Text | QFile::WriteOnly))) { qWarning().noquote().nospace() << "TMap::writeJsonMapFile(...) WARNING - Could not open save file \"" << destination << "\"."; - mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true); - mpProgressDialog->close(); - mpProgressDialog = nullptr; + emit signal_mapProgressClose(); + mMapProgressStandalone = false; return {false, qsl("could not open save file \"%1\", reason: %2").arg(destination.toHtmlEscaped(), file.errorString())}; } @@ -3066,9 +3141,8 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest) } if (abort) { file.cancelWriting(); - mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true); - mpProgressDialog->close(); - mpProgressDialog = nullptr; + emit signal_mapProgressClose(); + mMapProgressStandalone = false; return {false, qsl("aborted by user")}; } @@ -3147,20 +3221,19 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest) mapObj.insert(QLatin1String("playerRoomOuterDiameterPercentage"), static_cast<double>(mPlayerRoomOuterDiameterPercentage)); mapObj.insert(QLatin1String("playerRoomInnerDiameterPercentage"), static_cast<double>(mPlayerRoomInnerDiameterPercentage)); - mpProgressDialog->setLabelText(tr("Exporting JSON map file from %1 - writing data to file:\n" - "%2 ...") - .arg(mProfileName, destination)); - mpProgressDialog->setValue(0); + emit signal_mapProgressSetLabel(tr("Exporting JSON map file from %1 - writing data to file:\n" + "%2 ...") + .arg(mProfileName, destination)); + emit signal_mapProgressSetValue(0); // Hide the cancel button as we can't stop now: - mpProgressDialog->setCancelButton(nullptr); + emit signal_mapProgressDisableCancel(); file.write(QJsonDocument(mapObj).toJson(QJsonDocument::Indented)); if (!file.commit()) { qDebug() << "TMap::writeJsonMapFile: error saving JSON map: " << file.errorString(); } - mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true); - mpProgressDialog->close(); - mpProgressDialog = nullptr; + emit signal_mapProgressClose(); + mMapProgressStandalone = false; return {file.error() == QFileDevice::NoError, ((file.error() == QFileDevice::NoError) ? QString() : qsl("could not export file, reason: %1").arg(file.errorString()))}; } @@ -3168,12 +3241,13 @@ std::pair<bool, QString> TMap::writeJsonMapFile(const QString& dest) // The translatable messages are used within this file and do not need to // mention the file concerned whereas the untranslated messages are used by the // Lua sub-system and do need to report the file: -std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool translatableTexts, const bool allowUserCancellation) +std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool translatableTexts) { + const MapOperationScope operationScope(this); const QString oldDefaultAreaName{mDefaultAreaName}; const QString oldUnnamedName{mUnnamedAreaName}; - if (mpProgressDialog) { + if (mMapProgressStandalone) { return {false, (translatableTexts ? tr("import or export already in progress") : qsl("import or export already in progress"))}; } @@ -3227,28 +3301,25 @@ std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool mProgressDialogRoomsCount = 0; mProgressDialogLabelsTotal = qRound(mapObj[QLatin1String("labelCount")].toDouble()); mProgressDialogLabelsCount = 0; - mpProgressDialog = new QProgressDialog(tr("Importing JSON map data to %1\n" - "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") - .arg(mProfileName, - QLatin1String("0"), - QString::number(mProgressDialogAreasTotal), - QLatin1String("0"), - QString::number(mProgressDialogRoomsTotal), - QLatin1String("0"), - QString::number(mProgressDialogLabelsTotal)), - (allowUserCancellation ? tr("Abort") : QString()), - 0, - mProgressDialogRoomsTotal, - mpHost->mpConsole); - mpProgressDialog->setValue(0); - mpProgressDialog->setWindowModality(Qt::NonModal); + mMapProgressStandalone = true; + mMapProgressIsTransfer = false; + mMapProgressCancelRequested = false; + mMapProgressStandaloneMaximum = static_cast<int>(mProgressDialogRoomsTotal); + warnIfMapProgressUnwired(__func__, false); //: This is a title of a progress window. - mpProgressDialog->setWindowTitle(tr("Map JSON import")); - mpProgressDialog->setWindowIcon(QIcon(qsl(":/icons/mudlet_map_download.png"))); - mpProgressDialog->setMinimumWidth(500); - mpProgressDialog->setAutoClose(false); - mpProgressDialog->setAutoReset(false); - mpProgressDialog->setMinimumDuration(1); // Normally waits for 4 seconds before showing + emit signal_mapJsonProgressStart(tr("Map JSON import"), + tr("Importing JSON map data to %1\n" + "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") + .arg(mProfileName, + QLatin1String("0"), + QString::number(mProgressDialogAreasTotal), + QLatin1String("0"), + QString::number(mProgressDialogRoomsTotal), + QLatin1String("0"), + QString::number(mProgressDialogLabelsTotal)), + tr("Abort"), + static_cast<int>(mProgressDialogRoomsTotal)); + emit signal_mapProgressSetValue(0); qApp->processEvents(); mDefaultAreaName = mapObj[QLatin1String("defaultAreaName")].toString(); @@ -3325,18 +3396,15 @@ std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool auto [id, name] = pArea->readJsonArea(mapObj.value(QLatin1String("areas")).toArray(), i); ++mProgressDialogAreasCount; if (incrementJsonProgressDialog(false, true, 0)) { - if (allowUserCancellation) { - abort = true; - } + abort = true; break; } // This will populate the TRoomDB::areas and TRoomDB::areaNameMap: pNewRoomDB->addArea(pArea.release(), id, name); } if (abort) { - mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true); - mpProgressDialog->close(); - mpProgressDialog = nullptr; + emit signal_mapProgressClose(); + mMapProgressStandalone = false; mDefaultAreaName = oldDefaultAreaName; mUnnamedAreaName = oldUnnamedName; return {false, (translatableTexts ? tr("aborted by user") : qsl("aborted by user"))}; @@ -3368,9 +3436,8 @@ std::pair<bool, QString> TMap::readJsonMapFile(const QString& source, const bool if (mpMapper && mpMapper->mp2dMap) { mpMapper->mp2dMap->setPlayerRoomStyle(mPlayerRoomStyle); } - mpProgressDialog->setAttribute(Qt::WA_DeleteOnClose, true); - mpProgressDialog->close(); - mpProgressDialog = nullptr; + emit signal_mapProgressClose(); + mMapProgressStandalone = false; return {true, QString()}; } @@ -3469,30 +3536,30 @@ bool TMap::incrementJsonProgressDialog(const bool isExportNotImport, const bool mProgressDialogLabelsCount += increment; } - mpProgressDialog->setValue(mProgressDialogRoomsCount); + emit signal_mapProgressSetValue(static_cast<int>(mProgressDialogRoomsCount)); if (isExportNotImport) { - mpProgressDialog->setLabelText(tr("Exporting JSON map data from %1\n" - "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") - .arg(mProfileName, - QString::number(mProgressDialogAreasCount), - QString::number(mProgressDialogAreasTotal), - QString::number(mProgressDialogRoomsCount), - QString::number(mProgressDialogRoomsTotal), - QString::number(mProgressDialogLabelsCount), - QString::number(mProgressDialogLabelsTotal))); + emit signal_mapProgressSetLabel(tr("Exporting JSON map data from %1\n" + "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") + .arg(mProfileName, + QString::number(mProgressDialogAreasCount), + QString::number(mProgressDialogAreasTotal), + QString::number(mProgressDialogRoomsCount), + QString::number(mProgressDialogRoomsTotal), + QString::number(mProgressDialogLabelsCount), + QString::number(mProgressDialogLabelsTotal))); } else { - mpProgressDialog->setLabelText(tr("Importing JSON map data to %1\n" - "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") - .arg(mProfileName, - QString::number(mProgressDialogAreasCount), - QString::number(mProgressDialogAreasTotal), - QString::number(mProgressDialogRoomsCount), - QString::number(mProgressDialogRoomsTotal), - QString::number(mProgressDialogLabelsCount), - QString::number(mProgressDialogLabelsTotal))); + emit signal_mapProgressSetLabel(tr("Importing JSON map data to %1\n" + "Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...") + .arg(mProfileName, + QString::number(mProgressDialogAreasCount), + QString::number(mProgressDialogAreasTotal), + QString::number(mProgressDialogRoomsCount), + QString::number(mProgressDialogRoomsTotal), + QString::number(mProgressDialogLabelsCount), + QString::number(mProgressDialogLabelsTotal))); } qApp->processEvents(); - return mpProgressDialog->wasCanceled(); + return mMapProgressCancelRequested; } void TMap::updateArea(int areaId) diff --git a/src/TMap.h b/src/TMap.h index 616e9baec..023fcbdfa 100644 --- a/src/TMap.h +++ b/src/TMap.h @@ -67,7 +67,6 @@ class TRoom; class TRoomDB; class QFile; class QNetworkAccessManager; -class QProgressDialog; class MapInfoContributorManager; class TMap : public QObject @@ -79,6 +78,18 @@ signals: void signal_areaChanged(int areaId); void signal_mmpMapLocationChanged(); + // Map-progress seam for the libmudlet split (#8681, #9011): the map engine + // must stay free of Qt Widgets, so it emits these pre-translated payloads for + // the frontend (TMainConsole) to render as a QProgressDialog. Cancellation + // returns through slot_mapProgressDialogCancelled(). + void signal_mapTransferProgressStart(const QString& title, const QString& label, const QString& cancelButtonText); + void signal_mapJsonProgressStart(const QString& title, const QString& label, const QString& cancelButtonText, int maximum); + void signal_mapProgressSetLabel(const QString& text); + void signal_mapProgressSetRange(int minimum, int maximum); + void signal_mapProgressSetValue(int value); + void signal_mapProgressDisableCancel(); + void signal_mapProgressClose(); + private: QString mDefaultAreaName; QString mUnnamedAreaName; @@ -160,9 +171,10 @@ public: void reportProgressToProgressDialog(int, int); // Download/import progress helpers. Use the inline progress widget in the - // mapper when it is visible, otherwise fall back to a modal QProgressDialog. - // Do NOT use these from the JSON export/import paths - those keep their own - // dedicated QProgressDialog. + // mapper when it is visible, otherwise ask the frontend for a standalone + // progress dialog via signal_mapTransferProgressStart(). Do NOT use these + // from the JSON export/import paths - those drive their own frontend dialog + // through signal_mapJsonProgressStart(). void createTransferProgress(const QString& title, const QString& label, bool cancelable); void updateTransferProgressLabel(const QString& text); void updateTransferProgressRange(int minimum, int maximum); @@ -172,6 +184,20 @@ public: void disableTransferProgressCancel(); void clearTransferProgress(); + // True while a map import, export or download is on the stack. Those pump + // qApp->processEvents() to keep their progress display alive, so anything + // delivered from an event loop can find itself running nested inside one - + // and destroying this map's Host from there would free the operation's own + // "this" (#9520). Whoever would do that has to wait for this to go false. + bool mapOperationInProgress() const { return mMapOperationDepth > 0; } + // Ask an operation that is in progress to stop at its next opportunity, so + // that a caller waiting on the above does not wait for a whole map. Only the + // JSON import and export poll this; an XML import or a download runs to its + // own end. Asking twice does nothing, which mapOperationAbortRequested() + // also lets a caller polling in a loop see. + void requestMapOperationAbort(); + bool mapOperationAbortRequested() const { return mMapOperationAbortRequested; } + // Show which rooms have which symbols: QHash<QString, QSet<int>> roomSymbolsHash(); @@ -185,7 +211,7 @@ public: void setRoomNamesShown(bool shown); std::pair<bool, QString> writeJsonMapFile(const QString&); - std::pair<bool, QString> readJsonMapFile(const QString&, const bool translatableTexts = false, const bool allowUserCancellation = true); + std::pair<bool, QString> readJsonMapFile(const QString&, const bool translatableTexts = false); qsizetype getCurrentProgressRoomCount() const { return mProgressDialogRoomsCount; } bool incrementJsonProgressDialog(const bool isExportNotImport, const bool isRoomNotLabel, const int increment = 1); QString getDefaultAreaName() const { return mDefaultAreaName; } @@ -363,9 +389,40 @@ public slots: void slot_downloadCancel(); void slot_downloadError(QNetworkReply::NetworkError); void slot_replyFinished(QNetworkReply*); + // Called by the frontend when the user cancels the standalone map-progress + // dialog it owns on our behalf. + void slot_mapProgressDialogCancelled(); private: + // Held for the whole of a map operation that pumps the event loop, so that + // mapOperationInProgress() can tell anything re-entered from that pump that + // this map is on the stack. Nested operations are counted, not flagged: an + // XML import can start from inside a download's pump. + class MapOperationScope + { + public: + explicit MapOperationScope(TMap* pMap) + : mpMap(pMap) + { + if (!mpMap->mMapOperationDepth) { + mpMap->mMapOperationAbortRequested = false; + } + ++mpMap->mMapOperationDepth; + } + ~MapOperationScope() { --mpMap->mMapOperationDepth; } + MapOperationScope(const MapOperationScope&) = delete; + MapOperationScope& operator=(const MapOperationScope&) = delete; + + private: + TMap* mpMap = nullptr; + }; + + int mMapOperationDepth = 0; + // requestMapOperationAbort() is asked again on every retry of a deferred + // profile close, and asking twice would abort a network reply twice over. + bool mMapOperationAbortRequested = false; + void addDirectionalRoute(QHash<unsigned int, route>& bestRoutes, const QMap<QString, int>& exitWeights, unsigned int source, @@ -375,6 +432,7 @@ private: const QString& exitKey, const QSet<unsigned int>& unUsableRoomSet); const QString createFileHeaderLine(QString, QChar); + void warnIfMapProgressUnwired(const char* context, bool transferPath); void writeJsonUserData(QJsonObject&) const; void readJsonUserData(const QJsonObject&); bool validatePotentialMapFile(QFile&, QDataStream&); @@ -401,7 +459,13 @@ private: int mExpectedFileSize = 0; bool mImportRunning = false; - QProgressDialog* mpProgressDialog = nullptr; + // Engine-side mirror of the frontend-owned dialog, which the engine can't + // read back. mMapProgressStandalone also serves as the "import/export already + // running" guard (see writeJsonMapFile()/readJsonMapFile()). + bool mMapProgressStandalone = false; + bool mMapProgressIsTransfer = false; + bool mMapProgressCancelRequested = false; + int mMapProgressStandaloneMaximum = 0; // Using during updates of text in progress dialog partially from other // classes: qsizetype mProgressDialogAreasTotal = 0; diff --git a/src/TMatchState.h b/src/TMatchState.h index 75d163365..f177ea4b4 100644 --- a/src/TMatchState.h +++ b/src/TMatchState.h @@ -41,7 +41,8 @@ public: { } - // Copy constructor: + // Copy constructor - deliberately does not carry over the capture + // containers, so a copied state starts with empty captures: TMatchState(const TMatchState& ms) : mNumberOfConditions(ms.mNumberOfConditions) , mNextCondition(ms.mNextCondition) @@ -51,6 +52,13 @@ public: { } + // Pair the user-defined copy constructor with an explicit copy assignment + // (Rule of Two). Note the two are deliberately asymmetric: unlike the + // constructor above, this defaulted assignment copies every member, + // capture containers included. That reproduces the previously implicit + // assignment exactly, so behaviour is unchanged: + TMatchState& operator=(const TMatchState& ms) = default; + int nextCondition() { return mNextCondition; } void conditionMatched() { mNextCondition++; } bool isComplete() { return (mNextCondition >= mNumberOfConditions); } diff --git a/src/TMedia.cpp b/src/TMedia.cpp index cdc25ff3d..abae83fc8 100644 --- a/src/TMedia.cpp +++ b/src/TMedia.cpp @@ -34,6 +34,38 @@ #include <QRandomGenerator> #include <QSaveFile> #include <QStandardPaths> +#include <QTimer> + +namespace { +// Holds TMediaPlayer::reservedForPlay() for as long as a play() call is setting that player up, +// however that call returns. +class MediaPlayerReservation +{ +public: + MediaPlayerReservation() = default; + ~MediaPlayerReservation() + { + if (mPlayer) { + mPlayer->setReservedForPlay(false); + } + } + Q_DISABLE_COPY(MediaPlayerReservation) + + void reserve(const std::shared_ptr<TMediaPlayer>& player) + { + if (mPlayer) { + mPlayer->setReservedForPlay(false); + } + mPlayer = player; + if (mPlayer) { + mPlayer->setReservedForPlay(true); + } + } + +private: + std::shared_ptr<TMediaPlayer> mPlayer; +}; +} // namespace // Public TMedia::TMedia(Host* pHost, const QString& profileName) @@ -316,6 +348,24 @@ void TMedia::stopMedia(TMediaData& mediaData) continue; } + // A pooled player between tracks holds no source and has nothing to stop. Criteria this + // broad are the common case - a bare stopMusic() or Client.Media.Stop {} matches every + // player there is - so without this each idle one would be ended all over again, and + // told about with an empty file name and the key and tag of its last track. + if (!pPlayer->mediaPlayer() || pPlayer->mediaPlayer()->source().isEmpty()) { + continue; + } + + // Whichever way this track is being ended below, it is not to start again. A looping + // or multi-entry track restarts itself from the EndOfMedia handler in + // connectMediaPlayer(), which would undo the stop that was just asked for - on a + // StoppedState-first backend that signal can still be on its way when the stop + // arrives. An emptied playlist is what that handler checks, and play() builds a fresh + // one whenever this player is picked up again. + if (pPlayer->playlist()) { + pPlayer->playlist()->clear(); + } + if ((mediaData.mediaFadeAway() == TMediaData::MediaFadeAwayEnabled || mediaData.mediaFadeOut() != TMediaData::MediaFadeNotSet) && pPlayer->mediaData().mediaEnd() == TMediaData::MediaEndNotSet) { const int finishPosition = pPlayer->mediaData().mediaFinish(); @@ -339,7 +389,22 @@ void TMedia::stopMedia(TMediaData& mediaData) } // **Stop the player but keep it for reuse** + // Only a player that had started reports a change back to StoppedState, and that + // signal is what ends the playback and releases the source. One that is still loading + // - where a stop issued soon after a play lands on an asynchronous backend - is + // already stopped as far as Qt is concerned, so it reports nothing and its source + // would be held for good. + const bool willReportItsOwnStop = pPlayer->getPlaybackState() != QMediaPlayer::StoppedState; + pPlayer->mediaPlayer()->stop(); + + if (!willReportItsOwnStop) { + releaseMediaSourceAfterEvents(pPlayer, pPlayer->mediaData(), PlaybackEnd::Stopped); + // Announced at most once per playback, so a handler that stops the media it has + // just been told about does not arrive back here for the same track: the source it + // reads as live stays set until the deferred release above runs. + raiseMediaFinishedEvent(pPlayer, pPlayer->mediaPlayer()->source(), pPlayer->mediaData()); + } } } @@ -382,19 +447,24 @@ void TMedia::parseGMCP(QString& packageMessage, QString& gmcp) } // Documentation: https://wiki.mudlet.org/w/Manual:Miscellaneous_Functions#purgeMediaCache -bool TMedia::purgeMediaCache() +std::pair<bool, QString> TMedia::purgeMediaCache() { const QString mediaPath = mudlet::getMudletPath(enums::profileMediaPath, mpHost->getName()); QDir mediaDir(mediaPath); if (!mediaDir.mkpath(mediaPath)) { - qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to reference directory: %1").arg(mudlet::getMudletPath(enums::profileMediaPath, mpHost->getName())); - return false; + qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to reference directory: %1").arg(mediaPath); + return {false, qsl("could not access the media directory \"%1\"").arg(mediaPath)}; } stopAllMediaPlayers(); - mediaDir.removeRecursively(); - return true; + + if (!mediaDir.removeRecursively()) { + qWarning() << qsl("TMedia::purgeMediaCache() WARNING - not able to remove all of directory: %1").arg(mediaPath); + return {false, qsl("removed what could be removed, but not all of the media directory \"%1\" - some files may be in use or write protected").arg(mediaPath)}; + } + + return {true, QString()}; } void TMedia::refreshAudioDevices() @@ -563,14 +633,93 @@ void TMedia::stopAllMediaPlayers() QList<std::shared_ptr<TMediaPlayer>> mediaPlayerList = findMediaPlayersByCriteria(mediaData); for (const auto& pPlayer : std::as_const(mediaPlayerList)) { - if (!pPlayer) { - continue; + if (!pPlayer || !pPlayer->mediaPlayer() || pPlayer->mediaPlayer()->source().isEmpty()) { + continue; // A pooled player between tracks has nothing playing to stop + } + + // Everything the ending is described by has to be read before the source goes, because + // releasing is what makes it unreadable. + const TMediaData endedData = pPlayer->mediaData(); + const QUrl endedUrl = pPlayer->mediaPlayer()->source(); + const bool hadVideoOutput = pPlayer->mediaPlayer()->videoOutput() != nullptr; + const quint64 claimedAt = pPlayer->claimGeneration(); + + // No loop is to survive this: the EndOfMedia handler restarts one from the playlist, + // and a StoppedState-first backend can still have that signal on its way. + if (pPlayer->playlist()) { + pPlayer->playlist()->clear(); } pPlayer->mediaPlayer()->stop(); + + // stop() can deliver StoppedState synchronously, whose handler raises sysMediaFinished + // and so lets a script hand this player straight to another track. The release below is + // direct - it carries no generation of its own for releaseMediaSourceAfterEvents()' + // checks to catch - so this is the one thing standing between that new track and having + // its source cleared out from under it. + if (pPlayer->claimGeneration() != claimedAt) { + continue; + } + + // Released here rather than left to releaseMediaSourceAfterEvents(): this is a + // teardown, so there is no loop left to restart and no reason to wait a turn, and a + // caller may need the files free straight away - purgeMediaCache() deletes them. The + // empty source left behind is also what tells any release already scheduled for this + // player to stay quiet when its turn comes, so nothing is said twice. + pPlayer->releaseSource(); + + if (endedData.mediaWidget() == TMediaData::MediaWidgetLabel && endedData.mediaClose() == TMediaData::MediaCloseEnabled && hadVideoOutput) { + emit signal_hideVideoOutput(pPlayer.get()); + } + + // Announced from here because releasing synchronously means no deferred turn will do + // it: on a backend that reports StoppedState asynchronously nothing else ever would, + // and a script waiting on sysMediaFinished would sit through the teardown none the + // wiser. Skipped when stop() above already announced it - see endAnnounced(). + raiseMediaFinishedEvent(pPlayer, endedUrl, endedData); + + //: This word is part of a sentence like "Music stops" when the music is about to stop. + printClosedCaption(endedData, tr("stops")); } } +int TMedia::playersHoldingSource() const +{ + const auto countHeld = [](const QList<std::shared_ptr<TMediaPlayer>>& list) { + int held = 0; + for (const auto& player : list) { + if (player && player->mediaPlayer() && !player->mediaPlayer()->source().isEmpty()) { + ++held; + } + } + return held; + }; + + return countHeld(mMSPSoundList) + countHeld(mMSPMusicList) + countHeld(mGMCPSoundList) + countHeld(mGMCPMusicList) + countHeld(mGMCPVideoList) + countHeld(mAPISoundList) + countHeld(mAPIMusicList) + + countHeld(mAPIVideoList); +} + +int TMedia::playersInPlayingState() const +{ + const auto countPlaying = [](const QList<std::shared_ptr<TMediaPlayer>>& list) { + int playing = 0; + for (const auto& player : list) { + if (player && player->getPlaybackState() == QMediaPlayer::PlayingState) { + ++playing; + } + } + return playing; + }; + + return countPlaying(mMSPSoundList) + countPlaying(mMSPMusicList) + countPlaying(mGMCPSoundList) + countPlaying(mGMCPMusicList) + countPlaying(mGMCPVideoList) + countPlaying(mAPISoundList) + + countPlaying(mAPIMusicList) + countPlaying(mAPIVideoList); +} + +int TMedia::mediaPlayerCount() const +{ + return mMSPSoundList.size() + mMSPMusicList.size() + mGMCPSoundList.size() + mGMCPMusicList.size() + mGMCPVideoList.size() + mAPISoundList.size() + mAPIMusicList.size() + mAPIVideoList.size(); +} + void TMedia::setMediaPlayersMuted(const TMediaData::MediaProtocol mediaProtocol, const bool state) { TMediaData mediaData{}; @@ -1012,6 +1161,17 @@ void TMedia::downloadFile(TMediaData& mediaData) const QString scheme = fileUrl.scheme(); if (scheme != qsl("http") && scheme != qsl("https")) { qWarning() << qsl("TMedia::downloadFile() WARNING - refused to download media from a non-HTTP(S) URL: %1").arg(fileUrl.toString()); + + // Told the same way a download that fails is, so a script waiting on this media learns + // the request is over rather than waiting on it forever. + TEvent event{}; + event.mArgumentList << qsl("sysDownloadError"); + event.mArgumentTypeList << ARGUMENT_TYPE_STRING; + event.mArgumentList << qsl("Media can only be downloaded from an http:// or https:// URL, not \"%1\"").arg(fileUrl.toString()); + event.mArgumentTypeList << ARGUMENT_TYPE_STRING; + event.mArgumentList << mediaData.mediaAbsolutePathFileName(); + event.mArgumentTypeList << ARGUMENT_TYPE_STRING; + mpHost->raiseEvent(event); return; } @@ -1050,7 +1210,7 @@ QString TMedia::setupMediaAbsolutePathFileName(TMediaData& mediaData) void TMedia::connectMediaPlayer(std::shared_ptr<TMediaPlayer>& player) { - if (!player) { + if (!player || !player->mediaPlayer()) { qWarning() << qsl("TMedia::connectMediaPlayer() WARNING - Attempted to connect a null TMediaPlayer."); return; } @@ -1076,18 +1236,79 @@ void TMedia::connectMediaPlayer(std::shared_ptr<TMediaPlayer>& player) QUrl nextMedia = lockedPlayer->playlist()->next(); if (!nextMedia.isEmpty()) { - lockedPlayer->mediaPlayer()->setSource(nextMedia); - lockedPlayer->mediaPlayer()->play(); + lockedPlayer->continuePlaying(nextMedia); } else if (lockedPlayer->playlist()->playbackMode() == TMediaPlaylist::Loop) { lockedPlayer->playlist()->setCurrentIndex(0); - lockedPlayer->mediaPlayer()->setSource(lockedPlayer->playlist()->currentMedia()); - lockedPlayer->mediaPlayer()->play(); + lockedPlayer->continuePlaying(lockedPlayer->playlist()->currentMedia()); } } } } }); + disconnect(player->mediaPlayer(), &QMediaPlayer::errorOccurred, nullptr, nullptr); + connect(player->mediaPlayer(), &QMediaPlayer::errorOccurred, this, [this, weakPlayer](QMediaPlayer::Error error, const QString& errorString) { + const auto lockedPlayer = weakPlayer.lock(); + + if (!lockedPlayer || !lockedPlayer->mediaPlayer() || error == QMediaPlayer::NoError) { + return; + } + + qWarning().noquote() << qsl("TMedia::connectMediaPlayer() WARNING - media player error %1 on \"%2\": %3") + .arg(QString::number(static_cast<int>(error)), lockedPlayer->mediaPlayer()->source().toString(), errorString); + + if (mudlet::smDebugMode && mpHost && mpHost->mpConsole) { + //: %1 is the media backend's own description of what went wrong, e.g. "Failed to load media". + mpHost->mpConsole->printSystemMessage(qsl("%1\n").arg(tr("Media error: %1").arg(errorString))); + } + + // Only a failure nothing else will report is ended from here. A track that was playing + // reports StoppedState when the error takes it down, and the playback state handler + // ends it from there. That leaves two cases: a player already stopped, which is where a + // load failure lands because claimSource() leaves it stopped and there is no state to + // change from; and Qt's darwin backend, which reports PlayingState for media it has + // just failed to load and then never moves off it. InvalidMedia catches that second + // case and only that one - it cannot be asked to carry the first, because Qt's FFmpeg + // backend raises this signal before it sets the status. + if (lockedPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::InvalidMedia && lockedPlayer->getPlaybackState() != QMediaPlayer::StoppedState) { + return; + } + + // Nothing else will end it: a source set on a player that was already stopped - which + // is what every claimSource() on a new or finished player does, and what a loop restart + // or playlist advance does from the EndOfMedia handler - has no state to change from. + // Left alone the track falls silent still holding a source nothing will ever release, + // and a script waiting on sysMediaFinished to start the next one waits forever. + // + // Ended a turn from now rather than here, because setSource() can deliver this error + // synchronously from inside claimSource(): sysMediaFinished would then reach a script + // in the middle of the playMusic() call that asked for the track, and a handler that + // responds by playing the same undecodable file again would recurse until the stack + // gave out. The claim generation says whether this failure is still anyone's to report + // by the time the turn comes: a track that took the player over in between owns it now. + const quint64 claimedAt = lockedPlayer->claimGeneration(); + + QTimer::singleShot(0, this, [this, weakPlayer, claimedAt] { + const auto endingPlayer = weakPlayer.lock(); + + if (!endingPlayer || !endingPlayer->mediaPlayer() || endingPlayer->claimGeneration() != claimedAt) { + return; + } + + // The release armed below ignores playback state by design, since darwin claims to + // be playing media it has just failed to load. That makes this the only place an + // error the backend recovered from can be told apart from one it did not: a turn + // on, media it has condemned says so with InvalidMedia, and media that is playing + // without having been condemned is fine after all and must be left alone. + if (endingPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::InvalidMedia && endingPlayer->getPlaybackState() == QMediaPlayer::PlayingState) { + return; + } + + releaseMediaSourceAfterEvents(endingPlayer, endingPlayer->mediaData(), PlaybackEnd::Failed); + raiseMediaFinishedEvent(endingPlayer, endingPlayer->mediaPlayer()->source(), endingPlayer->mediaData()); + }); + }); + // Playback state changed connection disconnect(player->mediaPlayer(), &QMediaPlayer::playbackStateChanged, nullptr, nullptr); connect(player->mediaPlayer(), &QMediaPlayer::playbackStateChanged, this, [this, weakPlayer](QMediaPlayerPlaybackState playbackState) { @@ -1250,8 +1471,11 @@ std::shared_ptr<TMediaPlayer> TMedia::getMediaPlayer(TMediaData& mediaData) continue; } + if (existingPlayer->reservedForPlay()) { + continue; // Another play() call is setting this one up + } + if (existingPlayer->getPlaybackState() != QMediaPlayer::PlayingState && existingPlayer->mediaPlayer()->mediaStatus() != QMediaPlayer::LoadingMedia) { - existingPlayer->setMediaData(mediaData); return existingPlayer; // Reuse existing player } } @@ -1290,7 +1514,6 @@ std::shared_ptr<TMediaPlayer> TMedia::getMediaPlayer(TMediaData& mediaData) return nullptr; } - newPlayer->setMediaData(mediaData); connectMediaPlayer(newPlayer); mediaPlayerList.append(newPlayer); @@ -1343,6 +1566,162 @@ void TMedia::getMediaPlayerCounts(int& soundPlayers, int& musicPlayers, int& sto } #endif // MUDLET_MEMORY_TRACKING +// Tells scripts a playback is over. Raised for a failed load as well as for a stop, because a +// script that starts its next track from sysMediaFinished otherwise waits forever on the first +// file the backend cannot decode. +void TMedia::raiseMediaFinishedEvent(const std::shared_ptr<TMediaPlayer>& player, const QUrl& endedUrl, const TMediaData& endedData) +{ + if (!mpHost || !player) { + return; + } + + if (endedUrl.isEmpty()) { + // A pooled player between tracks. There is no playback to report, and the event would + // carry an empty file name and path with the key and tag of whatever it last played. + return; + } + + if (player->endAnnounced()) { + // Already reported by whichever of the stop, the error and the StoppedState got here + // first - see TMediaPlayer::endAnnounced(). + return; + } + + // Set before the handlers run, not after: raiseEvent() dispatches synchronously, and a + // handler that stops this player would otherwise arrive back here and announce again. + player->noteEndAnnounced(); + + TEvent mediaFinished{}; + mediaFinished.mArgumentList.append(qsl("sysMediaFinished")); + + mediaFinished.mArgumentList.append(endedUrl.fileName()); + mediaFinished.mArgumentList.append(endedUrl.path()); + mediaFinished.mArgumentList.append(mediaTypeToString(endedData.mediaType())); + mediaFinished.mArgumentList.append(endedData.mediaKey()); + mediaFinished.mArgumentList.append(endedData.mediaTag()); + mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + + mpHost->raiseEvent(mediaFinished); +} + +// Ends a playback: releases the media source and prints the closing caption, one event-loop +// turn from now. Deferred so a StoppedState-first backend can still emit the EndOfMedia that +// restarts a loop - clearing the source immediately destroys the playback engine and that +// signal never arrives (#9566). See TMediaPlayer for the generation counters this compares. +// +// endedBy decides whether the player's own state gets a say in the deferred turn. A stop needs +// it: on an EndOfMedia-first backend the restart happened before the snapshot, so the +// continuation counter cannot see it and a player still reporting PlayingState is the only sign +// the track carried on. A failure must not have it, because a backend can report PlayingState +// for media it has just failed to load (Qt 6.9's darwin backend does), and believing that would +// leave the dead source held forever - no state change follows to schedule another release. +void TMedia::releaseMediaSourceAfterEvents(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& endedData, const PlaybackEnd endedBy) +{ + const bool playbackStateDecides = (endedBy == PlaybackEnd::Stopped); + + if (!player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) { + // Nothing left to end, so no caption for it either + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - asked to end a playback that is already holding no source; nothing to do."; + return; + } + + const std::weak_ptr<TMediaPlayer> weakPlayer = player; + const quint64 claimedAt = player->claimGeneration(); + const quint64 continuedAt = player->continuationGeneration(); + + QTimer::singleShot(0, this, [this, weakPlayer, endedData, claimedAt, continuedAt, playbackStateDecides] { + const auto lockedPlayer = weakPlayer.lock(); + const bool stillOurs = lockedPlayer && lockedPlayer->claimGeneration() == claimedAt; + // Two ways the same playback can have carried on during the deferred turn. On a + // StoppedState-first backend the loop restarts from the EndOfMedia handler after the + // snapshot above, so the counter is what sees it; on an EndOfMedia-first backend the + // restart already happened before the snapshot, so the counter cannot see it and the + // player still reporting PlayingState is. + const bool sameMediaContinues = + lockedPlayer && (lockedPlayer->continuationGeneration() != continuedAt || (playbackStateDecides && stillOurs && lockedPlayer->getPlaybackState() == QMediaPlayer::PlayingState)); + + if (sameMediaContinues) { + // No caption either: nothing ended, so "stops" between the passes of a looping + // track would be wrong. Logged because this is the one outcome that keeps a source + // on purpose, which makes it the first thing to rule out when one is held too long. + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - the same playback carried on into another pass; keeping its source."; + return; + } + + // Releasing bumps no generation, so any path that clears the source itself leaves a + // pending turn still looking entitled to end this playback - an error and the stop + // that follows it, stopAllMediaPlayers(), the setupVideo() failure in play(). Each of + // those announces its own ending, so this one has nothing left to do or to say. + if (stillOurs && lockedPlayer->mediaPlayer() && lockedPlayer->mediaPlayer()->source().isEmpty()) { + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - this playback was already ended by whoever released the source; nothing left to do."; + return; + } + + if (!lockedPlayer) { + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - player was destroyed before its deferred release ran; its destructor released the source."; + } else if (!stillOurs) { + // A claimed player is already loading the source of the track that took it over, + // which on an asynchronous backend still reads as stopped. + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - another track claimed this player before its deferred release ran; keeping the new source."; + } else if (!lockedPlayer->mediaPlayer()) { + qWarning() << "TMedia::releaseMediaSourceAfterEvents() WARNING - mediaPlayer() is null, cannot release the media source."; + } else if (playbackStateDecides && lockedPlayer->getPlaybackState() != QMediaPlayer::StoppedState) { + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - player is no longer stopped, keeping its source."; + } else { + qDebug() << "TMedia::releaseMediaSourceAfterEvents() - releasing the media source of the playback that ended."; + lockedPlayer->releaseSource(); + + if (endedData.mediaWidget() == TMediaData::MediaWidgetLabel && endedData.mediaClose() == TMediaData::MediaCloseEnabled && lockedPlayer->mediaPlayer()->videoOutput() != nullptr) { + emit signal_hideVideoOutput(lockedPlayer.get()); + } + } + + // Printed on every path that got past the continuation check: the track this release + // was scheduled for is over regardless of what has become of the player since. + //: This word is part of a sentence like "Music stops" when the music is about to stop. + printClosedCaption(endedData, tr("stops")); + }); +} + +// Hands a player over to a track that is about to start on it. Call only once the request is +// certain to go ahead - a request that is then refused must not have ended anything. +void TMedia::claimPlayerFor(const std::shared_ptr<TMediaPlayer>& player, TMediaData& mediaData, const QUrl& mediaSource) +{ + // In this order: the ending is reported under the key and tag the player still carries for + // the track that ended, and its source is released before claimSource() bumps the generation + // that any release still pending on this player is judged against. + endDisplacedPlayback(player); + player->setMediaData(mediaData); + player->claimSource(mediaSource); +} + +// Ends whatever playback the player is still holding, so the track taking it over starts from a +// player with nothing loaded. A paused one is what makes this necessary: it keeps its source, and +// handing setSource() a player that is not stopped stops it - which would otherwise be reported +// as the new track ending, under the new track's key and tag, and could clear its source a turn +// later. +void TMedia::endDisplacedPlayback(const std::shared_ptr<TMediaPlayer>& player) +{ + if (!player || !player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) { + return; + } + + // Read before the source goes, because releasing is what makes it unreadable. + const TMediaData endedData = player->mediaData(); + const QUrl endedUrl = player->mediaPlayer()->source(); + + player->mediaPlayer()->stop(); + player->releaseSource(); + + // Skipped when the stop above already announced it - see endAnnounced(). + raiseMediaFinishedEvent(player, endedUrl, endedData); +} + void TMedia::handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playbackState, const std::shared_ptr<TMediaPlayer>& player) { if (!player) { @@ -1350,34 +1729,25 @@ void TMedia::handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playback } if (playbackState == QMediaPlayer::StoppedState) { - TEvent mediaFinished{}; - mediaFinished.mArgumentList.append(qsl("sysMediaFinished")); - - const QUrl mediaUrl = player->mediaPlayer()->source(); - mediaFinished.mArgumentList.append(mediaUrl.fileName()); - mediaFinished.mArgumentList.append(mediaUrl.path()); - mediaFinished.mArgumentList.append(mediaTypeToString(player->mediaData().mediaType())); - mediaFinished.mArgumentList.append(player->mediaData().mediaKey()); - mediaFinished.mArgumentList.append(player->mediaData().mediaTag()); - mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - mediaFinished.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); - - if (mpHost) { - mpHost->raiseEvent(mediaFinished); + if (player->claimingSource()) { + qDebug() << "TMedia::handlePlayerPlaybackStateChanged() - stopped a player that is being handed a new source; the playback it stopped was ended by whoever handed it over."; + return; } - player->mediaPlayer()->setSource(QUrl()); - - if (player->mediaData().mediaWidget() == TMediaData::MediaWidgetLabel && player->mediaData().mediaClose() == TMediaData::MediaCloseEnabled && player->mediaPlayer()->videoOutput() != nullptr) { - emit signal_hideVideoOutput(player.get()); + if (!player->mediaPlayer() || player->mediaPlayer()->source().isEmpty()) { + // Whoever released the source already ended this playback and raised its event. A + // second one from here would carry an empty file name and path, because the URL + // they describe is exactly what was just cleared. + qDebug() << "TMedia::handlePlayerPlaybackStateChanged() - stopped a player that is already holding no source; its playback was ended elsewhere."; + return; } - //: This word is part of a sentence like "Music stops" when the music is about to stop. - printClosedCaption(player->mediaData(), tr("stops")); + // Scheduled before the event below, because a sysMediaFinished handler runs + // synchronously and may hand this player to the next track - which would change both + // the media data and the generations the release has to be judged against. + releaseMediaSourceAfterEvents(player, player->mediaData(), PlaybackEnd::Stopped); + raiseMediaFinishedEvent(player, player->mediaPlayer()->source(), player->mediaData()); + return; } else if (playbackState == QMediaPlayer::PlayingState && player->mediaData().mediaVolume() != TMediaData::MediaVolumePreload) { // NOLINT(readability-else-after-return) TEvent mediaStarted{}; @@ -1548,6 +1918,7 @@ void TMedia::play(TMediaData& mediaData) } std::shared_ptr<TMediaPlayer> pPlayer; + MediaPlayerReservation reservation; // Only match an existing media player for music and video if (mediaData.mediaType() == TMediaData::MediaTypeMusic || mediaData.mediaType() == TMediaData::MediaTypeVideo) { @@ -1571,6 +1942,10 @@ void TMedia::play(TMediaData& mediaData) return; } + // The stops below raise sysMediaFinished into script handlers synchronously - see + // TMediaPlayer::reservedForPlay(). + reservation.reserve(pPlayer); + // Ensure the player has a valid playlist TMediaPlaylist* playlist = pPlayer->playlist(); @@ -1613,7 +1988,7 @@ void TMedia::play(TMediaData& mediaData) } const QUrl mediaSource = mediaData.mediaInput() == TMediaData::MediaInputFile ? QUrl::fromLocalFile(absolutePathFileName) : QUrl(absolutePathFileName); - pPlayer->mediaPlayer()->setSource(mediaSource); + claimPlayerFor(pPlayer, mediaData, mediaSource); } else { if (mediaData.mediaLoops() == TMediaData::MediaLoopsRepeat) { // Repeat indefinitely playlist->setPlaybackMode(TMediaPlaylist::Loop); @@ -1679,7 +2054,7 @@ void TMedia::play(TMediaData& mediaData) playlist->setCurrentIndex(0); pPlayer->setPlaylist(playlist); - pPlayer->mediaPlayer()->setSource(playlist->currentMedia()); + claimPlayerFor(pPlayer, mediaData, playlist->currentMedia()); } // Set volume and start position @@ -1704,6 +2079,16 @@ void TMedia::play(TMediaData& mediaData) // Handle video setup if applicable if (mediaData.mediaType() == TMediaData::MediaTypeVideo && !setupVideo(pPlayer)) { + // Claiming the player disarmed any release still pending on it, so drop the source it + // is now never going to play rather than leave it held indefinitely. + pPlayer->releaseSource(); + + // Same guards as the deferred release: a reused player can still be showing the widget + // of an earlier clip, and hiding that is only wanted when this request asked for it. + if (mediaData.mediaWidget() == TMediaData::MediaWidgetLabel && mediaData.mediaClose() == TMediaData::MediaCloseEnabled && pPlayer->mediaPlayer()->videoOutput() != nullptr) { + emit signal_hideVideoOutput(pPlayer.get()); + } + return; } diff --git a/src/TMedia.h b/src/TMedia.h index 545188571..51cf0474e 100644 --- a/src/TMedia.h +++ b/src/TMedia.h @@ -32,8 +32,10 @@ #include "TMediaPlaylist.h" #include <memory> +#include <utility> #include <QAudioOutput> #include <QMediaPlayer> +#include <QUrl> class QJsonObject; @@ -55,6 +57,14 @@ public: ~TMediaPlayer() { if (mMediaPlayer) { + // The unload below releases the media file, and announces itself wherever it + // changes something - sourceChanged from clearing the source, playbackStateChanged + // from a stop that had something to stop - synchronously, into handlers reading a + // TMediaPlayer whose members are about to go. The handlers TMedia installs decline + // by way of a weak_ptr that has already expired by now; blocking makes that + // structural rather than something each new handler has to remember. The unload + // still happens, and Qt unblocks in ~QObject so destroyed() still arrives. + mMediaPlayer->blockSignals(true); mMediaPlayer->stop(); mMediaPlayer->setSource(QUrl()); } @@ -62,6 +72,83 @@ public: TMediaData mediaData() const { return mMediaData; } void setMediaData(TMediaData& mediaData) { mMediaData = mediaData; } + + // TMedia::releaseMediaSourceAfterEvents() ends a playback one event-loop turn late, by which + // time a stopped player is indistinguishable from one asynchronously loading a source set + // since. These two counters record what happened in between: a claim is this player being + // given a new track to play, a continuation is its own playlist advancing or looping. + // Outside this class, install a source only through claimSource() or continuePlaying() - + // never through mediaPlayer()->setSource() directly, since a missed bump lets that pending + // release clear the new source again. As of Qt 6.9 that reproduces only on backends that + // load asynchronously, so it will not show up on a macOS-only test run. + void claimSource(const QUrl& media) + { + // Bumped before the source is touched because setSource() can raise errorOccurred + // synchronously, and that handler snapshots these counters to arm its own release. + ++mClaimGeneration; + mEndAnnounced = false; + if (mMediaPlayer) { + // A stopped player still holding anything has that media loaded, so handing it a + // source now starts playback synchronously and raises sysMediaStarted inside the + // script call that asked for it. Unloading first restores the usual asynchronous + // start. The reported symptom was replaying the same file (#9611). + if (mMediaPlayer->playbackState() == QMediaPlayer::StoppedState && !mMediaPlayer->source().isEmpty()) { + releaseSource(); + } + mClaimingSource = true; + mMediaPlayer->setSource(media); + mClaimingSource = false; + } + } + void continuePlaying(const QUrl& media) + { + ++mContinuationGeneration; + if (mMediaPlayer) { + mMediaPlayer->setSource(media); + // Cleared after the source is installed, not before. On a backend that delivers + // EndOfMedia while the player is still playing this setSource() is a real + // playing-to-stopped transition, and its handler announces the pass that just ended. + // Clearing first leaves that announcement standing for the pass about to start, and + // the last pass has no continuation left to clear it again. + mEndAnnounced = false; + mMediaPlayer->play(); + } + } + // No bump: an empty source cannot be mistaken for a track that needs protecting from a + // pending release. A release already scheduled therefore still fires, and recognises that + // it has nothing left to do by the source being empty - see releaseMediaSourceAfterEvents(). + void releaseSource() + { + if (mMediaPlayer) { + mMediaPlayer->setSource(QUrl()); + } + } + quint64 claimGeneration() const { return mClaimGeneration; } + quint64 continuationGeneration() const { return mContinuationGeneration; } + + // One ended playback can be reported from three places - a stop, a load error and the + // StoppedState that follows either - and the source stays set until the deferred release + // runs, so each of them still finds a playback that looks live. Only the first may tell + // scripts about it: a second sysMediaFinished for the same track is at best a duplicate, + // and at worst unbounded recursion when the handler stops the media it was told about. + // Cleared by the two ways this player is given something new to play, above. + bool endAnnounced() const { return mEndAnnounced; } + void noteEndAnnounced() { mEndAnnounced = true; } + + // True only while claimSource() is installing a new source. A stop delivered during that is + // the previous track being displaced rather than this one ending, and whoever displaced it + // has already said so - with the metadata the player no longer holds. + bool claimingSource() const { return mClaimingSource; } + + // A play() call owns the player it is setting up until it returns, because the events it + // raises run script handlers synchronously. A handler that starts media of its own must be + // given a different player: two play() calls sharing one overwrite each other's playlist and + // media data. + bool reservedForPlay() const { return mReservedForPlay; } + void setReservedForPlay(const bool reserved) { mReservedForPlay = reserved; } + + // Read-only uses and playback control are fine; do not setSource() on it, for the reason + // given above claimSource(). QMediaPlayer* mediaPlayer() const { return mMediaPlayer.get(); } bool isInitialized() const { return initialized; } QMediaPlayer::PlaybackState getPlaybackState() const @@ -117,6 +204,11 @@ private: std::unique_ptr<QMediaPlayer> mMediaPlayer; std::unique_ptr<TMediaPlaylist> mPlaylist; bool initialized = false; + quint64 mClaimGeneration = 0; + quint64 mContinuationGeneration = 0; + bool mEndAnnounced = false; + bool mClaimingSource = false; + bool mReservedForPlay = false; }; class TMedia : public QObject @@ -142,13 +234,25 @@ public: void pauseMedia(TMediaData& mediaData); void stopMedia(TMediaData& mediaData); void parseGMCP(QString& packageMessage, QString& gmcp); - bool purgeMediaCache(); + std::pair<bool, QString> purgeMediaCache(); void refreshAudioDevices(); void muteMedia(const TMediaData::MediaProtocol mediaProtocol); void unmuteMedia(const TMediaData::MediaProtocol mediaProtocol); void printClosedCaption(const TMediaData& mediaData, const QString& action) const; void stopAllMediaPlayers(); + // Read-only diagnostics for the media tests. A deferred release is otherwise hard to + // observe: playingMedia() has already dropped the player, the closed caption needs captions + // enabled and signal_hideVideoOutput needs a video widget. + int playersHoldingSource() const; + // Players that have actually started. playingMedia() deliberately counts one that is still + // loading as playing, which is not enough for a test that needs playback truly under way. + int playersInPlayingState() const; + // Players registered in the protocol lists, so a reuse test can tell a claimed player from + // a second one allocated alongside it. A player play() abandons before it finishes is never + // registered and so is never counted. + int mediaPlayerCount() const; + // Returns true if mediaFileName would resolve to a location outside mediaRoot, either // lexically (e.g. via "../" traversal) or through a symlink component that already exists // under mediaRoot but points elsewhere. Static so it can be unit-tested without a Host. @@ -188,6 +292,15 @@ private: std::shared_ptr<TMediaPlayer> matchMediaPlayer(TMediaData& mediaData); bool doesMediaHavePriorityToPlay(TMediaData& mediaData, const QString& absolutePathFileName); void matchMediaKeyAndStopMediaVariants(TMediaData& mediaData, const QString& absolutePathFileName); + // Why a playback ended, which decides whether the player's own state is worth consulting + // when the deferred release comes around. See releaseMediaSourceAfterEvents(). + enum class PlaybackEnd { Stopped, Failed }; + // endedUrl and endedData are passed in rather than read off the player, so a caller that has + // already released the source can still say what it was that ended. + void raiseMediaFinishedEvent(const std::shared_ptr<TMediaPlayer>& player, const QUrl& endedUrl, const TMediaData& endedData); + void claimPlayerFor(const std::shared_ptr<TMediaPlayer>& player, TMediaData& mediaData, const QUrl& mediaSource); + void endDisplacedPlayback(const std::shared_ptr<TMediaPlayer>& player); + void releaseMediaSourceAfterEvents(const std::shared_ptr<TMediaPlayer>& player, const TMediaData& endedData, const PlaybackEnd endedBy); void handlePlayerPlaybackStateChanged(QMediaPlayerPlaybackState playbackState, const std::shared_ptr<TMediaPlayer>& player); bool setupVideo(const std::shared_ptr<TMediaPlayer>& player); static QString mediaTypeToString(int mediaType); diff --git a/src/TMxpFrameManager.cpp b/src/TMxpFrameManager.cpp index b3931d5e9..173eaac2b 100644 --- a/src/TMxpFrameManager.cpp +++ b/src/TMxpFrameManager.cpp @@ -124,6 +124,11 @@ bool TMxpFrameManager::createFrame(const QString& name, const QMap<QString, QStr qDebug() << "TMxpFrameManager::createFrame:" << name << "TITLE attr:" << attributes.value(qsl("TITLE")) << "title:" << frame->title << "floating:" << frame->floating; #endif + // relayoutFrames() works off mFrameOrder, so nothing may lay a frame out + // before it is in there + mFrames[name] = frame; + mFrameOrder.append(frame); + // Create the appropriate UI layout if (frame->isInternal) { if (!frame->dockFrame.isEmpty() && frame->align == qsl("client")) { @@ -135,9 +140,6 @@ bool TMxpFrameManager::createFrame(const QString& name, const QMap<QString, QStr layoutExternalFrame(frame); } - // Store the frame - mFrames[name] = frame; - return true; } @@ -176,6 +178,7 @@ bool TMxpFrameManager::closeFrame(const QString& name) // Remove from frames map and delete mFrames.remove(name); + mFrameOrder.removeOne(frame); delete frame; // No need to recalculate borders for tab frames since they don't affect main window borders @@ -200,10 +203,11 @@ bool TMxpFrameManager::closeFrame(const QString& name) removeFrameFromHierarchy(frame); mFrames.remove(name); + mFrameOrder.removeOne(frame); delete frame; - // Recalculate borders after frame removal to reclaim space - recalculateBorders(); + // Reposition what is left so it reclaims the space the frame gave up + relayoutFrames(); return true; } @@ -257,6 +261,7 @@ void TMxpFrameManager::resetAllFrames() closeFrame(name); } + mFrameOrder.clear(); mMxpBorders = QMargins(); if (mpHost) { @@ -350,29 +355,38 @@ QStringList TMxpFrameManager::getFrameNames() const return mFrames.keys(); } -void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame) +QRect TMxpFrameManager::availableFrameArea() const { - if (!mpHost || !mpHost->mpConsole) { - qWarning() << "TMxpFrameManager::layoutInternalFrame: No console available"; - return; + if (!mpHost || mpHost->mpConsole.isNull()) { + return {}; } - TMainConsole* mainConsole = mpHost->mpConsole.data(); - - // Note: DOCK tabbing is handled in createFrame() when ALIGN=CLIENT is set. - // Per CMUD, ALIGN=CLIENT + DOCK creates tabbed frames. - // This is a CMUD extension, not part of the official MXP 1.0 specification. - - // Check if we're inside a DEST - if so, nest this frame inside the destination - TMxpFrame* parentFrame = nullptr; - if (!mCurrentDestination.isEmpty()) { - parentFrame = getFrame(mCurrentDestination); + // getMainWindowSize() rather than mpMainFrame's own geometry, which + // TConsole::resizeEvent() sets to the full console size until the layout + // corrects it. It is also the size Lua scripts lay themselves out against, + // so taking the user borders off it keeps frames out of the space a package + // such as the base UI has reserved with setBorderRight() and friends. + QRect area = QRect(QPoint(0, 0), mpHost->mpConsole->getMainWindowSize()).marginsRemoved(mpHost->userBorders()); + if (area.width() < 0) { + area.setWidth(0); } + if (area.height() < 0) { + area.setHeight(0); + } + return area; +} +// Works out where a frame goes. An edge aligned top level frame consumes the +// space it takes from mMxpBorders and a nested one advances its parent's +// usedHeight; an absolutely positioned frame consumes neither. Callers are +// responsible for pushing the updated borders to the Host. +QRect TMxpFrameManager::calculateFrameGeometry(TMxpFrame* frame, TMxpFrame* parentFrame) +{ // Determine the container for this frame QSize containerSize; int containerX = 0; int containerY = 0; + QRect area; if (parentFrame && parentFrame->widget) { // Nested frame - position relative to parent @@ -384,12 +398,17 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame) containerY += parentFrame->usedHeight; containerSize.setHeight(containerSize.height() - parentFrame->usedHeight); } else { - // Top-level frame - use MXP-specific borders (not Host borders which are for Lua) - containerSize = mainConsole->size(); - containerX = mMxpBorders.left(); - containerY = mMxpBorders.top(); - containerSize.setWidth(containerSize.width() - mMxpBorders.left() - mMxpBorders.right()); - containerSize.setHeight(containerSize.height() - mMxpBorders.top() - mMxpBorders.bottom()); + // A parent with no widget of its own gives nothing to place against, so + // such a frame is placed as a top level one for the rest of this + // calculation - frame->parentFrame still records the hierarchy + parentFrame = nullptr; + // MXP borders stack inwards from the area the user borders leave. + // userBorders() and not borders(), which already carries the MXP borders + // this function is in the middle of recomputing. + area = availableFrameArea(); + containerX = area.x() + mMxpBorders.left(); + containerY = area.y() + mMxpBorders.top(); + containerSize = QSize(area.width() - mMxpBorders.left() - mMxpBorders.right(), area.height() - mMxpBorders.top() - mMxpBorders.bottom()); } // Calculate frame dimensions relative to container @@ -404,13 +423,14 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame) } // Ensure minimum size for visibility - if (frameWidth < 50) + if (frameWidth < 50) { frameWidth = 100; + } // For character-based height specs, handle minimum size more carefully - bool isCharacterHeight = frame->height.trimmed().endsWith('c', Qt::CaseInsensitive); - bool isCharacterWidth = frame->width.trimmed().endsWith('c', Qt::CaseInsensitive); - bool willHaveTitle = !frame->floating && frame->hasExplicitTitle; + const bool isCharacterHeight = frame->height.trimmed().endsWith('c', Qt::CaseInsensitive); + const bool isCharacterWidth = frame->width.trimmed().endsWith('c', Qt::CaseInsensitive); + const bool willHaveTitle = !frame->floating && frame->hasExplicitTitle; // Apply minimum size for visibility to non-character-based frames if (frameHeight < 20 && !isCharacterHeight) { @@ -455,7 +475,7 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame) // Calculate position based on alignment int x = containerX; int y = containerY; - QString align = frame->align.toLower(); + const QString align = frame->align.toLower(); if (parentFrame) { // Nested frame - position within parent's bounds using VBox/HBox logic @@ -473,62 +493,92 @@ void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame) y = containerY + containerSize.height() - frameHeight; frameWidth = containerSize.width(); } - } else { - // Top-level frame - position at window edges and update MXP borders - QSize windowSize = mainConsole->size(); - // Check for LEFT/TOP absolute positioning first - these take precedence over alignment - bool hasAbsolutePosition = !frame->left.isEmpty() || !frame->top.isEmpty(); - - if (hasAbsolutePosition) { - // Absolute positioning via LEFT/TOP attributes - if (!frame->left.isEmpty()) { - QSize leftSize = calculateFrameSize(frame->left, windowSize, false); - if (leftSize.width() > 0) { - x = leftSize.width(); - } - } - if (!frame->top.isEmpty()) { - QSize topSize = calculateFrameSize(frame->top, windowSize, true); - if (topSize.height() > 0) { - y = topSize.height(); - } - } - // Absolute positioned frames don't modify MXP borders - } else if (align == qsl("left")) { - // Left-aligned: position at actual left edge (after existing left MXP frames) - x = mMxpBorders.left(); - y = 0; - frameHeight = windowSize.height(); - // Update MXP left border - mMxpBorders.setLeft(mMxpBorders.left() + frameWidth); - } else if (align == qsl("right")) { - // Right-aligned: position at right edge - x = windowSize.width() - mMxpBorders.right() - frameWidth; - y = 0; - frameHeight = windowSize.height(); - mMxpBorders.setRight(mMxpBorders.right() + frameWidth); - } else if (align == qsl("top")) { - // Top-aligned: position at top edge - x = mMxpBorders.left(); - y = mMxpBorders.top(); - frameWidth = windowSize.width() - mMxpBorders.left() - mMxpBorders.right(); - mMxpBorders.setTop(mMxpBorders.top() + frameHeight); - } else if (align == qsl("bottom")) { - // Bottom-aligned: position at bottom edge - x = mMxpBorders.left(); - y = windowSize.height() - mMxpBorders.bottom() - frameHeight; - frameWidth = windowSize.width() - mMxpBorders.left() - mMxpBorders.right(); - mMxpBorders.setBottom(mMxpBorders.bottom() + frameHeight); - } else { - // No alignment and no absolute positioning - use container defaults - x = containerX; - y = containerY; - } - - mpHost->setMxpBorders(mMxpBorders); + return {x, y, frameWidth, frameHeight}; } + // Top-level frame - position at the edges of the available area and update MXP borders + + // Check for LEFT/TOP absolute positioning first - these take precedence over alignment + const bool hasAbsolutePosition = !frame->left.isEmpty() || !frame->top.isEmpty(); + + if (hasAbsolutePosition) { + // Absolute positioning via LEFT/TOP attributes + if (!frame->left.isEmpty()) { + QSize leftSize = calculateFrameSize(frame->left, area.size(), false); + if (leftSize.width() > 0) { + x = area.x() + leftSize.width(); + } + } + if (!frame->top.isEmpty()) { + QSize topSize = calculateFrameSize(frame->top, area.size(), true); + if (topSize.height() > 0) { + y = area.y() + topSize.height(); + } + } + // Absolute positioned frames don't modify MXP borders + } else if (align == qsl("left")) { + // Left-aligned: position at actual left edge (after existing left MXP frames) + x = area.x() + mMxpBorders.left(); + y = area.y(); + frameHeight = area.height(); + // Update MXP left border + mMxpBorders.setLeft(mMxpBorders.left() + frameWidth); + } else if (align == qsl("right")) { + // Right-aligned: position at right edge + x = area.x() + area.width() - mMxpBorders.right() - frameWidth; + y = area.y(); + frameHeight = area.height(); + mMxpBorders.setRight(mMxpBorders.right() + frameWidth); + } else if (align == qsl("top")) { + // Top-aligned: position at top edge + x = area.x() + mMxpBorders.left(); + y = area.y() + mMxpBorders.top(); + frameWidth = area.width() - mMxpBorders.left() - mMxpBorders.right(); + mMxpBorders.setTop(mMxpBorders.top() + frameHeight); + } else if (align == qsl("bottom")) { + // Bottom-aligned: position at bottom edge + x = area.x() + mMxpBorders.left(); + y = area.y() + area.height() - mMxpBorders.bottom() - frameHeight; + frameWidth = area.width() - mMxpBorders.left() - mMxpBorders.right(); + mMxpBorders.setBottom(mMxpBorders.bottom() + frameHeight); + } + + return {x, y, frameWidth, frameHeight}; +} + +void TMxpFrameManager::layoutInternalFrame(TMxpFrame* frame) +{ + if (!mpHost || !mpHost->mpConsole) { + qWarning() << "TMxpFrameManager::layoutInternalFrame: No console available"; + return; + } + + TMainConsole* mainConsole = mpHost->mpConsole.data(); + + // Note: DOCK tabbing is handled in createFrame() when ALIGN=CLIENT is set. + // Per CMUD, ALIGN=CLIENT + DOCK creates tabbed frames. + // This is a CMUD extension, not part of the official MXP 1.0 specification. + + // Check if we're inside a DEST - if so, nest this frame inside the destination + TMxpFrame* parentFrame = nullptr; + if (!mCurrentDestination.isEmpty()) { + parentFrame = getFrame(mCurrentDestination); + } + + const QRect geometry = calculateFrameGeometry(frame, parentFrame); + const int x = geometry.x(); + const int y = geometry.y(); + const int frameWidth = geometry.width(); + const int frameHeight = geometry.height(); + + // A nested frame never touches mMxpBorders, so this hands Host the margins + // it already has and setBorders() drops it + mpHost->setMxpBorders(mMxpBorders); + + const bool isCharacterHeight = frame->height.trimmed().endsWith('c', Qt::CaseInsensitive); + const bool willHaveTitle = !frame->floating && frame->hasExplicitTitle; + // FLOATING attribute, no explicit title, or very small height = borderless frame without header // Exception: character-based frames with explicit titles always show headers bool showHeader = !frame->floating && frame->hasExplicitTitle && (frameHeight >= 50 || (isCharacterHeight && willHaveTitle)); @@ -1002,51 +1052,58 @@ void TMxpFrameManager::layoutTabIntoExistingFrame(TMxpFrame* frame, TMxpFrame* t console->show(); } -void TMxpFrameManager::recalculateBorders() +void TMxpFrameManager::scheduleRelayout() +{ + if (mRelayoutPending || mFrames.isEmpty()) { + return; + } + + // Deferred so that the layout of the widgets frames are placed against has + // settled, and so that pushing new borders from here cannot re-enter the + // resize handling that asked for the relayout. The push at the end of a + // relayout does schedule one more pass, which then finds the same borders + // and stops there because Host::setBorders() ignores an unchanged value. + mRelayoutPending = true; + QTimer::singleShot(0, mpHost, [this]() { + mRelayoutPending = false; + relayoutFrames(); + }); +} + +void TMxpFrameManager::relayoutFrames() { if (!mpHost) { return; } - // Reset borders and recalculate based on remaining frames + // calculateFrameGeometry() accumulates into these, so they have to start + // empty or every pass would count the same frames again mMxpBorders = QMargins(); - if (!mpHost->mpConsole) { + if (mpHost->mpConsole.isNull() || !mpHost->mpConsole->mpMainFrame) { mpHost->setMxpBorders(mMxpBorders); return; } - QSize windowSize = mpHost->mpConsole->size(); + const QWidget* mainFrame = mpHost->mpConsole->mpMainFrame; - // Recalculate borders by examining all remaining top-level frames - // (frames without parents that affect the main window borders) - for (auto* frame : mFrames.values()) { - if (!frame || frame->parentFrame) { - continue; // Skip child frames, only process top-level frames - } + // calculateFrameGeometry() also accumulates into a parent's usedHeight, so + // without this nested frames would march further down on every pass + for (auto* frame : std::as_const(mFrameOrder)) { + frame->usedHeight = 0; + } - // Only frames that were positioned using alignment (not absolute positioning) - // contribute to MXP borders - bool hasAbsolutePosition = !frame->left.isEmpty() || !frame->top.isEmpty(); - if (hasAbsolutePosition) { + for (auto* frame : std::as_const(mFrameOrder)) { + // Skip a frame whose layout never produced a widget, one docked as a tab + // (the QTabWidget places it), and one in a window of its own. An external + // frame keeps mpMainFrame as its parent even after Qt::Window is set on + // it, so isWindow() rather than the parent is what tells them apart. + if (!frame->widget || frame->widget->isWindow() || frame->widget->parentWidget() != mainFrame) { continue; } - QString align = frame->align.toLower(); - QSize widthSize = calculateFrameSize(frame->width, windowSize, false); - QSize heightSize = calculateFrameSize(frame->height, windowSize, true); - - if (align == qsl("left")) { - mMxpBorders.setLeft(mMxpBorders.left() + widthSize.width()); - } else if (align == qsl("right")) { - mMxpBorders.setRight(mMxpBorders.right() + widthSize.width()); - } else if (align == qsl("top")) { - mMxpBorders.setTop(mMxpBorders.top() + heightSize.height()); - } else if (align == qsl("bottom")) { - mMxpBorders.setBottom(mMxpBorders.bottom() + heightSize.height()); - } + frame->widget->setGeometry(calculateFrameGeometry(frame, frame->parentFrame)); } - // Apply the recalculated borders mpHost->setMxpBorders(mMxpBorders); } diff --git a/src/TMxpFrameManager.h b/src/TMxpFrameManager.h index d0db143c4..0c00b514f 100644 --- a/src/TMxpFrameManager.h +++ b/src/TMxpFrameManager.h @@ -27,6 +27,7 @@ #include <QMap> #include <QMargins> #include <QPointer> +#include <QRect> #include <QSize> #include <QString> #include <QStringList> @@ -111,31 +112,41 @@ public: QStringList getFrameNames() const; bool frameExists(const QString& name) const { return mFrames.contains(name); } int frameCount() const { return mFrames.size(); } - + + // Reposition every frame on the next event loop turn, once the space they + // are laid out in has changed. Does nothing while no frames are open. + void scheduleRelayout(); + // Configuration static constexpr int MAX_FRAMES = 20; private: Host* mpHost; QMap<QString, TMxpFrame*> mFrames; + // Frames in creation order: borders accumulate inwards, so the order frames + // were opened in decides where each one sits + QList<TMxpFrame*> mFrameOrder; QString mCurrentDestination; // Current output target (empty = main console) QMargins mMxpBorders; // MXP-specific borders, separate from Host::mBorders - + bool mRelayoutPending = false; + // Layout and sizing helpers void layoutInternalFrame(TMxpFrame* frame); void layoutExternalFrame(TMxpFrame* frame); void layoutTabFrame(TMxpFrame* frame); void layoutTabIntoExistingFrame(TMxpFrame* frame, TMxpFrame* targetFrame); + QRect availableFrameArea() const; + QRect calculateFrameGeometry(TMxpFrame* frame, TMxpFrame* parentFrame); QSize calculateFrameSize(const QString& spec, const QSize& containerSize, bool isHeight); + void relayoutFrames(); Qt::DockWidgetArea alignmentToDockArea(const QString& align); - + // Validation bool validateFrameName(const QString& name) const; bool canCreateFrame() const; - + // Cleanup void removeFrameFromHierarchy(TMxpFrame* frame); - void recalculateBorders(); }; #endif // MUDLET_TMXPFRAMEMANAGER_H diff --git a/src/TMxpLinkTagHandler.cpp b/src/TMxpLinkTagHandler.cpp index dde289c27..51bbbbc9f 100644 --- a/src/TMxpLinkTagHandler.cpp +++ b/src/TMxpLinkTagHandler.cpp @@ -19,7 +19,9 @@ ***************************************************************************/ #include "TMxpLinkTagHandler.h" +#include "LuaLiteral.h" #include "TMxpClient.h" +#include "UntrustedText.h" // <A href=URL [hint=text] [expire=name]> TMxpTagHandlerResult TMxpLinkTagHandler::handleStartTag(TMxpContext& ctx, TMxpClient& client, MxpStartTag* tag) @@ -37,9 +39,12 @@ TMxpTagHandlerResult TMxpLinkTagHandler::handleStartTag(TMxpContext& ctx, TMxpCl return MXP_TAG_NOT_HANDLED; } - const QString hint = tag->hasAttribute(qsl("hint")) ? tag->getAttributeValue(qsl("hint")) : href; + // Server-supplied, and lands in the same tooltip as an OSC 8 hint. An + // explicit hint is prose written to be read; falling back to the href makes + // this a link target the user is being asked to trust. + const QString hint = tag->hasAttribute(qsl("hint")) ? UntrustedText::forAuthoredText(tag->getAttributeValue(qsl("hint"))) : UntrustedText::forTarget(href); - href = qsl("openUrl([[%1]])").arg(href); + href = qsl("openUrl(%1)").arg(LuaLiteral::quote(href)); // Use the version of setLink that supports expire names if (!expireName.isEmpty()) { diff --git a/src/TMxpProcessor.cpp b/src/TMxpProcessor.cpp index 19a754e2b..1a4276937 100644 --- a/src/TMxpProcessor.cpp +++ b/src/TMxpProcessor.cpp @@ -390,7 +390,6 @@ TMxpProcessingResult TMxpProcessor::processMxpInput(char& ch, bool resolveCustom return HANDLER_INSERT_ENTITY_LIT; } } - // ask for the next char return HANDLER_NEXT_CHAR; } diff --git a/src/TRoom.cpp b/src/TRoom.cpp index 2a55d2079..a1e654e9a 100644 --- a/src/TRoom.cpp +++ b/src/TRoom.cpp @@ -892,8 +892,17 @@ void TRoom::restore(QDataStream& ifs, int roomID, int version) if (!hiddenString.compare(QLatin1String("true"), Qt::CaseInsensitive)) { hidden = true; } + } else { + // The stream carries the authoritative value so any copy of the + // fallback key in the user data is stale: + userData.remove(QLatin1String("system.fallback_hidden")); } - if (version < 19) { + if (version >= 19) { + // Clean up a stale fallback key that past versions could leave + // behind in the live room's user data (and thus in files saved + // from it) after saving in a format before 19: + userData.remove(QLatin1String("system.fallback_symbol")); + } else { const QString symbolString = userData.take(QLatin1String("system.fallback_symbol")); if (!symbolString.isEmpty()) { // There is a fallback in the user data @@ -915,6 +924,10 @@ void TRoom::restore(QDataStream& ifs, int roomID, int version) if (userData.contains(symbolColorFallbackKey)) { mSymbolColor = QColor(userData.take(symbolColorFallbackKey)); } + } else { + // The stream carries the authoritative value so any copy of the + // fallback key in the user data is stale: + userData.remove(QLatin1String("system.fallback_symbol_color")); } // Border properties are stored in userData (not binary stream) to avoid map bloat diff --git a/src/TScript.cpp b/src/TScript.cpp index 441ce9b1b..0c6f01625 100644 --- a/src/TScript.cpp +++ b/src/TScript.cpp @@ -25,9 +25,12 @@ #include "Host.h" +#include "ScriptUnit.h" #include "TDebug.h" #include "mudlet.h" +#include <QScopeGuard> + TScript::TScript(TScript* parent, Host* pHost) : Tree<TScript>(parent) , mpHost(pHost) @@ -120,6 +123,26 @@ bool TScript::setScript(const QString& script) bool TScript::compileScript(bool saveLoadingError) { + // Whilst this frame is on the stack ScriptUnit::uninstall() must defer deleting + // this profile's scripts: the top-level Lua body run below (the lua_pcall inside + // TLuaInterpreter::compile()) can uninstall its own package - a common package + // auto-updater pattern - and freeing this script mid-compile, or writing to it + // after compile() returns (see mNeedsToBeCompiled/mOK_code below and in + // setScript()), is a use-after-free. See ScriptUnit::mProcessingDepth. + ScriptUnit* pUnit = mpHost->getScriptUnit(); + pUnit->beginProcessing(); + // NB: deliberately decrement-only - do NOT add a doCleanup() call here. setScript() + // writes mOK_code AFTER this returns and ScriptUnit::compileAll()'s loop is still + // iterating the root list, so deleting `this` now would be a use-after-free. The + // deferred deletes are flushed at a safe point once the pointer is no longer in + // use: after ScriptUnit::compileAll()'s loop, at the end of the editor's + // saveScript(), in Host::raiseEvent()'s scope guard, and by the catch-all + // doCleanup() in Host::incomingStreamProcessor()/slot_purgeTemps() and the queued + // save in Host::uninstallPackage(). + const auto processingGuard = qScopeGuard([pUnit] { + pUnit->endProcessing(); + }); + QString error; if (mpHost->mLuaInterpreter.compile(mScript, error, QString("Script: ") + getName())) { mNeedsToBeCompiled = false; diff --git a/src/TTextEdit.cpp b/src/TTextEdit.cpp index 013077005..3b1ed66ca 100644 --- a/src/TTextEdit.cpp +++ b/src/TTextEdit.cpp @@ -39,6 +39,7 @@ #include "widechar_width.h" #include "TTextProperties.h" +#include <algorithm> #include <chrono> #include <cmath> #include <QtEvents> @@ -452,29 +453,30 @@ void TTextEdit::scrollDown(int lines) } } -void TTextEdit::drawLine(QPainter& painter, int lineNumber, int lineOfScreen, int* offset) const +bool TTextEdit::hasBufferLine(int lineNumber) const { + return lineNumber >= 0 && lineNumber < static_cast<int>(mpBuffer->buffer.size()); +} + +TChar TTextEdit::timeStampCharStyle() const +{ + return TChar(QColor(200, 150, 0), mpConsole->getConsoleBgColor()); +} + +void TTextEdit::layoutLine(int lineNumber, int lineOfScreen, const TChar& timeStampStyle, LineLayout& layout, int* offset) const +{ + layout.clear(); QPoint cursor(-mCursorX, lineOfScreen); - QString lineText = mpBuffer->lineBuffer.at(lineNumber); + const QString lineText = mpBuffer->lineBuffer.at(lineNumber); QTextBoundaryFinder boundaryFinder(QTextBoundaryFinder::Grapheme, lineText); int currentSize = lineText.size(); if (mpConsole->showTimeStamps()) { - TChar timeStampStyle(QColor(200, 150, 0), mpConsole->getConsoleBgColor()); - QString timestamp(mpBuffer->timeBuffer.at(lineNumber)); - QVector<QColor> fgColors; - QVector<QRect> textRects; - QVector<int> charWidths; - QVector<QString> graphemes; + const QString timestamp(mpBuffer->timeBuffer.at(lineNumber)); for (const QChar c : timestamp) { // The column argument is not incremented here (is fixed at 0) so // the timestamp does not take up any places when it is clicked on // by the mouse... - cursor.setX(cursor.x() + drawGraphemeBackground(painter, fgColors, textRects, graphemes, charWidths, cursor, c, 0, lineNumber, timeStampStyle)); - } - int index = -1; - for (const QChar c : timestamp) { - ++index; - drawGraphemeForeground(painter, fgColors.at(index), textRects.at(index), c, timeStampStyle); + cursor.setX(cursor.x() + layoutGrapheme(layout, cursor, c, 0, lineNumber, timeStampStyle)); } currentSize += mudlet::smTimeStampFormat.size(); } @@ -485,414 +487,421 @@ void TTextEdit::drawLine(QPainter& painter, int lineNumber, int lineOfScreen, in } int columnWithOutTimestamp = 0; - QVector<QColor> fgColors; - QVector<QRect> textRects; - QVector<int> charWidths; - QVector<QString> graphemes; for (int indexOfChar = 0, total = lineText.size(); indexOfChar < total;) { - int nextBoundary = boundaryFinder.toNextBoundary(); + const int nextBoundary = boundaryFinder.toNextBoundary(); + if (Q_UNLIKELY(nextBoundary <= indexOfChar)) { + // toNextBoundary() reports -1 once it can no longer advance, which + // would send indexOfChar backwards and index the line out of bounds + break; + } - TChar& charStyle = mpBuffer->buffer.at(lineNumber).at(indexOfChar); - int graphemeWidth = drawGraphemeBackground( - painter, fgColors, textRects, graphemes, charWidths, cursor, lineText.mid(indexOfChar, nextBoundary - indexOfChar), columnWithOutTimestamp, lineNumber, charStyle); + const TChar& charStyle = mpBuffer->buffer.at(lineNumber).at(indexOfChar); + const int graphemeWidth = layoutGrapheme(layout, cursor, lineText.mid(indexOfChar, nextBoundary - indexOfChar), columnWithOutTimestamp, lineNumber, charStyle); cursor.setX(cursor.x() + graphemeWidth); indexOfChar = nextBoundary; columnWithOutTimestamp += graphemeWidth; } - boundaryFinder.toStart(); - int index = -1; - for (int indexOfChar = 0, total = lineText.size(); indexOfChar < total;) { - int nextBoundary = boundaryFinder.toNextBoundary(); - - TChar& charStyle = mpBuffer->buffer.at(lineNumber).at(indexOfChar); - ++index; - drawGraphemeForeground(painter, fgColors.at(index), textRects.at(index), graphemes.at(index), charStyle); - indexOfChar = nextBoundary; - } // If caret mode is enabled and the line is empty, still draw the caret. if (mpHost && mpHost->caretEnabled() && mCaretLine == lineNumber && lineText.isEmpty()) { - auto textRect = QRect(0, mFontHeight * lineOfScreen, mFontWidth, mFontHeight); - painter.fillRect(textRect, mCaretColor); + GraphemeRun caretRun; + caretRun.textRect = QRect(0, mFontHeight * lineOfScreen, mFontWidth, mFontHeight); + caretRun.bgColor = mCaretColor; + caretRun.fillsBackground = true; + layout.push_back(std::move(caretRun)); } } -void TTextEdit::replaceControlCharacterWith_Picture(const uint unicode, const QString& grapheme, const int column, QVector<QString>& graphemes, int& charWidth) const +void TTextEdit::paintBackgrounds(QPainter& painter, const LineLayout& layout) const +{ + for (const GraphemeRun& run : layout) { + if (run.fillsBackground) { + painter.fillRect(run.textRect, run.bgColor); + } + } +} + +void TTextEdit::paintForegrounds(QPainter& painter, const LineLayout& layout, const QRect& clip) const +{ + if (layout.empty()) { + return; + } + if (!clip.isNull()) { + painter.save(); + painter.setClipRect(clip); + } + for (const GraphemeRun& run : layout) { + if (run.style) { + paintGraphemeForeground(painter, run); + } + } + if (!clip.isNull()) { + painter.restore(); + } +} + +void TTextEdit::replaceControlCharacterWith_Picture(const uint unicode, const QString& grapheme, const int column, QString& outGrapheme, int& charWidth) const { switch (unicode) { case 0: - graphemes.append(QChar(0x2400)); + outGrapheme = QChar(0x2400); charWidth = 1; break; // NUL - not sure that this can appear case 1: - graphemes.append(QChar(0x2401)); + outGrapheme = QChar(0x2401); charWidth = 1; break; // SOH case 2: - graphemes.append(QChar(0x2402)); + outGrapheme = QChar(0x2402); charWidth = 1; break; // STX case 3: - graphemes.append(QChar(0x2403)); + outGrapheme = QChar(0x2403); charWidth = 1; break; // ETX case 4: - graphemes.append(QChar(0x2404)); + outGrapheme = QChar(0x2404); charWidth = 1; break; // EOT case 5: - graphemes.append(QChar(0x2405)); + outGrapheme = QChar(0x2405); charWidth = 1; break; // ENQ case 6: - graphemes.append(QChar(0x2406)); + outGrapheme = QChar(0x2406); charWidth = 1; break; // ACK case 7: - graphemes.append(QChar(0x2407)); + outGrapheme = QChar(0x2407); charWidth = 1; break; // BEL - the (audio) handling of this gets done when it is received, not when it is displayed here: case 8: - graphemes.append(QChar(0x2408)); + outGrapheme = QChar(0x2408); charWidth = 1; break; // BS case 9: // HT // Makes the spacing behave like a tab charWidth = mTabStopwidth - (column % mTabStopwidth); // But print the "control picture" on top - graphemes.append(QChar(0x2409)); + outGrapheme = QChar(0x2409); break; case 10: - graphemes.append(QChar(0x240A)); + outGrapheme = QChar(0x240A); charWidth = 1; break; // LF - may not ever appear! case 11: - graphemes.append(QChar(0x240B)); + outGrapheme = QChar(0x240B); charWidth = 1; break; // VT case 12: - graphemes.append(QChar(0x240C)); + outGrapheme = QChar(0x240C); charWidth = 1; break; // FF case 13: - graphemes.append(QChar(0x240D)); + outGrapheme = QChar(0x240D); charWidth = 1; break; // CR - shouldn't appear but does seem to crop up somehow! case 14: - graphemes.append(QChar(0x240E)); + outGrapheme = QChar(0x240E); charWidth = 1; break; // SO case 15: - graphemes.append(QChar(0x240F)); + outGrapheme = QChar(0x240F); charWidth = 1; break; // SI case 16: - graphemes.append(QChar(0x2410)); + outGrapheme = QChar(0x2410); charWidth = 1; break; // DLE case 17: - graphemes.append(QChar(0x2411)); + outGrapheme = QChar(0x2411); charWidth = 1; break; // DC1 case 18: - graphemes.append(QChar(0x2412)); + outGrapheme = QChar(0x2412); charWidth = 1; break; // DC2 case 19: - graphemes.append(QChar(0x2413)); + outGrapheme = QChar(0x2413); charWidth = 1; break; // DC3 case 20: - graphemes.append(QChar(0x2414)); + outGrapheme = QChar(0x2414); charWidth = 1; break; // DC4 case 21: - graphemes.append(QChar(0x2415)); + outGrapheme = QChar(0x2415); charWidth = 1; break; // NAK case 22: - graphemes.append(QChar(0x2416)); + outGrapheme = QChar(0x2416); charWidth = 1; break; // SYN case 23: - graphemes.append(QChar(0x2417)); + outGrapheme = QChar(0x2417); charWidth = 1; break; // ETB case 24: - graphemes.append(QChar(0x2418)); + outGrapheme = QChar(0x2418); charWidth = 1; break; // CAN case 25: - graphemes.append(QChar(0x2419)); + outGrapheme = QChar(0x2419); charWidth = 1; break; // EM case 26: - graphemes.append(QChar(0x241A)); + outGrapheme = QChar(0x241A); charWidth = 1; break; // SUB case 27: - graphemes.append(QChar(0x241B)); + outGrapheme = QChar(0x241B); charWidth = 1; break; // ESC - shouldn't appear as will have been intercepted previously case 28: - graphemes.append(QChar(0x241C)); + outGrapheme = QChar(0x241C); charWidth = 1; break; // FS case 29: - graphemes.append(QChar(0x241D)); + outGrapheme = QChar(0x241D); charWidth = 1; break; // GS case 30: - graphemes.append(QChar(0x241E)); + outGrapheme = QChar(0x241E); charWidth = 1; break; // RS case 31: - graphemes.append(QChar(0x241F)); + outGrapheme = QChar(0x241F); charWidth = 1; break; // US case 127: - graphemes.append(QChar(0x2421)); + outGrapheme = QChar(0x2421); charWidth = 1; break; // DEL default: charWidth = getGraphemeWidth(unicode); - graphemes.append((charWidth < 1) ? QChar() : grapheme); + outGrapheme = (charWidth < 1) ? QString() : grapheme; } } -void TTextEdit::replaceControlCharacterWith_OEMFont(const uint unicode, const QString& grapheme, const int column, QVector<QString>& graphemes, int& charWidth) const +void TTextEdit::replaceControlCharacterWith_OEMFont(const uint unicode, const QString& grapheme, const int column, QString& outGrapheme, int& charWidth) const { Q_UNUSED(column) switch (unicode) { case 0: - graphemes.append(QString(QChar::Space)); + outGrapheme = QString(QChar::Space); charWidth = 1; break; // NUL - not sure that this can appear and the OEM font treats it as a space case 1: - graphemes.append(QChar(0x263A)); + outGrapheme = QChar(0x263A); charWidth = 1; break; // SOH - White Smiling Face case 2: - graphemes.append(QChar(0x263B)); + outGrapheme = QChar(0x263B); charWidth = 1; break; // STX - Black Smiling Face case 3: - graphemes.append(QChar(0x2665)); + outGrapheme = QChar(0x2665); charWidth = 1; break; // ETX - Black Heart Suite case 4: - graphemes.append(QChar(0x2666)); + outGrapheme = QChar(0x2666); charWidth = 1; break; // EOT - Black Diamond Suite case 5: - graphemes.append(QChar(0x2663)); + outGrapheme = QChar(0x2663); charWidth = 1; break; // ENQ - Black ClubsSuite case 6: - graphemes.append(QChar(0x2660)); + outGrapheme = QChar(0x2660); charWidth = 1; break; // ACK - Black Spade Suite case 7: - graphemes.append(QChar(0x2022)); + outGrapheme = QChar(0x2022); charWidth = 1; break; // BEL - Bullet - the handling of this gets done when it is received, not when it is displayed here: case 8: - graphemes.append(QChar(0x25D8)); + outGrapheme = QChar(0x25D8); charWidth = 1; break; // BS - Inverse Bullet case 9: // NOTE THAT WE DO NOT USE TAB SPACING FOR THIS MODE: - graphemes.append(QChar(0x25CB)); + outGrapheme = QChar(0x25CB); charWidth = 1; break; // HT - Circle case 10: - graphemes.append(QChar(0x25D9)); + outGrapheme = QChar(0x25D9); charWidth = 1; break; // LF - Inverse Circle case 11: - graphemes.append(QChar(0x2642)); + outGrapheme = QChar(0x2642); charWidth = 1; break; // VT - Male Sign case 12: - graphemes.append(QChar(0x2640)); + outGrapheme = QChar(0x2640); charWidth = 1; break; // FF - Female Sign case 13: - graphemes.append(QChar(0x266A)); + outGrapheme = QChar(0x266A); charWidth = 1; break; // CR - Single Quaver - shouldn't appear but does seem to crop up somehow! case 14: - graphemes.append(QChar(0x266B)); + outGrapheme = QChar(0x266B); charWidth = 1; break; // SO - Double Quaver case 15: - graphemes.append(QChar(0x263C)); + outGrapheme = QChar(0x263C); charWidth = 1; break; // SI - White Sun with Rays case 16: - graphemes.append(QChar(0x25BA)); + outGrapheme = QChar(0x25BA); charWidth = 1; break; // DLE - Black Right-Pointing Pointer case 17: - graphemes.append(QChar(0x25C4)); + outGrapheme = QChar(0x25C4); charWidth = 1; break; // DC1 - Black Left-Pointing Pointer case 18: - graphemes.append(QChar(0x2195)); + outGrapheme = QChar(0x2195); charWidth = 1; break; // DC2 - Up Down ArroW case 19: - graphemes.append(QChar(0x203C)); + outGrapheme = QChar(0x203C); charWidth = 1; break; // DC3 - Double Exclaimation Mark case 20: - graphemes.append(QChar(0x00B6)); + outGrapheme = QChar(0x00B6); charWidth = 1; break; // DC4 - Pilcrow case 21: - graphemes.append(QChar(0x00A7)); + outGrapheme = QChar(0x00A7); charWidth = 1; break; // NAK - Section Sign case 22: - graphemes.append(QChar(0x25AC)); + outGrapheme = QChar(0x25AC); charWidth = 1; break; // SYN - Black Rectangle case 23: - graphemes.append(QChar(0x21A8)); + outGrapheme = QChar(0x21A8); charWidth = 1; break; // ETB - Up Down Arrow With Base case 24: - graphemes.append(QChar(0x2191)); + outGrapheme = QChar(0x2191); charWidth = 1; break; // CAN - Up Arrow case 25: - graphemes.append(QChar(0x2193)); + outGrapheme = QChar(0x2193); charWidth = 1; break; // EM - Down Arrow case 26: - graphemes.append(QChar(0x2192)); + outGrapheme = QChar(0x2192); charWidth = 1; break; // SUB - Right Arrow case 27: - graphemes.append(QChar(0x2190)); + outGrapheme = QChar(0x2190); charWidth = 1; break; // ESC - Left Arrow - shouldn't appear as will have been intercepted previously case 28: - graphemes.append(QChar(0x221F)); + outGrapheme = QChar(0x221F); charWidth = 1; break; // FS - Right Angle case 29: - graphemes.append(QChar(0x2194)); + outGrapheme = QChar(0x2194); charWidth = 1; break; // GS - Left Right Arrow case 30: - graphemes.append(QChar(0x25B2)); + outGrapheme = QChar(0x25B2); charWidth = 1; break; // RS - Black Up-Pointing Pointer case 31: - graphemes.append(QChar(0x25BC)); + outGrapheme = QChar(0x25BC); charWidth = 1; break; // US - Black Down-Pointing Pointer case 127: - graphemes.append(QChar(0x2302)); + outGrapheme = QChar(0x2302); charWidth = 1; break; // DEL - House default: charWidth = getGraphemeWidth(unicode); - graphemes.append((charWidth < 1) ? QChar() : grapheme); + outGrapheme = (charWidth < 1) ? QString() : grapheme; } } -int TTextEdit::drawGraphemeBackground(QPainter& painter, - QVector<QColor>& fgColors, - QVector<QRect>& textRects, - QVector<QString>& graphemes, - QVector<int>& charWidths, - QPoint& cursor, - const QString& grapheme, - const int column, - const int line, - TChar& charStyle) const +int TTextEdit::layoutGrapheme(LineLayout& layout, const QPoint& cursor, const QString& grapheme, const int column, const int line, const TChar& charStyle) const { - uint unicode = graphemeInfo::getBaseCharacter(grapheme); + const uint unicode = graphemeInfo::getBaseCharacter(grapheme); int charWidth = 0; + GraphemeRun run; + run.style = &charStyle; switch (mpConsole->mControlCharacter) { default: // No special handling, except for these: if (Q_UNLIKELY(unicode == '\t')) { charWidth = mTabStopwidth - (column % mTabStopwidth); - graphemes.append(QString(QChar::Tabulation)); + run.grapheme = QString(QChar::Tabulation); } else { charWidth = graphemeInfo::getWidth(unicode, mWideAmbigousWidthGlyphs); - graphemes.append((charWidth < 1) ? QChar() : grapheme); + run.grapheme = (charWidth < 1) ? QString() : grapheme; } break; case ControlCharacterMode::Picture: - replaceControlCharacterWith_Picture(unicode, grapheme, column, graphemes, charWidth); + replaceControlCharacterWith_Picture(unicode, grapheme, column, run.grapheme, charWidth); break; case ControlCharacterMode::OEM: - replaceControlCharacterWith_OEMFont(unicode, grapheme, column, graphemes, charWidth); + replaceControlCharacterWith_OEMFont(unicode, grapheme, column, run.grapheme, charWidth); break; } // End of switch - charWidths.append(charWidth); - QRect textRect; if (charWidth > 0) { - textRect = QRect(mFontWidth * cursor.x(), mFontHeight * cursor.y(), mFontWidth * charWidth, mFontHeight); + run.textRect = QRect(mFontWidth * cursor.x(), mFontHeight * cursor.y(), mFontWidth * charWidth, mFontHeight); } - textRects.append(textRect); - QColor bgColor; - bool caretIsHere = mpHost && mpHost->caretEnabled() && mCaretLine == line && mCaretColumn == column; + const bool caretIsHere = mpHost && mpHost->caretEnabled() && mCaretLine == line && mCaretColumn == column; + const bool swapColors = charStyle.isReversed() != (charStyle.isSelected() != caretIsHere); if (Q_UNLIKELY(charStyle.isFound())) { - if (Q_UNLIKELY(charStyle.isReversed() != (charStyle.isSelected() != caretIsHere))) { - fgColors.append(mSearchHighlightBgColor); - bgColor = mSearchHighlightFgColor; + if (Q_UNLIKELY(swapColors)) { + run.fgColor = mSearchHighlightBgColor; + run.bgColor = mSearchHighlightFgColor; } else { - fgColors.append(mSearchHighlightFgColor); - bgColor = mSearchHighlightBgColor; + run.fgColor = mSearchHighlightFgColor; + run.bgColor = mSearchHighlightBgColor; + } + } else if (Q_UNLIKELY(swapColors)) { + // When colors would be swapped (e.g., during selection) + // and foreground equals background (hidden text), + // only reverse one color to make the text readable + if (charStyle.foreground() == charStyle.background()) { + run.fgColor = charStyle.foreground(); + // Invert background: use white for dark colors, black for light colors + run.bgColor = (charStyle.background().lightness() < 128) ? Qt::white : Qt::black; + } else { + run.fgColor = charStyle.background(); + run.bgColor = charStyle.foreground(); } } else { - if (Q_UNLIKELY(charStyle.isReversed() != (charStyle.isSelected() != caretIsHere))) { - // When colors would be swapped (e.g., during selection) - // and foreground equals background (hidden text), - // only reverse one color to make the text readable - if (charStyle.foreground() == charStyle.background()) { - fgColors.append(charStyle.foreground()); - // Invert background: use white for dark colors, black for light colors - bgColor = (charStyle.background().lightness() < 128) ? Qt::white : Qt::black; - } else { - fgColors.append(charStyle.background()); - bgColor = charStyle.foreground(); - } - } else { - fgColors.append(charStyle.foreground()); - bgColor = charStyle.background(); - } + run.fgColor = charStyle.foreground(); + run.bgColor = charStyle.background(); } if (caretIsHere) { - bgColor = mCaretColor; - } - // Fill the cell background when: - // - the text bg differs from the console bg (e.g. coloured text), or - // - the main console has a background image to paint the text bg over (#8885), or - // - the main console bg is partially transparent and would otherwise let - // the underlying surface bleed through. - // Skipping the fill when the bg matches an opaque console bg lets glyph - // descenders that extend slightly past mFontHeight (e.g. underscores at - // certain font sizes) survive the next line's drawing (#9070). - const bool fillNeeded = bgColor != mpConsole->getConsoleBgColor() - || (mpConsole->getType() == TConsole::MainConsole - && (mpConsole->mBgImageMode > 0 || bgColor.alpha() < 255)); - if (!textRect.isNull() && fillNeeded) { - painter.fillRect(textRect, bgColor); + run.bgColor = mCaretColor; } + // Main console cells are always filled: over a background image or a + // translucent console background the cell has to be opaque (#8885), and + // keeping it unconditional leaves the paint order in drawForeground() as the + // only thing protecting ink that overflows its cell (#9070, #9719). Other + // console types skip cells matching the console background so that the + // widget underneath shows through. + run.fillsBackground = !run.textRect.isNull() && (mpConsole->getType() == TConsole::MainConsole || run.bgColor != mpConsole->getConsoleBgColor()); + layout.push_back(std::move(run)); return charWidth; } -void TTextEdit::drawGraphemeForeground(QPainter& painter, const QColor& fgColor, const QRect& textRect, const QString& grapheme, TChar& charStyle) const +void TTextEdit::paintGraphemeForeground(QPainter& painter, const GraphemeRun& run) const { - TChar::AttributeFlags attributes = charStyle.allDisplayAttributes(); + const QColor& fgColor = run.fgColor; + const QRect& textRect = run.textRect; + const QString& grapheme = run.grapheme; + const TChar& charStyle = *run.style; + const TChar::AttributeFlags attributes = charStyle.allDisplayAttributes(); const bool isBold = attributes & TChar::Bold; const bool isBlinking = attributes & (TChar::Blink | TChar::FastBlink); @@ -953,9 +962,9 @@ void TTextEdit::drawGraphemeForeground(QPainter& painter, const QColor& fgColor, drawCustomDecorations(painter, effectiveFgColor, textRect, charStyle); } -void TTextEdit::drawCustomDecorations(QPainter& painter, const QColor& defaultColor, const QRect& textRect, TChar& charStyle) const +void TTextEdit::drawCustomDecorations(QPainter& painter, const QColor& defaultColor, const QRect& textRect, const TChar& charStyle) const { - TChar::AttributeFlags attributes = charStyle.allDisplayAttributes(); + const TChar::AttributeFlags attributes = charStyle.allDisplayAttributes(); QFontMetrics fm(painter.font()); // Calculate decoration positions @@ -1163,8 +1172,11 @@ void TTextEdit::drawForeground(QPainter& painter, const QRect& r) bool reusedCachedScreenContent = false; qreal dpr = devicePixelRatioF(); - QPixmap screenPixmap; - QPixmap pixmap = QPixmap(mScreenWidth * mFontWidth * dpr, mScreenHeight * mFontHeight * dpr); + // One spare row below the last character cell, so that ink which overflows + // the bottom cell - descenders and underscores do at many font sizes - has + // somewhere to go instead of being cut off by the edge of the pixmap. + const int pixmapHeight = (mScreenHeight + 1) * mFontHeight; + QPixmap pixmap = QPixmap(mScreenWidth * mFontWidth * dpr, pixmapHeight * dpr); pixmap.setDevicePixelRatio(dpr); pixmap.fill(Qt::transparent); @@ -1175,7 +1187,6 @@ void TTextEdit::drawForeground(QPainter& painter, const QRect& r) int y_top = r.top() / mFontHeight; int y_bottom = r.bottom() / mFontHeight; - int x_right = std::min(r.right(), (mScreenWidth * mFontWidth)) / mFontWidth; int lineOffset = imageTopLine(); int from = 0; @@ -1196,7 +1207,7 @@ void TTextEdit::drawForeground(QPainter& painter, const QRect& r) mScrollVector = 0; noScroll = true; } - if ((r.height() < rect().height()) && (lineOffset > 0) && (mScreenWidth * mFontWidth * dpr <= mScreenMap.width()) && (mScreenHeight * mFontHeight * dpr <= mScreenMap.height())) { + if ((r.height() < rect().height()) && (lineOffset > 0) && (mScreenWidth * mFontWidth * dpr <= mScreenMap.width()) && (pixmapHeight * dpr <= mScreenMap.height())) { p.drawPixmap(0, 0, mScreenMap); reusedCachedScreenContent = true; from = y_top; @@ -1208,38 +1219,95 @@ void TTextEdit::drawForeground(QPainter& painter, const QRect& r) mScrollVector = 0; } } - if ((!noScroll) && (mScrollVector >= 0) && (mScrollVector <= mScreenHeight) && (!mForceUpdate)) { - if (mScrollVector * mFontHeight < mScreenMap.height() && mScreenWidth * mFontWidth <= mScreenMap.width() && (mScreenHeight - mScrollVector) * mFontHeight > 0 - && (mScreenHeight - mScrollVector) * mFontHeight <= mScreenMap.height()) { - screenPixmap = mScreenMap.copy(0, mScrollVector * mFontHeight * dpr, mScreenWidth * mFontWidth * dpr, (mScreenHeight - mScrollVector) * mFontHeight * dpr); - p.drawPixmap(0, 0, screenPixmap); + const int scrolledRows = qAbs(mScrollVector); + if (!noScroll && !mForceUpdate && scrolledRows <= mScreenHeight) { + if (scrolledRows * mFontHeight < mScreenMap.height() && mScreenWidth * mFontWidth <= mScreenMap.width() && (mScreenHeight - scrolledRows) * mFontHeight > 0 + && (mScreenHeight - scrolledRows) * mFontHeight <= mScreenMap.height()) { + p.drawPixmap(0, -mScrollVector * mFontHeight, mScreenMap); reusedCachedScreenContent = true; - from = mScreenHeight - mScrollVector - 1; - } - } else if ((!noScroll) && (mScrollVector < 0 && mScrollVector >= ((-1) * mScreenHeight)) && (!mForceUpdate)) { - if (abs(mScrollVector) * mFontHeight < mScreenMap.height() && mScreenWidth * mFontWidth <= mScreenMap.width() && (mScreenHeight - abs(mScrollVector)) * mFontHeight > 0 - && (mScreenHeight - abs(mScrollVector)) * mFontHeight <= mScreenMap.height()) { - screenPixmap = mScreenMap.copy(0, 0, mScreenWidth * mFontWidth * dpr, (mScreenHeight - abs(mScrollVector)) * mFontHeight * dpr); - p.drawPixmap(0, abs(mScrollVector) * mFontHeight, screenPixmap); - reusedCachedScreenContent = true; - from = 0; - y_bottom = abs(mScrollVector); + if (mScrollVector >= 0) { + from = mScreenHeight - mScrollVector - 1; + } else { + from = 0; + y_bottom = scrolledRows; + } } } + const int lastRow = mScreenHeight - 1; + const int drawFrom = qMax(0, from); + // One row past the dirty region: the last dirty row's ink can spill into the + // row below, which would otherwise be left showing the ink of whatever used + // to be on that last row. + const int drawTo = qMin(y_bottom + 1, lastRow); + const bool bottomRowIsRepainted = drawTo == lastRow; + //delete non used characters. //needed for horizontal scrolling because there sometimes characters didn't get cleared - QRect deleteRect = QRect(0, from * mFontHeight, x_right * mFontWidth, (y_bottom + 1) * mFontHeight); + int clearHeight = (drawTo + 1 - drawFrom) * mFontHeight; + if (bottomRowIsRepainted) { + clearHeight += mFontHeight; + } + const QRect deleteRect(0, drawFrom * mFontHeight, mScreenWidth * mFontWidth, clearHeight); p.setCompositionMode(QPainter::CompositionMode_Source); p.fillRect(deleteRect, Qt::transparent); + // Scrolling shifts the cached screen by whole cells, which drops a complete + // line of text into the spare row. Nothing but the bottom line's overflow + // belongs there, so rebuild it from scratch whenever it is not already part + // of the band above. + QRect spareRowRect; + if (!bottomRowIsRepainted) { + spareRowRect = QRect(0, mScreenHeight * mFontHeight, mScreenWidth * mFontWidth, mFontHeight); + p.fillRect(spareRowRect, Qt::transparent); + } p.setCompositionMode(QPainter::CompositionMode_SourceOver); - for (int i = from; i <= y_bottom; ++i) { - if (static_cast<int>(mpBuffer->buffer.size()) <= i + lineOffset) { + const TChar timeStampStyle = timeStampCharStyle(); + + // The line above the cleared band keeps its cell but loses whatever it had + // spilled into the band, so put its glyphs back clipped to the band. Drawing + // the whole line again would paint it on top of itself and thicken its + // antialiasing. + mOverflowLineLayout.clear(); + if (drawFrom > 0 && hasBufferLine(drawFrom - 1 + lineOffset)) { + layoutLine(drawFrom - 1 + lineOffset, drawFrom - 1, timeStampStyle, mOverflowLineLayout); + } + + // Each line's backgrounds go down before the previous line's glyphs, so that + // no background fill can wipe out ink which overflowed out of its cell. + mPreviousLineLayout.clear(); + bool lineAboveRestored = false; + for (int i = drawFrom; i <= drawTo; ++i) { + if (!hasBufferLine(i + lineOffset)) { break; } - drawLine(p, i + lineOffset, i, &mScreenOffset); + layoutLine(i + lineOffset, i, timeStampStyle, mCurrentLineLayout, &mScreenOffset); + paintBackgrounds(p, mCurrentLineLayout); + if (!lineAboveRestored) { + paintForegrounds(p, mOverflowLineLayout, deleteRect); + lineAboveRestored = true; + } + paintForegrounds(p, mPreviousLineLayout); + mPreviousLineLayout.swap(mCurrentLineLayout); } + if (!lineAboveRestored) { + paintForegrounds(p, mOverflowLineLayout, deleteRect); + } + // Anything below the band is cached content that already holds this line's + // overflow, so clip it away rather than compositing the same ink twice. + const QRect bandRect(0, drawFrom * mFontHeight, mScreenWidth * mFontWidth, (drawTo + 1 - drawFrom) * mFontHeight); + paintForegrounds(p, mPreviousLineLayout, bottomRowIsRepainted ? QRect() : bandRect); + + if (!spareRowRect.isNull() && hasBufferLine(lastRow + lineOffset)) { + layoutLine(lastRow + lineOffset, lastRow, timeStampStyle, mOverflowLineLayout); + paintForegrounds(p, mOverflowLineLayout, spareRowRect); + } + // The layouts borrow TChar pointers from the buffer, so do not keep them + // past the paint they were built for. + mPreviousLineLayout.clear(); + mCurrentLineLayout.clear(); + mOverflowLineLayout.clear(); + calculateHMaxRange(); if (Q_UNLIKELY(mpConsole->mHScrollBarEnabled && mpConsole->mpHScrollBar)) { updateHorizontalScrollBar(); @@ -1582,7 +1650,16 @@ void TTextEdit::updateTextCursor(const QMouseEvent* event, int lineIndex, int tC QStringList tooltip = mpBuffer->mLinkStore.getHints(linkIndex); QStringList commands = mpBuffer->mLinkStore.getLinks(linkIndex); // If a special tooltip hint was given, use that one. - QToolTip::showText(event->globalPosition().toPoint(), tooltip.size() > commands.size() ? tooltip[0] : tooltip.join(QChar::LineFeed)); + // The server chooses this text and QToolTip renders anything + // Qt::mightBeRichText() accepts as HTML, so escape it and wrap it + // in an explicit document rather than letting that guess decide + // whether the markup is live. white-space:pre keeps the line + // breaks the plain-text path used to give. + // An empty string is how QToolTip is told to hide, so it has to + // stay empty rather than becoming an empty document. + const QString tooltipText = tooltip.size() > commands.size() ? tooltip[0] : tooltip.join(QChar::LineFeed); + const QString tooltipMarkup = tooltipText.isEmpty() ? QString() : qsl("<html><body style='white-space:pre'>%1</body></html>").arg(tooltipText.toHtmlEscaped()); + QToolTip::showText(event->globalPosition().toPoint(), tooltipMarkup); // Update hover state for CSS pseudo-class support // Don't set hover state for disabled links - they should stay disabled @@ -2090,20 +2167,24 @@ void TTextEdit::slot_copySelectionToClipboardHTML() // matches slot_copySelectionToClipboard(), which also keeps the selection. } +// The part of establishSelectedText()'s bail-out that is cheap enough to check +// while building the context menu; its remaining checks (font metrics, pane +// size) hold for any console the user can right-click on. +bool TTextEdit::hasSelectedText() const +{ + return !mpBuffer->lineBuffer.isEmpty() && !mSelectedRegion.isEmpty(); +} + bool TTextEdit::establishSelectedText() { - if (mpBuffer->lineBuffer.isEmpty()) { - // Prevent problems with trying to do a copy when TBuffer is empty: + if (!hasSelectedText()) { return false; } // if selection was made backwards swap // right to left if (mFontWidth <= 0 || mFontHeight <= 0) { - return false; - } - - if (mSelectedRegion == QRegion(0, 0, 0, 0)) { + qWarning().nospace() << "TTextEdit::establishSelectedText() ERROR - font is " << mFontWidth << "x" << mFontHeight << " so the selection cannot be worked out"; return false; } @@ -2111,6 +2192,7 @@ bool TTextEdit::establishSelectedText() mScreenHeight = height() / mFontHeight; mScreenWidth = 100; if (mScreenHeight <= 0) { + qWarning().nospace() << "TTextEdit::establishSelectedText() ERROR - pane is only " << height() << "px high, too short for a line of text"; return false; } if (mpConsole->getType() == TConsole::MainConsole && !mIsLowerPane) { @@ -2125,6 +2207,18 @@ bool TTextEdit::establishSelectedText() return true; } +// [first, last] line numbers, not clamped to the buffer - the caller must do that. +std::pair<int, int> TTextEdit::visibleLines() +{ + if (mScreenHeight <= 0) { + // imageTopLine() works the top line out from mScreenHeight, so repair it + // first or the two disagree by a whole screen + mScreenHeight = std::max(1, height() / mFontHeight); + } + const int firstLine = std::max(0, imageTopLine()); + return {firstLine, firstLine + mScreenHeight - 1}; +} + // Technically this copies whole lines into the image even if the selection does // not start at the beginning of the first line or end at the last grapheme on // the last line. @@ -2132,17 +2226,55 @@ void TTextEdit::slot_copySelectionToClipboardImage() { mCopyImageStartTime = std::chrono::high_resolution_clock::now(); - if (!establishSelectedText()) { + if (mFontWidth <= 0 || mFontHeight <= 0) { + qWarning().nospace() << "TTextEdit::slot_copySelectionToClipboardImage() ERROR - font is " << mFontWidth << "x" << mFontHeight << ", nothing was copied to the clipboard"; return; } + // drawLine() reads both halves of the buffer, so neither may be indexed past + // its end: + const int lastBufferLine = std::min(mpBuffer->lineBuffer.size(), static_cast<qsizetype>(mpBuffer->buffer.size())) - 1; + if (lastBufferLine < 0) { + qWarning() << "TTextEdit::slot_copySelectionToClipboardImage() ERROR - there is nothing in this console to copy"; + return; + } + + // Unlike Copy and Copy HTML, "as image" has an obvious default when nothing + // is selected: a picture of what the user is looking at (#9715). + bool copyingSelection = establishSelectedText(); + if (copyingSelection && mPB.y() > lastBufferLine) { + // Lines lost off the front of a buffer that reached its limit shift every + // remaining index down; getSelectedText() compensates the same way. mPA + // and mPB move rather than a copy of them, so that the deselect below + // still finds the characters that are about to be drawn. + const int shift = mpBuffer->mBatchDeleteSize; + if (mPA.y() - shift >= 0 && mPB.y() - shift <= lastBufferLine) { + mPA.ry() -= shift; + mPB.ry() -= shift; + } else { + // The selected lines are gone for good, so copy the visible area + // rather than whatever has since taken their place in the buffer. + copyingSelection = false; + } + } + + int firstLine = mPA.y(); + int lastLine = mPB.y(); + if (!copyingSelection) { + const auto [firstVisible, lastVisible] = visibleLines(); + firstLine = firstVisible; + lastLine = lastVisible; + } + firstLine = std::clamp(firstLine, 0, lastBufferLine); + lastLine = std::clamp(lastLine, firstLine, lastBufferLine); + // Qt says: "Maximum supported image dimension is 65500 pixels" in stdout - auto heightpx = std::min(65500, (mPB.y() - mPA.y() + 1) * mFontHeight); - auto lineOffset = mPA.y(); + auto heightpx = std::min(65500, (lastLine - firstLine + 1) * mFontHeight); + auto lineOffset = firstLine; // find the biggest width of text we need to work with int largestLine{}; - for (int y = mPA.y(), total = mPB.y() + 1; y < total; ++y) { + for (int y = firstLine, total = lastLine + 1; y < total; ++y) { const QString lineText{mpBuffer->lineBuffer.at(y)}; // Will accumulate the width in pixels of the current line: auto lineWidth{(mpConsole->showTimeStamps() ? mudlet::smTimeStampFormat.size() : 0) * mFontWidth}; @@ -2173,34 +2305,66 @@ void TTextEdit::slot_copySelectionToClipboardImage() largestLine = std::max(static_cast<int>(lineWidth), largestLine); } - auto widthpx = std::min(65500, largestLine); - auto rect = QRect(mPA.x(), mPA.y(), widthpx, heightpx); - auto pixmap = QPixmap(widthpx, heightpx); + // A zero width pixmap is null, so the painter below never activates and + // nothing at all reaches the clipboard. Floor the width at one character so a + // run of only blank lines (which is what makes largestLine zero) still copies: + auto widthpx = std::max(mFontWidth, std::min(65500, largestLine)); + auto rect = QRect(0, 0, widthpx, heightpx); + // The bottom line's ink can reach past its cell, so paint into a spare row + // and keep only as much of it as the glyphs actually used. + auto pixmap = QPixmap(widthpx, std::min(65500, heightpx + mFontHeight)); auto solidColor = QColor(mBgColor); solidColor.setAlpha(255); pixmap.fill(solidColor); QPainter painter(&pixmap); if (!painter.isActive()) { + qWarning().nospace() << "TTextEdit::slot_copySelectionToClipboardImage() ERROR - cannot paint a " << widthpx << "x" << heightpx << " image, nothing was copied to the clipboard"; return; } - // deselect to prevent inverted colours in image - unHighlight(); - mSelectedRegion = QRegion(0, 0, 0, 0); + if (copyingSelection) { + // deselect to prevent inverted colours in image + unHighlight(); + mSelectedRegion = QRegion(0, 0, 0, 0); + } auto result = drawTextForClipboard(painter, rect, lineOffset); - highlightSelection(); - - // if we cut didn't finish painting the complete picture, trim the bottom of the image - if (!result.first) { - const auto& smallerPixmap = pixmap.scaled(QSize(widthpx, result.second * mFontHeight), Qt::KeepAspectRatio); - QApplication::clipboard()->setImage(smallerPixmap.toImage()); - return; + if (copyingSelection) { + highlightSelection(); } + // the pixmap cannot be read back while a painter is still active on it + painter.end(); - QApplication::clipboard()->setImage(pixmap.toImage()); + const QImage image = pixmap.toImage(); + int keepHeight = heightpx + overflowRowsUsed(image, heightpx, solidColor); + if (!result.first) { + // Crop rather than scale an abandoned copy: scaling to fit would squash + // the lines that did get drawn instead of dropping the rest. + keepHeight = result.second * mFontHeight; + if (keepHeight <= 0) { + qWarning() << "TTextEdit::slot_copySelectionToClipboardImage() ERROR - ran out of time before drawing a single line, nothing was copied to the clipboard"; + return; + } + } + QApplication::clipboard()->setImage(image.copy(0, 0, widthpx, std::min(image.height(), keepHeight))); +} + +// How many rows below fromRow the glyph ink actually reached into. +int TTextEdit::overflowRowsUsed(const QImage& image, const int fromRow, const QColor& background) +{ + const QRgb backgroundPixel = background.rgb(); + int used = 0; + for (int y = std::max(0, fromRow); y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if ((image.pixel(x, y) | 0xff000000) != backgroundPixel) { + used = y - fromRow + 1; + break; + } + } + } + return used; } // a stateless version of drawForeground that doesn't do any caching @@ -2213,17 +2377,29 @@ std::pair<bool, int> TTextEdit::drawTextForClipboard(QPainter& painter, QRect re int lineCount = rectangle.height() / mFontHeight; int linesDrawn = 0; auto timeout = mudlet::self()->mCopyAsImageTimeout; - for (int i = 0; i <= lineCount; i++, linesDrawn++) { - if (static_cast<int>(mpBuffer->buffer.size()) <= i + lineOffset) { + const TChar timeStampStyle = timeStampCharStyle(); + LineLayout previousLine; + LineLayout currentLine; + for (int i = 0; i < lineCount; ++i) { + if (!hasBufferLine(i + lineOffset)) { break; } - drawLine(painter, i + lineOffset, i); + // A line's backgrounds have to go down before the previous line's glyphs + layoutLine(i + lineOffset, i, timeStampStyle, currentLine); + paintBackgrounds(painter, currentLine); + paintForegrounds(painter, previousLine); + previousLine.swap(currentLine); + // counted here rather than in the loop's increment, so that the timeout + // below reports the line it just drew instead of the one before it + ++linesDrawn; if (std::chrono::duration_cast<std::chrono::seconds>(std::chrono::high_resolution_clock::now() - mCopyImageStartTime).count() >= timeout) { - qDebug().nospace() << "timeout for image copy (" << timeout << "s) reached, managed to draw " << i << " lines"; + qDebug().nospace() << "timeout for image copy (" << timeout << "s) reached, managed to draw " << linesDrawn << " lines"; + paintForegrounds(painter, previousLine); return {false, linesDrawn}; } } + paintForegrounds(painter, previousLine); return {true, linesDrawn}; } @@ -2283,12 +2459,9 @@ QString TTextEdit::getSelectedText(const QChar& newlineChar, const bool showTime textLines[0] = textLines.at(0).mid(startPos, endPos - startPos + 1); } } else { - // replace a number of QChars at the front with a corresponding - // number of spaces to push the first line to the right so it lines up - // with the following lines: + // trim characters off the front of the first line according to startPos: if (!textLines.at(0).isEmpty()) { textLines[0] = textLines.at(0).mid(startPos); - textLines[0] = QString(QChar::Space).repeated(startPos) % textLines.at(0); } // and chop off the required number of QChars from the end of the last // line: @@ -2371,6 +2544,9 @@ void TTextEdit::mouseReleaseEvent(QMouseEvent* event) if (!hyperlinkStyling.menuTitle.isEmpty()) { auto titleLabel = new QLabel(hyperlinkStyling.menuTitle, popup); + // The server picks this text and QLabel defaults to Qt::AutoText, + // which would render markup in it. + titleLabel->setTextFormat(Qt::PlainText); titleLabel->setFont(font()); // Build stylesheet from title style properties @@ -2445,6 +2621,10 @@ void TTextEdit::mouseReleaseEvent(QMouseEvent* event) popup->setAttribute(Qt::WA_DeleteOnClose); popup->setToolTipsVisible(true); // Not the default... + //: Tooltip shown on the console context menu's copy and search entries while they are disabled because nothing is selected + const QString noSelectionHint = utils::richText(tr("Select some text in the console first.")); + const bool selectionAvailable = hasSelectedText(); + QAction* action = new QAction(tr("Copy"), popup); // According to the Qt Documentation: // "This text is used for the tooltip." @@ -2461,6 +2641,7 @@ void TTextEdit::mouseReleaseEvent(QMouseEvent* event) connect(action2, &QAction::triggered, this, &TTextEdit::slot_copySelectionToClipboardHTML); auto* actionCopyImage = new QAction(tr("Copy as image"), popup); + actionCopyImage->setToolTip(QString()); connect(actionCopyImage, &QAction::triggered, this, &TTextEdit::slot_copySelectionToClipboardImage); QAction* action3 = new QAction(tr("Select all"), popup); @@ -2471,6 +2652,28 @@ void TTextEdit::mouseReleaseEvent(QMouseEvent* event) QAction* action4 = new QAction(tr("Search on %1").arg(selectedEngine), popup); action4->setToolTip(QString()); connect(action4, &QAction::triggered, this, &TTextEdit::slot_searchSelectionOnline); + + // These have no sensible whole-console fallback, so they are disabled with + // a reason rather than left as entries that quietly do nothing. "Copy as + // image" is not among them: it falls back to the visible area (#9715). + // The object names let tests find each entry without matching translated text: + const QVector<std::pair<QAction*, QString>> selectionActions{{action, qsl("consoleCopy")}, {action2, qsl("consoleCopyHtml")}, {action4, qsl("consoleSearchOnline")}}; + for (const auto& [selectionAction, objectName] : selectionActions) { + selectionAction->setObjectName(objectName); + selectionAction->setEnabled(selectionAvailable); + if (!selectionAvailable) { + selectionAction->setToolTip(noSelectionHint); + } + } + action3->setObjectName(qsl("consoleSelectAll")); + + actionCopyImage->setObjectName(qsl("consoleCopyAsImage")); + if (mpBuffer->lineBuffer.isEmpty()) { + actionCopyImage->setEnabled(false); + //: Tooltip shown on the console context menu's "Copy as image" entry while it is disabled because the console holds no text at all + actionCopyImage->setToolTip(utils::richText(tr("This console is empty, there is nothing to copy."))); + } + if (!qApp->testAttribute(Qt::AA_DontShowIconsInMenus)) { action->setIcon(QIcon::fromTheme(qsl("edit-copy"), QIcon(qsl(":/icons/edit-copy.png")))); action3->setIcon(QIcon::fromTheme(qsl("edit-select-all"), QIcon(qsl(":/icons/edit-select-all.png")))); @@ -3016,10 +3219,7 @@ void TTextEdit::slot_analyseSelection() quint8 columnsToUse = qMax(size_t{2}, utf8Width); if (includeThisCodePoint) { - utf16indexes.append(qsl("<th colspan=\"%1\"><center>%2 & %3</center></th>") - .arg(QString::number(columnsToUse), - QString::number(index + 1), - QString::number(index + 2))); + utf16indexes.append(qsl("<th colspan=\"%1\"><center>%2 & %3</center></th>").arg(QString::number(columnsToUse), QString::number(index + 1), QString::number(index + 2))); // The use of one qsl inside another is because it is // impossible to force an upper-case alphabet to Hex digits otherwise @@ -3027,19 +3227,18 @@ void TTextEdit::slot_analyseSelection() // 
 is the Unicode Line Separator. // The static casts are only needed since Qt 6.9.0 but they // shouldn't do any harm prior to that: - utf16Vals.append(qsl("<td colspan=\"%1\" style=\"white-space:no-wrap vertical-align:top\"><center>%2</center>
<center>(0x%3:0x%4)</center></td>") - .arg(QString::number(columnsToUse), - qsl("%1").arg(static_cast<uint32_t>(QChar::surrogateToUcs4(mpBuffer->lineBuffer.at(line).at(index), - mpBuffer->lineBuffer.at(line).at(index + 1))), - 4, 16, zero).toUpper()) - .arg(static_cast<uint16_t>(mpBuffer->lineBuffer.at(line).at(index).unicode()), 4, 16, zero) - .arg(static_cast<uint16_t>(mpBuffer->lineBuffer.at(line).at(index + 1).unicode()), 4, 16, zero)); + utf16Vals.append( + qsl("<td colspan=\"%1\" style=\"white-space:no-wrap vertical-align:top\"><center>%2</center>
<center>(0x%3:0x%4)</center></td>") + .arg(QString::number(columnsToUse), + qsl("%1") + .arg(static_cast<uint32_t>(QChar::surrogateToUcs4(mpBuffer->lineBuffer.at(line).at(index), mpBuffer->lineBuffer.at(line).at(index + 1))), 4, 16, zero) + .toUpper()) + .arg(static_cast<uint16_t>(mpBuffer->lineBuffer.at(line).at(index).unicode()), 4, 16, zero) + .arg(static_cast<uint16_t>(mpBuffer->lineBuffer.at(line).at(index + 1).unicode()), 4, 16, zero)); // Note the addition to the index here to jump over the low-surrogate: graphemes.append(qsl("<td colspan=\"%1\">%2</td>") - .arg(QString::number(columnsToUse), - convertWhitespaceToVisual(mpBuffer->lineBuffer.at(line).at(index), - mpBuffer->lineBuffer.at(line).at(index + 1)))); + .arg(QString::number(columnsToUse), convertWhitespaceToVisual(mpBuffer->lineBuffer.at(line).at(index), mpBuffer->lineBuffer.at(line).at(index + 1)))); } switch (utf8Width) { @@ -3549,7 +3748,18 @@ void TTextEdit::applyHyperlinkSelectionGroupState(int linkIndex, QString& uri, c } const bool newSelected = mgr->isSelected(group, value); - uri = mgr->modifyUriForSelection(uri, group, value); + + // A menu link's commands are built from its menu items and the base URI's + // command is discarded, so baseCommand matches none of what can actually be + // run here. Callers pass the item the user picked; rebuilding from the base + // would send something they did not choose. + if (mpBuffer->mLinkStore.getLinksConst(linkIndex).size() <= 1) { + const Mudlet::HyperlinkStyling styling = mpBuffer->mLinkStore.getStyling(linkIndex); + const QString rebuiltUri = mgr->modifyUriForSelection(styling.actionScheme, styling.baseCommand, group, value); + if (!rebuiltUri.isEmpty()) { + uri = rebuiltUri; + } + } mpBuffer->setLinkSelected(linkIndex, newSelected); mpBuffer->setLinkState(linkIndex, newSelected ? Mudlet::HyperlinkStyling::StateSelected : Mudlet::HyperlinkStyling::StateDefault); @@ -3639,6 +3849,9 @@ void TTextEdit::showLinkContextMenu() if (!hyperlinkStyling.menuTitle.isEmpty()) { auto titleLabel = new QLabel(hyperlinkStyling.menuTitle, popup); + // The server picks this text and QLabel defaults to Qt::AutoText, + // which would render markup in it. + titleLabel->setTextFormat(Qt::PlainText); titleLabel->setFont(font()); QStringList styleProps; diff --git a/src/TTextEdit.h b/src/TTextEdit.h index fbc82cb0e..fa6a02985 100644 --- a/src/TTextEdit.h +++ b/src/TTextEdit.h @@ -28,14 +28,18 @@ ***************************************************************************/ +#include <QColor> #include <QElapsedTimer> #include <QMap> #include <QPointer> +#include <QImage> +#include <QRect> #include <QTimer> #include <QWidget> #include <chrono> #include <string> +#include <vector> #include "THyperlinkStyling.h" @@ -62,10 +66,6 @@ public: void paintEvent(QPaintEvent*) override; void contextMenuEvent(QContextMenuEvent* event) override; void drawForeground(QPainter&, const QRect&); - void drawLine(QPainter& painter, int lineNumber, int rowOfScreen, int* offset = nullptr) const; - int drawGraphemeBackground(QPainter&, QVector<QColor>&, QVector<QRect>&, QVector<QString>&, QVector<int>&, QPoint&, const QString&, const int, const int, TChar&) const; - void drawGraphemeForeground(QPainter&, const QColor&, const QRect&, const QString&, TChar&) const; - void drawCustomDecorations(QPainter&, const QColor&, const QRect&, TChar&) const; void showNewLines(); void forceUpdate(); void needUpdate(int, int); @@ -125,6 +125,7 @@ public: // long enough again. int mOldCaretColumn = 0; + friend class CopyAsImageTest; friend class TTextEditBlinkTest; static bool shouldRegisterBlinkClient(bool enableBlinkText, bool hasBlinkingContentInRedrawnRegion, bool isBlinkClientRegistered, bool reusedCachedScreenContent); @@ -189,12 +190,50 @@ private: void normaliseSelection(); void updateTextCursor(const QMouseEvent* event, int lineIndex, int tCharIndex, bool isOutOfbounds); bool establishSelectedText(); + bool hasSelectedText() const; + std::pair<int, int> visibleLines(); void expandSelectionToWords(); void expandSelectionToLine(int); - inline void replaceControlCharacterWith_Picture(const uint, const QString&, const int, QVector<QString>&, int&) const; - inline void replaceControlCharacterWith_OEMFont(const uint, const QString&, const int, QVector<QString>&, int&) const; + inline void replaceControlCharacterWith_Picture(const uint, const QString&, const int, QString&, int&) const; + inline void replaceControlCharacterWith_OEMFont(const uint, const QString&, const int, QString&, int&) const; int offsetForPosition(int line, int column) const; + bool hasBufferLine(int lineNumber) const; + static int overflowRowsUsed(const QImage& image, const int fromRow, const QColor& background); + TChar timeStampCharStyle() const; + // One grapheme's painted cell (or cells, for a wide glyph): where it goes, + // the colours resolved for it, and the style they were resolved from. + struct GraphemeRun + { + QRect textRect; + QColor fgColor; + QColor bgColor; + QString grapheme; + // Borrowed from TBuffer::buffer, or from the caller's timestamp style. + // Only valid for the duration of one paint, during which the buffer must + // not be modified. A null pointer marks a background-only run, such as + // the caret block on an empty line. + const TChar* style = nullptr; + bool fillsBackground = false; + }; + using LineLayout = std::vector<GraphemeRun>; + + // Laying a line out without painting it lets the callers put line N's + // backgrounds down before line N-1's glyphs, so that ink overflowing out of + // the bottom of a cell cannot be erased by the line below it. Both callers + // depend on that order, which is why none of this is reachable from outside. + void layoutLine(int lineNumber, int lineOfScreen, const TChar& timeStampStyle, LineLayout& layout, int* offset = nullptr) const; + void paintBackgrounds(QPainter&, const LineLayout&) const; + void paintForegrounds(QPainter&, const LineLayout&, const QRect& clip = QRect()) const; + void drawCustomDecorations(QPainter&, const QColor&, const QRect&, const TChar&) const; + int layoutGrapheme(LineLayout& layout, const QPoint& cursor, const QString& grapheme, const int column, const int line, const TChar& charStyle) const; + void paintGraphemeForeground(QPainter&, const GraphemeRun&) const; + + // Reused between paints to keep their capacity rather than reallocating a + // line's worth of graphemes on every repaint. + mutable LineLayout mPreviousLineLayout; + mutable LineLayout mCurrentLineLayout; + mutable LineLayout mOverflowLineLayout; int mFontHeight; int mFontWidth; bool mForceUpdate = false; diff --git a/src/TTimer.cpp b/src/TTimer.cpp index 2e5fe3385..cf8d9b4ad 100644 --- a/src/TTimer.cpp +++ b/src/TTimer.cpp @@ -28,6 +28,8 @@ #include "TDebug.h" #include "mudlet.h" +#include <QScopeGuard> + const char* TTimer::scmProperty_HostName = "HostName"; const char* TTimer::scmProperty_TTimerId = "TTimerId"; @@ -163,6 +165,18 @@ void TTimer::compileAll() bool TTimer::setScript(const QString& script) { + // Switching from a registered anonymous Lua function (set up by tempTimer with a + // function argument) to a script string: release the old function from the Lua + // registry and leave callback mode. Unlike triggers/aliases/keys, TTimer::execute() + // keys off mScript rather than the flag, so the new script does run - but without + // this the registry entry still leaks, as the destructor would then take its + // mScript-based branch and delete the compiled function instead. + if (mRegisteredAnonymousLuaFunction) { + if (mpHost) { + mpHost->mLuaInterpreter.delete_luafunction(this); + } + mRegisteredAnonymousLuaFunction = false; + } mScript = script; if (script == "") { mNeedsToBeCompiled = false; @@ -203,6 +217,22 @@ void TTimer::execute() return; } + // Whilst this frame is on the stack TimerUnit::uninstall() must defer deleting + // this profile's timers: the scripts run below can uninstall their own package + // (a common package auto-updater pattern) and freeing this timer mid-execute() + // is a use-after-free - see TimerUnit::mProcessingDepth: + TimerUnit* pUnit = mpHost->getTimerUnit(); + pUnit->beginProcessing(); + // NB: deliberately only decrements the depth - do NOT add a doCleanup() call + // here: it would delete `this` (and other deferred timers) while + // mudlet::slot_timerFires() still holds the pointer. Deferred deletes are + // flushed by slot_timerFires() itself once it is finished with the timer + // (and by the doCleanup() calls in Host::incomingStreamProcessor() and + // Host::slot_purgeTemps()): + const auto processingGuard = qScopeGuard([pUnit] { + pUnit->endProcessing(); + }); + if (!isActive() || isFolder()) { mpQTimer->stop(); return; diff --git a/src/TToolBar.cpp b/src/TToolBar.cpp index b532a746b..046ed78fe 100644 --- a/src/TToolBar.cpp +++ b/src/TToolBar.cpp @@ -29,6 +29,8 @@ #include "TFlipButton.h" #include "mudlet.h" +#include <QScopeGuard> + TToolBar::TToolBar(Host* pHost, TAction* pA, const QString& name, QWidget* pW) : QDockWidget(pW) @@ -113,11 +115,11 @@ void TToolBar::addButton(TFlipButton* pB) pB->setMaximumSize(size); pB->setMinimumSize(size); } else { - const QSize size = QSize(pB->mpTAction->mSizeX, pB->mpTAction->mSizeY); + const QSize size = pB->mpTAction->getSize(); pB->setMaximumSize(size); pB->setMinimumSize(size); pB->setParent(mpWidget); - pB->setGeometry(pB->mpTAction->mPosX, pB->mpTAction->mPosY, pB->mpTAction->mSizeX, pB->mpTAction->mSizeY); + pB->setGeometry(pB->mpTAction->mPosX, pB->mpTAction->mPosY, pB->mpTAction->getSizeX(), pB->mpTAction->getSizeY()); } pB->setStyleSheet(pB->mpTAction->css); @@ -139,12 +141,8 @@ void TToolBar::addButton(TFlipButton* pB) if (!mpTAction->mUseCustomLayout) { // tool bar mButtonColumns > 0 -> autolayout // case == 0: use individual button placement for user defined layouts - int columns = mpTAction->getButtonColumns(); - if (columns <= 0) { - columns = 1; - } - mItemCount++; - const int row = mItemCount / columns; + int columns = std::max(1, mpTAction->getButtonColumns()); + const int row = ++mItemCount / columns; const int col = mItemCount % columns; if (mVerticalOrientation) { mpLayout->addWidget(pB, row, col); @@ -162,22 +160,24 @@ void TToolBar::addButton(TFlipButton* pB) void TToolBar::finalize() { - if (mpTAction->mUseCustomLayout) { + if (mpTAction->mUseCustomLayout || !mpTAction->getButtonFillerOffset()) { return; } - auto fillerWidget = new QWidget; - const QSizePolicy sizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - fillerWidget->setSizePolicy(sizePolicy); - int columns = mpTAction->getButtonColumns(); - if (columns <= 0) { - columns = 1; + auto fillerWidget = new QWidget(this); + QPushButton dummy; + fillerWidget->setMinimumSize(dummy.minimumSizeHint()); + fillerWidget->setMaximumSize(dummy.minimumSizeHint()); + if (mpLayout) { + if (mpTAction->mOrientation == 1) { + // The toolbar is to be filled with rows of mpTAction->getButtonColumns() wide + // The filler widget is to be one or more columns wide + mpLayout->addWidget(fillerWidget, 0, 0, mpTAction->getButtonFillerOffset(), 1); + } else { + // The toolbar is to be filled with columns of mpTAction->getButtonColumns() tall + // The filler widget is to be one or more rows tall + mpLayout->addWidget(fillerWidget, 0, 0, 1, mpTAction->getButtonFillerOffset()); + } } - const int row = (++mItemCount) / columns; - const int column = (mItemCount - 1) % columns; - mpLayout->addWidget(fillerWidget, row, column); - // 3 lines above are to avoid order of operations problem of original line - // (-Wsequence-point warning on mItemCount) NEEDS TO BE CHECKED: - // mpLayout->addWidget( fillerWidget, ++mItemCount/columns, mItemCount%columns ); } // Used by buttons directly on a TToolBar instance but NOT on sub-menu item - we @@ -190,6 +190,19 @@ void TToolBar::slot_pressed(const bool isChecked) } TAction* pA = pB->mpTAction; + + // Hold off ActionUnit deletes for this whole slot so a self-uninstall (the + // button's own script removing its package) cannot free pA out from under the + // dereferences below, even if a Host catch-all doCleanup() fires at depth 0 + // mid-slot. The scope guard flushes once at the end, after pA's last use (see + // ActionUnit::uninstall()): + ActionUnit* pActionUnit = mpHost->getActionUnit(); + pActionUnit->beginProcessing(); + const auto processingGuard = qScopeGuard([pActionUnit] { + pActionUnit->endProcessing(); + pActionUnit->doCleanup(); + }); + // NOTE: This function blocks until an item is selected from the menu, and, // as the action to "pop-up" the menu is the same as "buttons" use to // perform their command/scripts is why "commands" are (no longer) permitted @@ -198,7 +211,7 @@ void TToolBar::slot_pressed(const bool isChecked) // entries... pB->menu(); - if (pA->mIsPushDownButton) { + if (pA->isPushDownButton()) { pA->mButtonState = isChecked; mpHost->mpConsole->mButtonState = (pA->mButtonState ? 2 : 1); // Was using 1 and 0 but that was wrong } else { diff --git a/src/TToolBar.h b/src/TToolBar.h index c7d60e23f..ee77dbe52 100644 --- a/src/TToolBar.h +++ b/src/TToolBar.h @@ -39,6 +39,7 @@ public: Q_DISABLE_COPY(TToolBar) TToolBar(Host*, TAction*, const QString&, QWidget* pW = nullptr); void addButton(TFlipButton* pW); + void resetItemCount(const int initialOffset) { mItemCount = initialOffset; } void resizeEvent(QResizeEvent* e) override; void moveEvent(QMoveEvent* e) override; void mousePressEvent(QMouseEvent*) override; diff --git a/src/TTrigger.cpp b/src/TTrigger.cpp index 0c5bea767..3694821b1 100644 --- a/src/TTrigger.cpp +++ b/src/TTrigger.cpp @@ -146,7 +146,7 @@ bool TTrigger::setRegexCodeList(QStringList patterns, QList<int> patternKinds, b TDebug(Qt::white, Qt::red) << "REGEX ERROR: failed to compile, reason:\n" << error << "\n" >> mpHost; TDebug(Qt::red, Qt::gray) << TDebug::csmContinue << R"(in: ")" << regexp.constData() << "\"\n" >> mpHost; } - setError(qsl("<b><font color='blue'>%1</font></b>") + setError(qsl("<b>%1</b>") .arg(tr(R"(Error: in item %1, perl regex "%2" failed to compile, reason: "%3".)") .arg(QString::number(i + 1), QString(regexp.constData()).toHtmlEscaped(), QString(error).toHtmlEscaped()))); state = false; @@ -168,7 +168,7 @@ bool TTrigger::setRegexCodeList(QStringList patterns, QList<int> patternKinds, b const QString code = qsl("function %1() %2\nend").arg(funcName.c_str(), patterns[i]); QString error; if (!mpLua->compile(code, error, QString::fromStdString(funcName))) { - setError(qsl("<b><font color='blue'>%1</font></b>") + setError(qsl("<b>%1</b>") .arg(tr(R"(Error: in item %1, lua function "%2" failed to compile, reason: "%3".)").arg(QString::number(i + 1), patterns.at(i).toHtmlEscaped(), QString(error)))); state = false; if (mudlet::smDebugMode) { @@ -187,7 +187,7 @@ bool TTrigger::setRegexCodeList(QStringList patterns, QList<int> patternKinds, b TTrigger::decodeColorPatternText(patterns.at(i), textAnsiFg, textAnsiBg); if (textAnsiBg == scmIgnored && textAnsiFg == scmIgnored) { - setError(qsl("<b><font color='blue'>%1</font></b>") + setError(qsl("<b>%1</b>") .arg(tr("Error: in item %1, no colors to match were set - at least <i>one</i> of the foreground or background must not be <i>ignored</i>.") .arg(QString::number(i + 1)))); state = false; @@ -650,6 +650,10 @@ bool TTrigger::match_color_pattern(int line, int patternNumber, int posOffset, i } std::deque<TChar>& bufferLine = mpHost->mpConsole->buffer.buffer[line]; const QString& lineBuffer = mpHost->mpConsole->buffer.lineBuffer[line]; + // Match against the colors as they arrived from the game, not as already + // recolored by other triggers or scripts earlier in this trigger pass; + // text inserted mid-pass has no game original so it is read live: + const std::deque<TChar>* pPassLine = mpHost->mpConsole->buffer.preTriggerPassLine(line); // Filter ("only pass matches") parents hand children just the matched // capture, so restrict the scan to that window; for top-level triggers // the window covers the whole line: @@ -671,13 +675,14 @@ bool TTrigger::match_color_pattern(int line, int patternNumber, int posOffset, i } for (auto it = bufferLine.begin() + start; pos < end; ++it, ++pos) { + const TChar& character = (pPassLine && pos < static_cast<int>(pPassLine->size())) ? (*pPassLine)[pos] : *it; // This now allows matching against the current default colours (-1) and // allows ONE of the foreground or background to NOT be considered (-2) // Ideally we should base the matching on only the ANSI code but not // all parts of the text come from the Server and can be determined to // have come from a decoded ANSI code number: - if (((pCT->ansiFg == scmIgnored) || ((pCT->ansiFg == scmDefault) && mpHost->mpConsole->mFgColor == (*it).foreground()) || (pCT->mFgColor == (*it).foreground())) - && ((pCT->ansiBg == scmIgnored) || ((pCT->ansiBg == scmDefault) && mpHost->mpConsole->mBgColor == (*it).background()) || (pCT->mBgColor == (*it).background()))) { + if (((pCT->ansiFg == scmIgnored) || ((pCT->ansiFg == scmDefault) && mpHost->mpConsole->mFgColor == character.foreground()) || (pCT->mFgColor == character.foreground())) + && ((pCT->ansiBg == scmIgnored) || ((pCT->ansiBg == scmDefault) && mpHost->mpConsole->mBgColor == character.background()) || (pCT->mBgColor == character.background()))) { if (matchBegin == -1) { matchBegin = pos; } @@ -1093,6 +1098,12 @@ bool TTrigger::match(char* haystackC, const QString& haystack, int line, int pos mExpiryCount--; if (mExpiryCount == 0) { + // The delete is deferred to the end of the outermost pass, so an + // expired trigger left active would fire again from any pass that + // re-enters meanwhile. What stops enableTrigger() resurrecting it + // in that window is the markCleanup() below, not the deactivation: + // enableTrigger() skips anything in mCleanupSet. + setIsActive(false); mpHost->getTriggerUnit()->markCleanup(this); if (mudlet::smDebugMode) { @@ -1248,6 +1259,17 @@ void TTrigger::compile() bool TTrigger::setScript(const QString& script) { + // Switching from a registered anonymous Lua function (set up by tempTrigger with a + // function argument) to a script string: release the old function from the Lua + // registry and leave callback mode. Otherwise execute() keeps calling the stale + // function so the new script never runs, and the registry entry leaks - the + // destructor would take its mScript-based branch and delete the compiled function. + if (mRegisteredAnonymousLuaFunction) { + if (mpHost) { + mpHost->mLuaInterpreter.delete_luafunction(this); + } + mRegisteredAnonymousLuaFunction = false; + } mScript = script; if (script.isEmpty()) { mNeedsToBeCompiled = false; @@ -1275,31 +1297,51 @@ bool TTrigger::compileScript() } namespace { -// Tracks the innermost trigger running its script so feedTriggers() can name the -// culprit when aborting an endless loop; RAII restores the prior value on exit. -class ExecutingTriggerNameGuard +// Tracks the innermost trigger running its script, so feedTriggers() can name the +// culprit when aborting an endless loop, and the same-line creation lineage of +// that trigger's root, so anything the script creates joins it; RAII restores the +// prior values on exit. +class ExecutingTriggerGuard { public: - ExecutingTriggerNameGuard(TriggerUnit* pUnit, const QString* pName) + ExecutingTriggerGuard(TriggerUnit* pUnit, const QString* pName, const int chainId, const int generation) : mpUnit(pUnit) - , mpPrevious(pUnit->currentExecutingTriggerName()) + , mpPreviousName(pUnit->currentExecutingTriggerName()) + , mPreviousChainId(pUnit->currentSameLineChainId()) + , mPreviousGeneration(pUnit->currentSameLineGeneration()) { mpUnit->setCurrentExecutingTriggerName(pName); + mpUnit->setCurrentSameLineChain(chainId, generation); + } + ~ExecutingTriggerGuard() + { + mpUnit->setCurrentExecutingTriggerName(mpPreviousName); + mpUnit->setCurrentSameLineChain(mPreviousChainId, mPreviousGeneration); } - ~ExecutingTriggerNameGuard() { mpUnit->setCurrentExecutingTriggerName(mpPrevious); } - ExecutingTriggerNameGuard(const ExecutingTriggerNameGuard&) = delete; - ExecutingTriggerNameGuard& operator=(const ExecutingTriggerNameGuard&) = delete; + ExecutingTriggerGuard(const ExecutingTriggerGuard&) = delete; + ExecutingTriggerGuard& operator=(const ExecutingTriggerGuard&) = delete; private: TriggerUnit* mpUnit; - const QString* mpPrevious; + const QString* mpPreviousName; + int mPreviousChainId; + int mPreviousGeneration; }; } // namespace void TTrigger::execute() { - const ExecutingTriggerNameGuard executingTriggerNameGuard(mpHost->getTriggerUnit(), &mName); + // Only root triggers carry a lineage, so a trigger nested in a folder or a + // filter chain reads the one on the root its subtree hangs from. Creations + // that go under a parent rather than to the root list are outside this + // accounting altogether, as they are outside the list processDataStream() + // walks. + const TTrigger* pRoot = this; + while (pRoot->getParent()) { + pRoot = pRoot->getParent(); + } + const ExecutingTriggerGuard executingTriggerGuard(mpHost->getTriggerUnit(), &mName, pRoot->sameLineChainId(), pRoot->sameLineGeneration()); if (mSoundTrigger) { /* eventually something should be added to the gui to change sound volumes. 100=full volume */ QString mediaFileName = mSoundFile; diff --git a/src/TTrigger.h b/src/TTrigger.h index d0bc54a88..75d4706c2 100644 --- a/src/TTrigger.h +++ b/src/TTrigger.h @@ -172,6 +172,20 @@ public: int getExpiryCount() const; void setExpiryCount(int expiryCount); + // Set when the trigger is registered as a root node while a line is being + // processed, and cleared when that line is done with - see TriggerUnit's + // same-line creation chains. The id names the lineage this trigger belongs + // to, the generation is how many creations deep in it this trigger sits; + // everything its script creates during that line joins the same lineage one + // generation further down. + int sameLineChainId() const { return mSameLineChainId; } + int sameLineGeneration() const { return mSameLineGeneration; } + void setSameLineChain(const int chainId, const int generation) + { + mSameLineChainId = chainId; + mSameLineGeneration = generation; + } + private: TTrigger() = default; @@ -225,6 +239,8 @@ private: bool mModuleMember = false; // -1: don't self-destruct, 0: delete, 1+: number of times it can still fire int mExpiryCount = -1; + int mSameLineChainId = 0; + int mSameLineGeneration = 0; }; #ifndef QT_NO_DEBUG_STREAM diff --git a/src/TimerUnit.cpp b/src/TimerUnit.cpp index cdd1a0034..8715eca82 100644 --- a/src/TimerUnit.cpp +++ b/src/TimerUnit.cpp @@ -84,7 +84,21 @@ void TimerUnit::uninstall(const QString& packageName) uninstallList.append(rootTimer); } } + // Re-entrant uninstall (#9337): a timer's own script (e.g. a package + // auto-updater calling uninstallPackage()) is removing its package while + // TTimer::execute() is still on the call stack for that timer. Deleting now + // would be a use-after-free, so defer to doCleanup() at depth 0. + if (mProcessingDepth > 0) { + for (auto timer : uninstallList) { + timer->setIsActive(false); + mCleanupSet.remove(timer); // keep the two deferred-delete paths disjoint + } + return; + } for (auto& timer : uninstallList) { + // in case the timer was also queued for the markCleanup()/doCleanup() + // path - deleting it here would otherwise leave a dangling pointer there: + mCleanupSet.remove(timer); delete timer; } uninstallList.clear(); @@ -196,13 +210,13 @@ void TimerUnit::_removeTimerRootNode(TTimer* pT) if (!pT) { return; } - // temp timers do not need to check for names referring to multiple different - // objects as names=ID -> much faster tempTimer creation - if (!pT->isTemporary()) { - mLookupTable.remove(pT->mName, pT); - } else { - mLookupTable.remove(pT->getName()); - } + // Names are not unique - the lookup table is a QMultiMap - so drop this one + // timer's entry rather than every entry filed under the name. The + // single-argument remove() used to be taken for temporary timers on the + // grounds that their name is their id, but a permanent timer named after + // that id was evicted with it and left unreachable by name for the rest of + // the session + mLookupTable.remove(pT->getName(), pT); mTimerMap.remove(pT->getID()); mTimerRootNodeList.remove(pT); } @@ -282,13 +296,8 @@ void TimerUnit::_removeTimer(TTimer* pT) return; } - // temp timers do not need to check for names referring to multiple different - // objects as names=ID -> much faster tempTimer creation - if (!pT->isTemporary()) { - mLookupTable.remove(pT->mName, pT); - } else { - mLookupTable.remove(pT->getName()); - } + // see _removeTimerRootNode(): one entry, not every same-named one + mLookupTable.remove(pT->getName(), pT); mTimerMap.remove(pT->getID()); } @@ -383,15 +392,27 @@ std::vector<int> TimerUnit::findItems(const QString& name, const bool exactMatch bool TimerUnit::killTimer(const QString& name) { for (auto timer : mTimerRootNodeList) { - if (timer->getName() == name) { - // only temporary timers can be killed - if (!timer->isTemporary()) { - return false; - } - timer->killTimer(); - markCleanup(timer); - return true; + if (timer->getName() != name) { + continue; } + // Names are not unique, so keep looking rather than give up on the first + // same-named timer that cannot be killed - a permanent timer loaded from + // the profile precedes this session's temporaries in this list, and + // reporting a failure over it would strand a killable timer + if (!timer->isTemporary()) { + // only temporary timers can be killed + continue; + } + // An already killed timer is only unlinked from this list once doCleanup() + // gets to free it, which cannot happen while a timer script is on the call + // stack - so until then it is still findable by name. Killing it a second + // time achieves nothing: + if (mCleanupSet.contains(timer)) { + continue; + } + timer->killTimer(); + markCleanup(timer); + return true; } return false; } @@ -423,14 +444,35 @@ int TimerUnit::getNewID() void TimerUnit::doCleanup() { + if (mProcessingDepth > 0) { + return; + } + + QSet<TTimer*> deletedTimers; QMutableSetIterator<TTimer*> itTimer(mCleanupSet); while (itTimer.hasNext()) { auto pTimer = itTimer.next(); // It is important to take the item OUT of the set before you delete // (and thus invalidate this pointer to) it...! itTimer.remove(); + deletedTimers.insert(pTimer); delete pTimer; } + // Flush the deletes uninstall() deferred (#9337). uninstallList is ordered + // children-before-parents and each ~Tree unlinks from its parent, so deleting + // children first empties the parent's child list (no double free); the seen + // set guards a node queued twice by re-entrant uninstalls and is shared with + // the mCleanupSet loop above so an object that ended up in both containers is + // freed once. It matches on pointer identity only: a node freed indirectly, as + // a child of a queued parent, is not in the set (not reachable today - only + // temporary root nodes are ever queued, and those have no children). + for (auto timer : uninstallList) { + if (!deletedTimers.contains(timer)) { + deletedTimers.insert(timer); + delete timer; + } + } + uninstallList.clear(); } void TimerUnit::markCleanup(TTimer* pT) diff --git a/src/TimerUnit.h b/src/TimerUnit.h index 835b62145..da6894403 100644 --- a/src/TimerUnit.h +++ b/src/TimerUnit.h @@ -37,10 +37,15 @@ class Host; class TTimer; class QTimer; -// Note: Unlike AliasUnit/TriggerUnit/KeyUnit, TimerUnit does not use mProcessingDepth -// because timers execute via Qt's event loop (QTimer signals), not synchronous loops. -// Protection against re-entrancy is provided by the guard in TTimer::execute() and -// by re-verifying timer existence after execute() in mudlet::slot_timerFires(). +// Note: mProcessingDepth tracks TTimer::execute() nesting (via begin/endProcessing()) +// so that uninstall() can defer deletion of a package's timers while one of them is +// still on the call stack - a timer script calling uninstallPackage() on its own +// package would otherwise free the very TTimer execute() is running on. Deferred +// items are flushed by doCleanup() once no timer script is executing - primarily +// by mudlet::slot_timerFires() right after it finishes with the fired timer, so +// the "uninstalled" objects do not outlive the event loop iteration. This +// complements the guard in TTimer::execute() and the re-verification of timer +// existence after execute() in mudlet::slot_timerFires(). class TimerUnit { friend class XMLexport; @@ -70,6 +75,12 @@ public: void reenableAllTriggers(); void markCleanup(TTimer*); void doCleanup(); + void beginProcessing() { ++mProcessingDepth; } + void endProcessing() + { + --mProcessingDepth; + Q_ASSERT(mProcessingDepth >= 0); + } std::tuple<QString, int, int, int> assembleReport(); int getNewID(); void uninstall(const QString&); @@ -79,6 +90,7 @@ public: QMultiMap<QString, TTimer*> mLookupTable; QList<TTimer*> uninstallList; + QSet<TTimer*> mCleanupSet; // This will contain all the QTimers associated with the TTimer instances // it is needed so that should mpHost be renamed we can update them to have @@ -103,7 +115,9 @@ private: std::list<TTimer*> mTimerRootNodeList; int mMaxID = 0; bool mModuleMember = false; - QSet<TTimer*> mCleanupSet; + // > 0 whilst a TTimer::execute() is on the call stack; uninstall() and + // doCleanup() must not delete timers then - see the note above the class: + int mProcessingDepth = 0; int statsActiveItems = 0; int statsItemsTotal = 0; int statsTempItems = 0; diff --git a/src/TriggerUnit.cpp b/src/TriggerUnit.cpp index 44f2ec775..305650601 100644 --- a/src/TriggerUnit.cpp +++ b/src/TriggerUnit.cpp @@ -28,8 +28,12 @@ #include "TConsole.h" #include "TTrigger.h" +#include <QScopeGuard> + #include <algorithm> #include <functional> +#include <limits> +#include <vector> /* We need an explicit constructor in this file as the Host class is forward * declared in the header file and it is problematic to define any dereferencing @@ -104,6 +108,9 @@ void TriggerUnit::uninstall(const QString& packageName) return; } for (auto& trigger : uninstallList) { + // in case the trigger was also queued for the markCleanup()/doCleanup() + // path - deleting it here would otherwise leave a dangling pointer there: + mCleanupSet.remove(trigger); delete trigger; } uninstallList.clear(); @@ -191,20 +198,15 @@ void TriggerUnit::removeTriggerRootNode(TTrigger* pT) if (!pT) { return; } - if (!pT->isTemporary()) { - mLookupTable.remove(pT->mName, pT); - } else { - mLookupTable.remove(pT->getName()); - } + // Names are not unique - the lookup table is a QMultiMap - so drop this one + // trigger's entry rather than every entry filed under the name. The + // single-argument remove() used to be taken for temporary triggers, which + // evicted live same-named triggers and left them unreachable by name for the + // rest of the session (tempComplexRegexTrigger() takes a user-supplied name, + // so a collision needs no coincidence) + mLookupTable.remove(pT->getName(), pT); mTriggerMap.remove(pT->getID()); mTriggerRootNodeList.remove(pT); - // A node can be removed and deleted mid-pass without going through the - // deferred-cleanup paths (e.g. XMLimport discarding its placeholder trigger - // when installPackage() runs from a trigger script), so it must not linger - // in the same-line match list. Null the slot instead of compacting: - // processDataStream() may be walking that list by index right now, and - // shifting entries under it would skip a trigger's same-line match. - std::replace(mRootNodesAddedWhileProcessing.begin(), mRootNodesAddedWhileProcessing.end(), pT, static_cast<TTrigger*>(nullptr)); } TTrigger* TriggerUnit::getTrigger(int id) @@ -236,15 +238,47 @@ bool TriggerUnit::registerTrigger(TTrigger* pT) addTriggerRootNode(pT); if (mProcessingDepth > 0) { mRootNodesAddedWhileProcessing.append(pT); + startOrExtendSameLineChain(pT); } return true; } +// A trigger created by a trigger that was itself created while this line was +// being processed joins that trigger's lineage, one generation further down; +// anything created from a script that predates the line starts a lineage of its +// own at generation one. So a script arming a batch produces a generation of +// one-deep lineages however big the batch, while a trigger that re-creates +// itself keeps adding generations to a single lineage. +void TriggerUnit::startOrExtendSameLineChain(TTrigger* pT) +{ + int chainId = mCurrentSameLineChainId; + if (!chainId) { + if (mLastSameLineChainId == std::numeric_limits<int>::max()) { + mLastSameLineChainId = 0; + } + chainId = ++mLastSameLineChainId; + mSameLineChainStarters.insert(chainId, mpCurrentExecutingTriggerName ? *mpCurrentExecutingTriggerName : QString()); + } + pT->setSameLineChain(chainId, mCurrentSameLineGeneration + 1); +} + void TriggerUnit::unregisterTrigger(TTrigger* pT) { if (!pT) { return; } + // A node can be removed and deleted mid-pass without going through the + // deferred-cleanup paths (e.g. XMLimport discarding its placeholder trigger + // when installPackage() runs from a trigger script), so it must not linger in + // the same-line match list. Done here rather than in removeTriggerRootNode() + // because a trigger that was a root node when it was added to that list can + // have been reparented since, which routes it to removeTrigger() instead. + // Null the slot instead of compacting: processDataStream() may be walking the + // list by index right now, and shifting entries under it would skip a + // trigger's same-line match. Nulling it also takes the trigger out of reach + // of the end-of-pass reset, so drop its lineage here instead. + std::replace(mRootNodesAddedWhileProcessing.begin(), mRootNodesAddedWhileProcessing.end(), pT, static_cast<TTrigger*>(nullptr)); + pT->setSameLineChain(0, 0); if (pT->getParent()) { removeTrigger(pT); return; @@ -271,11 +305,8 @@ void TriggerUnit::removeTrigger(TTrigger* pT) if (!pT) { return; } - if (!pT->isTemporary()) { - mLookupTable.remove(pT->mName, pT); - } else { - mLookupTable.remove(pT->getName()); - } + // see removeTriggerRootNode(): one entry, not every same-named one + mLookupTable.remove(pT->getName(), pT); mTriggerMap.remove(pT->getID()); } @@ -303,6 +334,67 @@ int TriggerUnit::getNewID() return ++mMaxID; } +// Stopping the pass is not enough: what the lineage created is still live and +// still matching, so the next line would start with a budget's worth of them and +// each would spawn a budget's worth again, costing a multiple of the line before +// it. Only the runaway lineage is disowned - a capture trigger an unrelated +// script armed on the same line belongs to a lineage of its own and is left +// alone. The whole list is scanned rather than the tail of this pass: a lineage +// started in an outer pass can go on growing inside a nested feedTriggers() pass, +// and when that nested pass is the one to trip, the earlier members sit below its +// first-node index. Permanent triggers get deactivate() and not setIsActive(false), +// which would clear the user-active state XMLexport saves and leave them switched +// off after a restart. +void TriggerUnit::stopSameLineCreationLoop(const int chainId) +{ + int killedCount = 0; + int deactivatedCount = 0; + for (auto trigger : std::as_const(mRootNodesAddedWhileProcessing)) { + if (!trigger || trigger->sameLineChainId() != chainId) { + continue; + } + if (trigger->isTemporary()) { + trigger->setIsActive(false); + markCleanup(trigger); + ++killedCount; + } else { + trigger->deactivate(); + ++deactivatedCount; + } + } + const QString triggerName = mSameLineChainStarters.value(chainId); + + qWarning().nospace() << "TriggerUnit::processDataStream(...) aborting: one lineage of triggers created while processing a line reached " << scmMaxSameLineGenerations + << " generations - probably a trigger that re-creates itself. Profile: " << (mpHost ? mpHost->getName() : QString()) << ", triggers removed: " << killedCount + << ", deactivated: " << deactivatedCount << ", lineage started by: " << triggerName; + if (!mpHost) { + return; + } + // A runaway whose creator outlives the line trips on every matching line and + // would bury the game text; the qWarning() above is not throttled. + constexpr qint64 reportIntervalMs = 10000; + if (mSameLineLoopReportTimer.isValid() && mSameLineLoopReportTimer.elapsed() < reportIntervalMs) { + return; + } + mSameLineLoopReportTimer.start(); + + //: %n is a count of triggers. Shown in the game window when a trigger keeps creating new triggers that match the same line, which would otherwise never end + const QString created = tr("%n trigger(s) created while processing this line have been stopped: temporary ones removed, permanent ones switched off until the profile is reloaded.", + nullptr, + killedCount + deactivatedCount); + if (triggerName.isEmpty()) { + //: %1 is the sentence above, about the triggers that were stopped + mpHost->postMessage(tr("[ ERROR ] - Trigger processing stopped to prevent a freeze: a trigger (or another trigger it creates) keeps creating new triggers that match the line being " + "processed, so that line never finishes. %1 Create the trigger once, outside its own script, or give it a pattern that does not match the line it is created on.") + .arg(created)); + return; + } + //: %1 is the name of a trigger - the name of a trigger made by tempTrigger() and friends is its id number - and %2 is the sentence above, about the triggers that were stopped + mpHost->postMessage(tr("[ ERROR ] - Trigger processing stopped to prevent a freeze: trigger '%1' (or another trigger it creates) keeps creating new triggers that match the line being " + "processed, so that line never finishes. %2 Create the trigger once, outside its own script, or give it a pattern that does not match the line it is created on.") + .arg(triggerName, created)); +} + void TriggerUnit::processDataStream(const QString& data, int line) { if (data.isEmpty()) { @@ -321,13 +413,31 @@ void TriggerUnit::processDataStream(const QString& data, int line) subject[utf8Length] = '\0'; mProcessingDepth++; + const auto processingGuard = qScopeGuard([this] { + mProcessingDepth--; + Q_ASSERT(mProcessingDepth >= 0); + if (mProcessingDepth == 0) { + // Deletion is deferred while any pass runs, so these pointers stayed + // valid; drop them before doCleanup() frees the underlying triggers. + // A trigger that outlives the line it was created on stops being part + // of a lineage, so its own creations start counting afresh. + for (auto trigger : std::as_const(mRootNodesAddedWhileProcessing)) { + if (trigger) { + trigger->setSameLineChain(0, 0); + } + } + mRootNodesAddedWhileProcessing.clear(); + mSameLineChainStarters.clear(); + doCleanup(); + } + }); // Iterate a snapshot of the root list: a trigger's Lua script can call // uninstallPackage()/installPackage() and mutate mTriggerRootNodeList // mid-iteration (the underlying std::list::remove frees the iterator's // current node → use-after-free on the next ++). AliasUnit dodges the // same hazard for the same reason — see Mudlet issue #4297. - auto copyOfNodeList = mTriggerRootNodeList; + std::vector<TTrigger*> copyOfNodeList(mTriggerRootNodeList.cbegin(), mTriggerRootNodeList.cend()); // Triggers registered by a script during this pass (tempTrigger() & Co.) // are missing from the snapshot but must still match the current line: // before the snapshot the loop walked the live std::list, which a push_back @@ -342,26 +452,32 @@ void TriggerUnit::processDataStream(const QString& data, int line) } trigger->match(subject, data, line); } - // Index-based loop: a match here can register yet more triggers, growing - // the list; they too get a shot at the current line, just as with the - // live-list iteration. + // A match here can register more triggers, which also get a shot at the + // current line - so the list grows in front of the loop, and a trigger that + // re-creates itself never lets the line finish. Nothing else catches that: no + // C++ frame recurses, so mProcessingDepth stays put and the feedTriggers() + // depth guard never sees it. Only the lineage that is extending itself gets + // stopped; every other lineage the line started carries on matching, which is + // the difference between a runaway and a script arming a batch of triggers. for (qsizetype i = firstNodeAddedThisPass; i < mRootNodesAddedWhileProcessing.size(); ++i) { + if (i - firstNodeAddedThisPass >= scmMaxSameLineCreationsPerLine) { + qWarning().nospace() << "TriggerUnit::processDataStream(...) stopping: more than " << scmMaxSameLineCreationsPerLine + << " triggers were created while processing one line, so the rest are not being offered it. Profile: " << (mpHost ? mpHost->getName() : QString()); + break; + } auto trigger = mRootNodesAddedWhileProcessing.at(i); if (!trigger || !trigger->isActive()) { continue; } + // stopSameLineCreationLoop() deactivates the whole lineage, so the check + // above skips its remaining members and this loop reaches a lineage once + if (trigger->sameLineGeneration() > scmMaxSameLineGenerations) { + stopSameLineCreationLoop(trigger->sameLineChainId()); + continue; + } trigger->match(subject, data, line); } free(subject); - - mProcessingDepth--; - Q_ASSERT(mProcessingDepth >= 0); - if (mProcessingDepth == 0) { - // Deletion is deferred while any pass runs, so these pointers stayed - // valid; drop them before doCleanup() frees the underlying triggers. - mRootNodesAddedWhileProcessing.clear(); - doCleanup(); - } } void TriggerUnit::compileAll() @@ -424,6 +540,13 @@ bool TriggerUnit::enableTrigger(const QString& name) // start mid-run and skip duplicates on some QMultiMap implementations const auto [begin, end] = mLookupTable.equal_range(name); for (auto it = begin; it != end; ++it) { + // A trigger queued for deletion stays in the lookup table until + // doCleanup() frees it, which cannot run mid-pass - re-activating one + // resurrects a spent one-shot, a killTrigger()ed trigger, or a trigger + // whose package was uninstalled mid-pass. + if (mCleanupSet.contains(it.value()) || uninstallList.contains(it.value())) { + continue; + } it.value()->setIsActive(true); found = true; } @@ -455,16 +578,31 @@ void TriggerUnit::setTriggerStayOpen(const QString& name, int lines) bool TriggerUnit::killTrigger(const QString& name) { - auto it = mLookupTable.constFind(name); - while (it != mLookupTable.cend() && it.key() == name) { + // equal_range visits every same-named trigger; constFind() + (++it) can + // start mid-run and skip duplicates on some QMultiMap implementations + const auto [begin, end] = mLookupTable.equal_range(name); + for (auto it = begin; it != end; ++it) { TTrigger* pT = it.value(); - if (pT->isTemporary()) //this function is only defined for tempTriggers, permanent objects cannot be removed - { - // there can only be a single tempTrigger by this name and this function ignores non-tempTriggers by definition - markCleanup(pT); - return true; + if (!pT->isTemporary()) { + // this function is only defined for tempTriggers, permanent objects cannot be removed + continue; } - it++; + // An already killed trigger is only unlinked from the lookup table once + // doCleanup() gets to free it, which cannot happen while a trigger script + // is on the call stack - so until then it is still findable by name. + // tempComplexRegexTrigger() replaces a temporary trigger under the name it + // was given, so a corpse and a live trigger can share one: keep looking + // rather than report a kill that would achieve nothing. + if (mCleanupSet.contains(pT)) { + continue; + } + // Deactivating matters as much as queueing the delete: the trigger stays + // in the list processDataStream() is walking until that deferred cleanup, + // and a killed trigger must no more fire on the rest of the line than a + // disabled one does + pT->setIsActive(false); + markCleanup(pT); + return true; } return false; } @@ -514,17 +652,22 @@ void TriggerUnit::doCleanup() return; } + QSet<TTrigger*> deletedTriggers; QMutableSetIterator<TTrigger*> itTrigger(mCleanupSet); while (itTrigger.hasNext()) { auto pTrigger = itTrigger.next(); itTrigger.remove(); + deletedTriggers.insert(pTrigger); delete pTrigger; } // Flush the deletes uninstall() deferred (#9337). uninstallList is ordered // children-before-parents and each ~Tree unlinks from its parent, so deleting // children first empties the parent's child list (no double free); the seen - // set guards a node queued twice by re-entrant uninstalls. - QSet<TTrigger*> deletedTriggers; + // set guards a node queued twice by re-entrant uninstalls and is shared with + // the mCleanupSet loop above so an object that ended up in both containers is + // freed once. It matches on pointer identity only: a node freed indirectly, as + // a child of a queued parent, is not in the set (not reachable today - only + // temporary root nodes are ever queued, and those have no children). for (auto trigger : uninstallList) { if (!deletedTriggers.contains(trigger)) { deletedTriggers.insert(trigger); diff --git a/src/TriggerUnit.h b/src/TriggerUnit.h index 487244d45..95ab96c36 100644 --- a/src/TriggerUnit.h +++ b/src/TriggerUnit.h @@ -26,6 +26,9 @@ #include "utils.h" +#include <QCoreApplication> +#include <QElapsedTimer> +#include <QHash> #include <QMultiMap> #include <QPointer> #include <QSet> @@ -38,6 +41,7 @@ class TTrigger; class TriggerUnit { + Q_DECLARE_TR_FUNCTIONS(TriggerUnit) // Needed so we can use tr() even though TriggerUnit is NOT derived from QObject friend class XMLexport; friend class XMLimport; @@ -81,11 +85,39 @@ public: // is deferred to doCleanup() once mProcessingDepth returns to 0. const QString* currentExecutingTriggerName() const { return mpCurrentExecutingTriggerName; } void setCurrentExecutingTriggerName(const QString* pName) { mpCurrentExecutingTriggerName = pName; } + // The same-line creation lineage of the root of the trigger whose script is + // running, so a trigger it creates joins that lineage rather than starting + // one - see registerTrigger(). Zero while no trigger script is running (an + // alias or a timer counts as none), or while the running one predates the + // line being processed. + int currentSameLineChainId() const { return mCurrentSameLineChainId; } + int currentSameLineGeneration() const { return mCurrentSameLineGeneration; } + void setCurrentSameLineChain(const int chainId, const int generation) + { + mCurrentSameLineChainId = chainId; + mCurrentSameLineGeneration = generation; + } // Turns an endless self-feeding-trigger loop into a catchable Lua error before // it overflows the stack. Sized for the smallest platform stack (~1MB on // Windows, where the original crash hit before Lua's own 200-C-call guard): // a few times any legitimate nesting, comfortably below the native limit. inline static const int scmMaxProcessingDepth = 50; + // How many creations deep one lineage of same-line creations may go while a + // single line is processed. Separate from the depth above, which measures the + // C stack: nothing recurses here, it is the list processDataStream() walks + // that grows. Generations rather than a head count is what separates the two + // shapes: a script arming a batch produces one generation however big the + // batch, while a trigger that re-creates itself adds a generation per round + // and is the only thing that can go on forever. 1000 is far past any chain a + // real script builds. + inline static const int scmMaxSameLineGenerations = 1000; + // Generations alone do not bound what one line costs: a lineage that widens + // as it deepens multiplies. Past this many creations new triggers stop being + // offered the line, and since matching is what makes them create more, that + // ends the growth. Nothing is stopped or disowned here - all of them are + // still armed for the lines that follow - so it can sit well clear of any + // legitimate batch. + inline static const qsizetype scmMaxSameLineCreationsPerLine = 20000; QList<TTrigger*> uninstallList; @@ -97,6 +129,8 @@ private: void addTrigger(TTrigger* pT); void removeTriggerRootNode(TTrigger* pT); void removeTrigger(TTrigger*); + void startOrExtendSameLineChain(TTrigger* pT); + void stopSameLineCreationLoop(const int chainId); QPointer<Host> mpHost; QMap<int, TTrigger*> mTriggerMap; @@ -115,6 +149,18 @@ private: // pass can match the ones created during it against the line being // processed - see processDataStream(). Cleared once the outermost pass ends. QList<TTrigger*> mRootNodesAddedWhileProcessing; + // The name of the trigger whose script started each same-line creation + // lineage, for the message when one runs away. Keyed by chain id, so a + // trigger dying mid-line cannot leave a stale pointer behind. Cleared once + // the outermost pass ends. + QHash<int, QString> mSameLineChainStarters; + int mCurrentSameLineChainId = 0; + int mCurrentSameLineGeneration = 0; + // Handed out monotonically and never deliberately recycled: an id that + // outlived the pass it was given out in would otherwise be misfiled under a + // later lineage. Zero means "none". + int mLastSameLineChainId = 0; + QElapsedTimer mSameLineLoopReportTimer; }; #endif // MUDLET_TRIGGERUNIT_H diff --git a/src/UntrustedText.cpp b/src/UntrustedText.cpp new file mode 100644 index 000000000..f8dc0e59d --- /dev/null +++ b/src/UntrustedText.cpp @@ -0,0 +1,103 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "UntrustedText.h" + +#include "utils.h" + +bool UntrustedText::unsafeCharacter(char32_t codePoint) +{ + // C0 controls, DEL, and C1 controls - which include CR, LF and NEL. + if (codePoint <= 0x1F || (codePoint >= 0x7F && codePoint <= 0x9F)) { + return true; + } + // Arabic letter mark, zero-width space through RLM, the explicit bidi + // embedding and override controls, and the bidi isolates. + if (codePoint == 0x061C || (codePoint >= 0x200B && codePoint <= 0x200F) || (codePoint >= 0x202A && codePoint <= 0x202E) || (codePoint >= 0x2066 && codePoint <= 0x2069)) { + return true; + } + // Line and paragraph separators, which Qt renders as a real line break. + if (codePoint >= 0x2028 && codePoint <= 0x2029) { + return true; + } + // The tag characters, invisible by design. U+E0001 is deprecated; the rest + // are what emoji subdivision flag sequences are built from, so this range + // is deliberately not applied to authored text. + if (codePoint >= 0xE0000 && codePoint <= 0xE007F) { + return true; + } + // Word joiner and byte order mark, both invisible padding. + return codePoint == 0x2060 || codePoint == 0xFEFF; +} + +bool UntrustedText::unsafeAuthoredCharacter(char32_t codePoint) +{ + // ZWNJ and ZWJ carry meaning in text a human is meant to read - emoji + // sequences and Persian, Arabic and Indic shaping - so escaping them + // corrupts legitimate content. They stay unsafe in a link target, where + // they have no such role and would only serve to hide part of it. + if (codePoint == 0x200C || codePoint == 0x200D) { + return false; + } + // Only the assigned tag characters, which is all an emoji subdivision flag + // needs - the lowest any of them uses is U+E0062. U+E0001 is a deprecated + // language tag and U+E0000 and U+E0002 to U+E001F are unassigned, so none + // of them can be needed by text meant to be read, and they keep no hiding + // space they do not have to. + if (codePoint >= 0xE0020 && codePoint <= 0xE007F) { + return false; + } + + return unsafeCharacter(codePoint); +} + +QString UntrustedText::forTarget(const QString& text) +{ + return escapeWith(text, &UntrustedText::unsafeCharacter); +} + +QString UntrustedText::forAuthoredText(const QString& text) +{ + return escapeWith(text, &UntrustedText::unsafeAuthoredCharacter); +} + +QString UntrustedText::escapeWith(const QString& text, bool (*unsafe)(char32_t)) +{ + const QList<uint> codePoints = text.toUcs4(); + + QString result; + result.reserve(text.size()); + for (qsizetype i = 0; i < codePoints.size(); ++i) { + const uint codePoint = codePoints.at(i); + if (unsafe(static_cast<char32_t>(codePoint))) { + // Uppercase the hex digits only - uppercasing the whole fragment + // would turn the \u prefix into \U. + result += qsl("\\u{%1}").arg(QString::number(codePoint, 16).toUpper()); + } else if (codePoint == '\\' && i + 2 < codePoints.size() && codePoints.at(i + 1) == 'u' && codePoints.at(i + 2) == '{') { + // A literal "\u{...}" in server text would read the same as an + // escaped invisible character, so the backslash that starts one is + // itself escaped to keep the output unambiguous. + result += qsl("\\u{5C}"); + } else { + result += QChar::fromUcs4(codePoint); + } + } + + return result; +} diff --git a/src/UntrustedText.h b/src/UntrustedText.h new file mode 100644 index 000000000..4b8879832 --- /dev/null +++ b/src/UntrustedText.h @@ -0,0 +1,61 @@ +#ifndef MUDLET_UNTRUSTEDTEXT_H +#define MUDLET_UNTRUSTEDTEXT_H + +/*************************************************************************** + * Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QString> + +// Escapes characters a remote game server could use to make displayed text +// misrepresent itself. Two policies, because the two kinds of text have +// different needs: +// +// forTarget() - a command or URL the user reads to decide whether to +// trust a link. Nothing invisible may survive here. +// forAuthoredText() - a tooltip, menu label or menu title the server author +// wrote to be read. Same policy, except it keeps the +// joiners and tag characters that multi-part emoji and +// Persian, Arabic and Indic text are built from. +class UntrustedText +{ +public: + // True for the enumerated set of code points that render as invisible, + // zero-width, direction reordering, or line breaking text - not a general + // test for those properties. The set is deliberately narrow; widen it in + // the implementation rather than assuming coverage. + static bool unsafeCharacter(char32_t codePoint); + + // As unsafeCharacter(), minus the zero-width joiner and non-joiner and the + // assigned tag characters U+E0020 to U+E007F. Those are load-bearing in + // text meant to be read: ZWJ builds 👨‍🍳 and 🏳️‍🌈, the tag characters build + // subdivision flags like 🏴󠁧󠁢󠁳󠁣󠁴󠁿, and ZWNJ is required for correct Persian + // and Indic shaping. + static bool unsafeAuthoredCharacter(char32_t codePoint); + + // Replace every unsafe code point with a visible \u{...} escape carrying + // its hex value, leaving all other text - including non-Latin scripts and + // astral plane code points - untouched. + static QString forTarget(const QString& text); + static QString forAuthoredText(const QString& text); + +private: + static QString escapeWith(const QString& text, bool (*unsafe)(char32_t)); +}; + +#endif // MUDLET_UNTRUSTEDTEXT_H diff --git a/src/VarUnit.cpp b/src/VarUnit.cpp index 5c6ba102d..7f7469499 100644 --- a/src/VarUnit.cpp +++ b/src/VarUnit.cpp @@ -182,6 +182,12 @@ void VarUnit::addTreeItem(QTreeWidgetItem* p, TVar* var) wVars.insert(p, var); } +void VarUnit::removeTreeItem(QTreeWidgetItem* p) +{ + wVars.remove(p); + tVars.remove(p); +} + void VarUnit::addTempVar(QTreeWidgetItem* p, TVar* var) { tVars.insert(p, var); diff --git a/src/VarUnit.h b/src/VarUnit.h index 80be57add..46e018a12 100644 --- a/src/VarUnit.h +++ b/src/VarUnit.h @@ -59,14 +59,15 @@ public: TVar* getWVar(QTreeWidgetItem*); TVar* getTVar(QTreeWidgetItem*); void addTreeItem(QTreeWidgetItem*, TVar*); + void removeTreeItem(QTreeWidgetItem*); void addSavedVar(TVar*); void removeSavedVar(TVar*); void addHidden(TVar*, int); void addHidden(const QString&); - bool isHidden(TVar *var); - bool isHidden(const QString &fullname); - void removeHidden(TVar *var); - void removeHidden(const QString &name); + bool isHidden(TVar* var); + bool isHidden(const QString& fullname); + void removeHidden(TVar* var); + void removeHidden(const QString& name); bool isSaved(TVar*); void addPointer(const void*); QString getUnsaveableReason(TVar*); diff --git a/src/XMLexport.cpp b/src/XMLexport.cpp index 5e3f84e7f..fe4a4b8ac 100644 --- a/src/XMLexport.cpp +++ b/src/XMLexport.cpp @@ -47,6 +47,7 @@ #include <QMetaEnum> #include <sstream> +#include <utility> XMLexport::XMLexport(Host* pH) : mpHost(pH) @@ -83,7 +84,10 @@ XMLexport::XMLexport(TKey* pT) { } -void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileName, bool async) +// Builds the module's XML document into mExportDoc. This reads the live +// trigger/timer/alias/action/script/key lists, so it must run on the main thread; +// serializing it to disk can then happen on a background thread. +void XMLexport::writeModuleXML(const QString& moduleName) { auto pHost = mpHost; auto mudletPackage = writeXmlHeader(); @@ -91,7 +95,7 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam auto triggerPackage = mudletPackage.append_child("TriggerPackage"); //we go a level down for all these functions so as to not infinitely nest the module for (auto& it : pHost->mTriggerUnit.mTriggerRootNodeList) { - if (!it || it->mPackageName != moduleName) { + if (!it || pHost->mTriggerUnit.uninstallList.contains(it) || it->mPackageName != moduleName) { continue; } if (!it->isTemporary() && it->mModuleMember) { @@ -101,7 +105,7 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam auto timerPackage = mudletPackage.append_child("TimerPackage"); for (auto& it : pHost->mTimerUnit.mTimerRootNodeList) { - if (!it || it->mPackageName != moduleName) { + if (!it || pHost->mTimerUnit.uninstallList.contains(it) || it->mPackageName != moduleName) { continue; } if (!it->isTemporary() && it->mModuleMember) { @@ -111,7 +115,7 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam auto aliasPackage = mudletPackage.append_child("AliasPackage"); for (auto& it : pHost->mAliasUnit.mAliasRootNodeList) { - if (!it || it->mPackageName != moduleName) { + if (!it || pHost->mAliasUnit.uninstallList.contains(it) || it->mPackageName != moduleName) { continue; } if (!it->isTemporary() && it->mModuleMember) { @@ -121,7 +125,7 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam auto actionPackage = mudletPackage.append_child("ActionPackage"); for (auto& it : pHost->mActionUnit.mActionRootNodeList) { - if (!it || it->mPackageName != moduleName) { + if (!it || pHost->mActionUnit.uninstallList.contains(it) || it->mPackageName != moduleName) { continue; } if (it->mModuleMember) { @@ -131,7 +135,7 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam auto scriptPackage = mudletPackage.append_child("ScriptPackage"); for (auto& it : pHost->mScriptUnit.mScriptRootNodeList) { - if (!it || it->mPackageName != moduleName) { + if (!it || pHost->mScriptUnit.uninstallList.contains(it) || it->mPackageName != moduleName) { continue; } if (it->mModuleMember) { @@ -141,7 +145,7 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam auto keyPackage = mudletPackage.append_child("KeyPackage"); for (auto& it : pHost->mKeyUnit.mKeyRootNodeList) { - if (!it || it->mPackageName != moduleName) { + if (!it || pHost->mKeyUnit.uninstallList.contains(it) || it->mPackageName != moduleName) { continue; } if (!it->isTemporary() && it->mModuleMember) { @@ -155,12 +159,14 @@ void XMLexport::writeModuleXML(const QString& moduleName, const QString& fileNam } else { helpPackage.append_child("helpURL").text().set(""); } - if (async) { - runAsyncSave(fileName, fileName); - } else { - saveXml(fileName); - mpHost->xmlSaved(fileName); - } +} + +// Hands the document over so the write can outlive both this XMLexport and its Host +// without a second copy of the tree. mExportDoc is left valid but empty, and any +// xml_node handle taken from it beforehand must not be used afterwards. +std::shared_ptr<pugi::xml_document> XMLexport::takeExportDocument() +{ + return std::make_shared<pugi::xml_document>(std::move(mExportDoc)); } bool XMLexport::exportHost(const QString& filename_pugi_xml) @@ -177,23 +183,16 @@ bool XMLexport::exportHost(const QString& filename_pugi_xml) return true; } -// Helper to encapsulate async save pattern: clone document, save in background thread, -// notify host when complete void XMLexport::runAsyncSave(const QString& fileName, const QString& xmlSavedKey) { - // Clone XML document on main thread, then serialize and save on background thread. - // Cloning is fast and safe; each document owns its own tree, so the clone can be - // serialized on a background thread without thread-safety issues. QPointer<Host> host = mpHost; - pugi::xml_document docClone; - // Deep copy the entire document tree - for (pugi::xml_node child = mExportDoc.first_child(); child; child = child.next_sibling()) { - docClone.append_copy(child); - } - auto future = QtConcurrent::run([fileName, docClone = std::move(docClone)]() mutable { - return XMLexport::saveXmlDocToFile(fileName, docClone); + auto future = QtConcurrent::run([fileName, doc = takeExportDocument()]() { + return XMLexport::saveXmlDocToFile(fileName, *doc); }); - auto watcher = new QFutureWatcher<bool>; + // Parented to the profile for the same reason the module save's watcher is: the + // deleteLater() below needs an event loop that is still running to be delivered, + // and the save that matters most here is the one on the way out. + auto watcher = new QFutureWatcher<bool>(host); connect(watcher, &QFutureWatcher<bool>::finished, host, [host, xmlSavedKey]() { if (!host) { return; @@ -317,11 +316,9 @@ bool XMLexport::saveXml(const QString& fileName) return success; } -// Save an XML document to a file. This is thread-safe and can be called from a background thread -// as long as the document is not being modified concurrently (which we ensure by passing a clone). -// Static method so it can be called without keeping XMLexport alive. -// Note: This is a static member method that doesn't access any instance state, -// making it safe to call from background threads. +// Callable from a background thread as long as nothing modifies the document +// concurrently, which handing it over with takeExportDocument() ensures. Static so it +// neither keeps the XMLexport alive nor touches any instance state. bool XMLexport::saveXmlDocToFile(const QString& fileName, const pugi::xml_document& doc) { QSaveFile file(fileName); @@ -546,6 +543,7 @@ void XMLexport::writeHost(Host* pHost, pugi::xml_node mudletPackage) host.append_attribute("DebugShowAllProblemCodepoints") = pHost->debugShowAllProblemCodepoints() ? "yes" : "no"; host.append_attribute("announceIncomingText") = pHost->mAnnounceIncomingText ? "yes" : "no"; host.append_attribute("advertiseScreenReader") = pHost->mAdvertiseScreenReader ? "yes" : "no"; + host.append_attribute("enableOSC8Hyperlinks") = pHost->mEnableOSC8Hyperlinks ? "yes" : "no"; host.append_attribute("f3SearchEnabled") = pHost->mF3SearchEnabled ? "yes" : "no"; host.append_attribute("enableClosedCaption") = pHost->mEnableClosedCaption ? "yes" : "no"; host.append_attribute("caretShortcut") = QMetaEnum::fromType<Host::CaretShortcut>().valueToKey(static_cast<int>(pHost->mCaretShortcut)); @@ -775,25 +773,37 @@ void XMLexport::writeVariablePackage(Host* pHost, pugi::xml_node& mudletPackage) } } - TVar* base = vu->getBase(); - if (!base) { - lI->getVars(false); - base = vu->getBase(); - } + // Into a throwaway tree rather than the live one: the Variables editor's + // QTreeWidgetItems point into the live tree, so rebuilding it here would + // strand every one of them. Reusing it as it stands is no good either - only + // the editor rebuilds it, so anything a script did since is missing from it. + LuaInterface saveTimeInterface(lI->getState()); + VarUnit* saveTimeUnit = saveTimeInterface.getVarUnit(); + // A fresh tree carries no per-variable saved/hidden flags, so isSaved() and + // isHidden() have to answer from these name-keyed sets. + saveTimeUnit->savedVars = vu->savedVars; + saveTimeUnit->hidden = vu->hidden; + saveTimeUnit->hiddenByUser = vu->hiddenByUser; + saveTimeInterface.getVars(false); - if (base) { + if (TVar* base = saveTimeUnit->getBase()) { QListIterator<TVar*> itVariable(base->getChildren(false)); while (itVariable.hasNext()) { - writeVariable(itVariable.next(), lI, vu, variablePackage); + writeVariable(itVariable.next(), &saveTimeInterface, saveTimeUnit, variablePackage); } } + saveTimeInterface.releaseVariableReferences(); } +// A unit busy executing an item of a package being uninstalled can only +// deactivate it; it stays registered in uninstallList until doCleanup() flushes +// it. Such an item is gone as far as the profile is concerned, so no writer that +// walks a root node list may serialize it. The list is empty at any other time. void XMLexport::writeKeyPackage(const Host* pHost, pugi::xml_node& mudletPackage, bool skipModuleMembers) { auto keyPackage = mudletPackage.append_child("KeyPackage"); for (auto it : pHost->mKeyUnit.mKeyRootNodeList) { - if (!it || it->isTemporary() || (skipModuleMembers && it->mModuleMember)) { + if (!it || pHost->mKeyUnit.uninstallList.contains(it) || it->isTemporary() || (skipModuleMembers && it->mModuleMember)) { continue; } writeKey(it, keyPackage); @@ -804,7 +814,7 @@ void XMLexport::writeScriptPackage(const Host* pHost, pugi::xml_node& mudletPack { auto scriptPackage = mudletPackage.append_child("ScriptPackage"); for (auto it : pHost->mScriptUnit.mScriptRootNodeList) { - if (!it || (skipModuleMembers && it->mModuleMember)) { + if (!it || pHost->mScriptUnit.uninstallList.contains(it) || (skipModuleMembers && it->mModuleMember)) { continue; } writeScript(it, scriptPackage); @@ -815,7 +825,7 @@ void XMLexport::writeActionPackage(const Host* pHost, pugi::xml_node& mudletPack { auto actionPackage = mudletPackage.append_child("ActionPackage"); for (auto it : pHost->mActionUnit.mActionRootNodeList) { - if (!it || (skipModuleMembers && it->mModuleMember)) { + if (!it || pHost->mActionUnit.uninstallList.contains(it) || (skipModuleMembers && it->mModuleMember)) { continue; } writeAction(it, actionPackage); @@ -826,7 +836,7 @@ void XMLexport::writeAliasPackage(const Host* pHost, pugi::xml_node& mudletPacka { auto aliasPackage = mudletPackage.append_child("AliasPackage"); for (auto it : pHost->mAliasUnit.mAliasRootNodeList) { - if (!it || (skipModuleMembers && it->mModuleMember)) { + if (!it || pHost->mAliasUnit.uninstallList.contains(it) || (skipModuleMembers && it->mModuleMember)) { continue; } if (!it->isTemporary()) { @@ -839,7 +849,7 @@ void XMLexport::writeTimerPackage(const Host* pHost, pugi::xml_node& mudletPacka { auto timerPackage = mudletPackage.append_child("TimerPackage"); for (auto it : pHost->mTimerUnit.mTimerRootNodeList) { - if (!it || (skipModuleMembers && it->mModuleMember)) { + if (!it || pHost->mTimerUnit.uninstallList.contains(it) || (skipModuleMembers && it->mModuleMember)) { continue; } if (!it->isTemporary()) { @@ -852,7 +862,7 @@ void XMLexport::writeTriggerPackage(const Host* pHost, pugi::xml_node& mudletPac { auto triggerPackage = mudletPackage.append_child("TriggerPackage"); for (auto it : pHost->mTriggerUnit.mTriggerRootNodeList) { - if (!it || (ignoreModuleMembers && it->mModuleMember)) { + if (!it || pHost->mTriggerUnit.uninstallList.contains(it) || (ignoreModuleMembers && it->mModuleMember)) { continue; } if (!it->isTemporary()) { @@ -861,9 +871,17 @@ void XMLexport::writeTriggerPackage(const Host* pHost, pugi::xml_node& mudletPac } } -void XMLexport::writeVariable(TVar* pVar, LuaInterface* pLuaInterface, VarUnit* pVariableUnit, pugi::xml_node xmlParent) +void XMLexport::writeVariable(TVar* pVar, LuaInterface* pLuaInterface, VarUnit* pVariableUnit, pugi::xml_node xmlParent, bool insideSavedTable) { - if (pVariableUnit->isSaved(pVar)) { + // a member of a saved table is saved with it even without its own + // savedVars entry: a missing entry cannot be told apart from a member a + // script added after the table was marked saved, and those must not be + // silently dropped (#9517). The ride-along skips hidden variables + // (Mudlet's internals and ones the user hid) and unsaveable ones + // (functions, references, oversized tables); an explicitly saved + // variable exports as it always has. + const bool exportable = pVariableUnit->isSaved(pVar) || (insideSavedTable && pVariableUnit->shouldSave(pVar) && !pVariableUnit->isHidden(pVar)); + if (exportable) { if (pVar->getValueType() == LUA_TTABLE) { auto variableGroup = xmlParent.append_child("VariableGroup"); @@ -874,7 +892,7 @@ void XMLexport::writeVariable(TVar* pVar, LuaInterface* pLuaInterface, VarUnit* QListIterator<TVar*> itNestedVariable(pVar->getChildren(false)); while (itNestedVariable.hasNext()) { - writeVariable(itNestedVariable.next(), pLuaInterface, pVariableUnit, variableGroup); + writeVariable(itNestedVariable.next(), pLuaInterface, pVariableUnit, variableGroup, true); } } else { auto variable = xmlParent.append_child("Variable"); @@ -1135,6 +1153,8 @@ void XMLexport::writeAction(TAction* pT, pugi::xml_node xmlParent) actionContents.append_child("sizeX").text().set(QString::number(pT->mSizeX).toUtf8().constData()); actionContents.append_child("sizeY").text().set(QString::number(pT->mSizeY).toUtf8().constData()); actionContents.append_child("buttonColumn").text().set(QString::number(pT->mButtonColumns).toUtf8().constData()); + // This will be noted as an unrecognised item in Mudlet versions prior to 4.22.0 + actionContents.append_child("buttonFillerOffset").text().set(QString::number(pT->mButtonFillerOffset).toUtf8().constData()); actionContents.append_child("buttonRotation").text().set(QString::number(pT->mButtonRotation).toUtf8().constData()); } } diff --git a/src/XMLexport.h b/src/XMLexport.h index ef7f39be9..d357325ea 100644 --- a/src/XMLexport.h +++ b/src/XMLexport.h @@ -65,13 +65,15 @@ public: void writeAction(TAction*, pugi::xml_node xmlParent); void writeScript(TScript*, pugi::xml_node xmlParent); void writeKey(TKey*, pugi::xml_node xmlParent); - void writeVariable(TVar*, LuaInterface*, VarUnit*, pugi::xml_node xmlParent); - void writeModuleXML(const QString& moduleName, const QString& fileName, bool async = false); + void writeVariable(TVar*, LuaInterface*, VarUnit*, pugi::xml_node xmlParent, bool insideSavedTable = false); + void writeModuleXML(const QString& moduleName); + std::shared_ptr<pugi::xml_document> takeExportDocument(); + static bool saveXmlDocToFile(const QString& fileName, const pugi::xml_document& doc); bool exportHost(const QString& filename_pugi_xml); bool writeGenericPackage(Host* pHost, pugi::xml_node& mMudletPackage, bool ignoreModuleMember = true, bool ignoreVariables = false); bool exportProfile(const QString& exportFileName); - bool exportPackage(const QString &exportFileName, bool ignoreModuleMember = true, bool ignoreVariables = false); + bool exportPackage(const QString& exportFileName, bool ignoreModuleMember = true, bool ignoreVariables = false); bool exportTrigger(const QString& fileName); bool exportTimer(const QString& fileName); bool exportAlias(const QString& fileName); @@ -110,7 +112,6 @@ private: static inline void replaceAll(std::string& source, const std::string& from, const std::string& to); bool saveXmlFile(QSaveFile& file); bool saveXml(const QString&); - static bool saveXmlDocToFile(const QString& fileName, const pugi::xml_document& doc); pugi::xml_node writeXmlHeader(); static void sanitizeForQxml(std::string& output); void runAsyncSave(const QString& fileName, const QString& xmlSavedKey); diff --git a/src/XMLimport.cpp b/src/XMLimport.cpp index dd052e49c..5e8df4b8f 100644 --- a/src/XMLimport.cpp +++ b/src/XMLimport.cpp @@ -35,6 +35,8 @@ #include "mudlet.h" #include <QBuffer> +#include <QClipboard> +#include <QGuiApplication> #include <QtMath> #include <QVersionNumber> @@ -226,7 +228,7 @@ std::pair<bool, QString> XMLimport::importPackage(QFile* pfile, QString packName std::pair<EditorViewType, int> XMLimport::importFromClipboard() { QString xml; - QClipboard* clipboard = QApplication::clipboard(); + QClipboard* clipboard = QGuiApplication::clipboard(); std::pair<EditorViewType, int> result; xml = clipboard->text(QClipboard::Clipboard); @@ -735,6 +737,7 @@ void XMLimport::readHost(Host* pHost) setBoolAttributeWithDefault(qsl("announceIncomingText"), pHost->mAnnounceIncomingText, true); setBoolAttributeWithDefault(qsl("advertiseScreenReader"), pHost->mAdvertiseScreenReader, false); + setBoolAttributeWithDefault(qsl("enableOSC8Hyperlinks"), pHost->mEnableOSC8Hyperlinks, true); setBoolAttributeWithDefault(qsl("enableClosedCaption"), pHost->mEnableClosedCaption, false); setBoolAttributeWithDefault(qsl("mEnableMTTS"), pHost->mEnableMTTS, true); setBoolAttributeWithDefault(qsl("mEnableMNES"), pHost->mEnableMNES, false); @@ -1136,7 +1139,9 @@ void XMLimport::readHost(Host* pHost) } else if (name() == qsl("commandLineMinimumHeight")) { pHost->commandLineMinimumHeight = readElementText().toInt(); } else if (name() == qsl("wrapAt")) { - pHost->mWrapAt = readElementText().toInt(); + // toInt() yields 0 for anything unparseable, and a profile that + // wraps at zero columns can show no text at all + pHost->mWrapAt = qMax(1, readElementText().toInt()); } else if (name() == qsl("wrapIndentCount")) { pHost->mWrapIndentCount = readElementText().toInt(); } else if (name() == qsl("wrapHangingIndentCount")) { @@ -1608,8 +1613,8 @@ int XMLimport::readAction(TAction* pParent) auto pT = new TAction(pParent, mpHost); pT->setIsFolder(attributes().value(qsl("isFolder")) == YES); - pT->mIsPushDownButton = attributes().value(qsl("isPushButton")) == YES; - pT->mButtonFlat = attributes().value(qsl("isFlatButton")) == YES; + pT->setIsPushDownButton(attributes().value(qsl("isPushButton")) == YES); + pT->setButtonFlat(attributes().value(qsl("isFlatButton")) == YES); pT->mUseCustomLayout = attributes().value(qsl("useCustomLayout")) == YES; mpHost->getActionUnit()->registerAction(pT); pT->setIsActive(attributes().value(qsl("isActive")) == YES); @@ -1626,7 +1631,7 @@ int XMLimport::readAction(TAction* pParent) } if (isStartElement()) { if (name() == qsl("name")) { - pT->mName = readElementText(); + pT->setName(readElementText()); } else if (name() == qsl("packageName")) { pT->mPackageName = readElementText(); } else if (name() == qsl("script")) { @@ -1637,21 +1642,21 @@ int XMLimport::readAction(TAction* pParent) } else if (name() == qsl("css")) { pT->css = readElementText(); } else if (name() == qsl("commandButtonUp")) { - pT->mCommandButtonUp = readElementText(); + pT->setCommandButtonUp(readElementText()); } else if (name() == qsl("commandButtonDown")) { - pT->mCommandButtonDown = readElementText(); + pT->setCommandButtonDown(readElementText()); } else if (name() == qsl("icon")) { - pT->mIcon = readElementText(); + pT->setIcon(readElementText()); } else if (name() == qsl("orientation")) { pT->mOrientation = readElementText().toInt(); } else if (name() == qsl("location")) { pT->mLocation = readElementText().toInt(); } else if (name() == qsl("buttonRotation")) { - pT->mButtonRotation = readElementText().toInt(); + pT->setButtonRotation(readElementText().toInt()); } else if (name() == qsl("sizeX")) { - pT->mSizeX = readElementText().toInt(); + pT->setSizeX(readElementText().toInt()); } else if (name() == qsl("sizeY")) { - pT->mSizeY = readElementText().toInt(); + pT->setSizeY(readElementText().toInt()); } else if (name() == qsl("mButtonState")) { // We now use a boolean but file must use original "1" (false) // or "2" (true) for backward compatibility @@ -1660,7 +1665,10 @@ int XMLimport::readAction(TAction* pParent) // Not longer present/used, skip over it if it is still in file: skipCurrentElement(); } else if (name() == qsl("buttonColumn")) { - pT->mButtonColumns = readElementText().toInt(); + // The above ought to have been plural! + pT->setButtonColumns(readElementText().toInt()); + } else if (name() == qsl("buttonFillerOffset")) { + pT->setButtonFillerOffset(readElementText().toInt()); } else if (name() == qsl("posX")) { pT->mPosX = readElementText().toInt(); } else if (name() == qsl("posY")) { diff --git a/src/XMLimport.h b/src/XMLimport.h index a98de68a1..c2a81d8b5 100644 --- a/src/XMLimport.h +++ b/src/XMLimport.h @@ -27,13 +27,11 @@ #include "dlgTriggerEditor.h" -#include <QApplication> #include <QFile> #include <QMap> #include <QMultiHash> #include <QPointer> #include <QXmlStreamReader> -#include <QClipboard> class Host; class TAction; @@ -100,7 +98,7 @@ private: void readHiddenVariables(); void readStringList(QStringList&, const QString&); - void readIntegerList(QList<int>&, const QString& parentName, const QString &whatIsParent); + void readIntegerList(QList<int>&, const QString& parentName, const QString& whatIsParent); void readModulesDetailsMap(QMap<QString, QStringList>&); void getVersionString(QString&); QString readScriptElement(); @@ -126,7 +124,7 @@ private: bool gotScript = false; int module = 0; int mMaxRoomId = 0; - quint8 mVersionMajor = 1; // 0 to 255 + quint8 mVersionMajor = 1; // 0 to 255 quint16 mVersionMinor = 0; // 0 to 999 for 3 digit decimal value. Cannot be a quint8 as that only allows x.255 for the decimal }; diff --git a/src/ctelnet.cpp b/src/ctelnet.cpp index 263f6b58a..2309ac092 100644 --- a/src/ctelnet.cpp +++ b/src/ctelnet.cpp @@ -61,6 +61,7 @@ #include <QNetworkProxy> #include <QRegularExpression> #include <QSaveFile> +#include <QScopeGuard> #include <QSettings> #include <QSignalBlocker> #include <QSslError> @@ -85,13 +86,6 @@ constexpr size_t BUFFER_SIZE = 100000L; // accumulation buffer without bound across reads. constexpr size_t MAX_TELNET_SUBNEGOTIATION_LENGTH = 5_MB; -// How many times processSocketData() may re-enter itself to drain data left -// over after a decompression pass (compressed input that did not fit in one -// output buffer, or plain data following the compressed stream). Each level -// puts ~100 KB (out_buffer) on the stack, so this also caps decompressed -// output at ~MAX_DECOMPRESSION_RECURSION * BUFFER_SIZE per socket read, which -// bounds a decompression bomb. -constexpr int MAX_DECOMPRESSION_RECURSION = 8; // TODO: https://github.com/Mudlet/Mudlet/issues/5780 (1 of 7) - investigate switching from using `char[]` to `std::array<char>` char loadBuffer[BUFFER_SIZE + 1]; int loadedBytes; @@ -1977,67 +1971,67 @@ QString cTelnet::getNewEnvironOSCColorPalette() QString cTelnet::getNewEnvironOSCHyperlinks() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksSend() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksPrompt() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksStyleBasic() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksStyleStates() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksTooltip() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksMenu() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksCompact() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksPresets() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksVisibility() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksSelection() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksSpoiler() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } QString cTelnet::getNewEnvironOSCHyperlinksDisabled() { - return qsl("1"); + return mpHost->mEnableOSC8Hyperlinks ? qsl("1") : qsl("0"); } bool cTelnet::oscHyperlinkConfigFeatureEnabled() @@ -2134,23 +2128,37 @@ QMap<QString, QPair<bool, QString>> cTelnet::getNewEnvironDataMap() // SEND INFO per https://www.rfc-editor.org/rfc/rfc1572 void cTelnet::sendInfoNewEnvironValue(const QString& var) +{ + sendInfoNewEnvironValues(QStringList{var}); +} + +// RFC 1572 gives INFO the same syntax as IS, so one subnegotiation may carry +// several variables. Preferred when a single preference changes a group of +// them, because the server then sees one consistent change rather than a run of +// partial ones. +void cTelnet::sendInfoNewEnvironValues(const QStringList& vars) { if (!enableNewEnviron || !mpHost->mEnableNEWENVIRON) { return; } - if (mpHost->mEnableMNES && !isMNESVariable(var)) { - return; - } - - if (!newEnvironVariablesSent.contains(var)) { - qDebug() << "We did not update NEW_ENVIRON" << var << "because the server did not request it yet"; - return; - } - const QMap<QString, QPair<bool, QString>> newEnvironDataMap = getNewEnvironDataMap(); - if (newEnvironDataMap.contains(var)) { + std::string payload; + for (const auto& var : vars) { + if (mpHost->mEnableMNES && !isMNESVariable(var)) { + continue; + } + + if (!newEnvironVariablesSent.contains(var)) { + qDebug() << "We did not update NEW_ENVIRON" << var << "because the server did not request it yet"; + continue; + } + + if (!newEnvironDataMap.contains(var)) { + continue; + } + qDebug() << "We updated NEW_ENVIRON" << var; // QPair first: NEW_ENVIRON_USERVAR indicator, second: data @@ -2158,25 +2166,16 @@ void cTelnet::sendInfoNewEnvironValue(const QString& var) const bool isUserVar = !mpHost->mEnableMNES && newEnvironData.first; const QString val = newEnvironData.second; - std::string output; - output += TN_IAC; - output += TN_SB; - output += OPT_NEW_ENVIRON; - output += NEW_ENVIRON_INFO; - output += isUserVar ? NEW_ENVIRON_USERVAR : NEW_ENVIRON_VAR; - output += prepareNewEnvironData(var).toStdString(); - output += NEW_ENVIRON_VAL; + payload += isUserVar ? NEW_ENVIRON_USERVAR : NEW_ENVIRON_VAR; + payload += prepareNewEnvironData(var).toStdString(); + payload += NEW_ENVIRON_VAL; // RFC 1572: If a VALUE is immediately followed by a "type" or IAC, then the // variable is defined, but has no value. if (!val.isEmpty()) { - output += prepareNewEnvironData(val).toStdString(); + payload += prepareNewEnvironData(val).toStdString(); } - output += TN_IAC; - output += TN_SE; - socketOutRaw(output); - if (mpHost->mEnableMNES) { if (!val.isEmpty()) { qDebug() << "WE inform NEW_ENVIRON (MNES) VAR" << var << "VAL" << val; @@ -2195,6 +2194,39 @@ void cTelnet::sendInfoNewEnvironValue(const QString& var) qDebug() << "WE inform NEW_ENVIRON USERVAR" << var << "as an empty VAL"; } } + + // Every candidate was filtered out, so send nothing rather than an empty + // INFO subnegotiation. + if (payload.empty()) { + return; + } + + std::string output; + output += TN_IAC; + output += TN_SB; + output += OPT_NEW_ENVIRON; + output += NEW_ENVIRON_INFO; + output += payload; + output += TN_IAC; + output += TN_SE; + socketOutRaw(output); +} + +void cTelnet::sendInfoNewEnvironOSCHyperlinks() +{ + // Derived from the advertised set rather than a second hand-kept list, so a + // capability added later is announced without touching this - as long as it + // keeps the OSC_HYPERLINKS prefix. + const QMap<QString, QPair<bool, QString>> newEnvironDataMap = getNewEnvironDataMap(); + + QStringList vars; + for (auto it = newEnvironDataMap.cbegin(); it != newEnvironDataMap.cend(); ++it) { + if (it.key().startsWith(qsl("OSC_HYPERLINKS"))) { + vars.append(it.key()); + } + } + + sendInfoNewEnvironValues(vars); } void cTelnet::appendAllNewEnvironValues(std::string& output, const bool isUserVar, const QMap<QString, QPair<bool, QString>>& newEnvironDataMap) @@ -3974,6 +4006,14 @@ void cTelnet::downloadAndInstallGUIPackage(const QString& packageName, const QSt mServerPackage = mudlet::getMudletPath(enums::profileDataItemPath, mProfileName, fileName); mpHost->updateProxySettings(mpDownloader); + // Abort any in-flight predecessor while mpPackageDownloadReply still points + // at it, so it tears down via its own finished() path. Aborting after the + // reassignment below would instead cancel the new reply, and the stale + // reply's progress would otherwise keep driving the replacement dialog. + if (mpPackageDownloadReply) { + mpPackageDownloadReply->abort(); + } + auto request = QNetworkRequest(QUrl(url)); mudlet::self()->setNetworkRequestDefaults(url, request); mpPackageDownloadReply = mpDownloader->get(request); @@ -4335,7 +4375,7 @@ void cTelnet::slot_tlsUpgradeResponse(const bool accepted) } #endif -bool cTelnet::purgeMediaCache() +std::pair<bool, QString> cTelnet::purgeMediaCache() { return mpHost->mpMedia->purgeMediaCache(); } @@ -4583,7 +4623,6 @@ void cTelnet::gotPrompt(std::string& mud_data) while (j < s) { if (mMudData[j] == 'm') { goto NEXT; - break; } ++j; } @@ -4756,6 +4795,10 @@ int cTelnet::decompressBuffer(char*& in_buffer, int& length, char* out_buffer) length = mZstream.avail_in; in_buffer = (char*)mZstream.next_in; + // Drop the borrowed caller-buffer pointers now that inflate() is done with + // them: mZstream is a member, so leaving them set would keep it referencing + // the caller's stack buffer after we return - a dangling pointer. mZstream + // should only reference them for the duration of the inflate() call above. mZstream.next_in = Z_NULL; mZstream.next_out = Z_NULL; @@ -5028,28 +5071,37 @@ void cTelnet::processSocketData(char* in_buffer, int amount, const bool loopback // each level allocates ~100 KB on the stack for out_buffer. Per-connection // (a member, not thread-wide) so one profile's drain - or a re-entrant // feedTelnet() - cannot spend another connection's budget. - if (++mDecompressionRecursionDepth > MAX_DECOMPRESSION_RECURSION) { + // Being a member, a level leaked by an early return would be permanent: + // scmMaxDecompressionRecursion of them and the connection refuses all further + // data, so the count comes off in a guard rather than at each return. + ++mDecompressionRecursionDepth; + const auto recursionGuard = qScopeGuard([this] { + --mDecompressionRecursionDepth; + // A second decrement reinstated on any of the exits below would drive + // the count negative and quietly disable the cap altogether: + Q_ASSERT(mDecompressionRecursionDepth >= 0); + }); + + if (mDecompressionRecursionDepth > scmMaxDecompressionRecursion) { qWarning() << "cTelnet::processSocketData(...) WARNING - recursion depth exceeded, dropping remaining data"; //: Shown when too much data expands out of one compressed read (e.g. a decompression bomb) to process safely. postMessage(tr("[ WARN ] - Too much data to process at once, some may have been lost.")); - --mDecompressionRecursionDepth; return; } // TODO: https://github.com/Mudlet/Mudlet/issues/5780 (3 of 7) - investigate switching from using `char[]` to `std::array<char>` char out_buffer[BUFFER_SIZE + 10]; - in_buffer[amount + 1] = '\0'; - - if (amount == -1) { - --mDecompressionRecursionDepth; - return; - } - - if (amount == 0) { - --mDecompressionRecursionDepth; + // read() reports -1 on error and 0 when nothing was available; loopbackTest() + // narrows a qsizetype into this int, so treat every non-positive value the + // same rather than testing for -1 exactly. Terminating before this point is + // what wrote a NUL outside the caller's buffer - see issue #1065. + if (amount <= 0) { return; } + // Restates the input contract for decompressBuffer() below, which may swap + // `buffer` over to out_buffer before the terminator is written again. + in_buffer[amount] = '\0'; std::string cleandata; // Pre-allocate for worst case: decompressed data can be much larger than input @@ -5301,7 +5353,6 @@ Some data loss is likely - please mention this problem to the game admins.)", // compressed stream). finalize() runs only at the deepest level. if (remainingData && remainingAmount > 0) { processSocketData(remainingData, remainingAmount, loopbackTesting); - --mDecompressionRecursionDepth; return; } @@ -5310,7 +5361,6 @@ Some data loss is likely - please mention this problem to the game admins.)", } mRecordLastChunkMSecTimeOffset = mRecordingChunkTimer.elapsed(); - --mDecompressionRecursionDepth; } void cTelnet::raiseProtocolEvent(const QString& name, const QString& protocol) diff --git a/src/ctelnet.h b/src/ctelnet.h index f5c9a47db..52f9ec852 100644 --- a/src/ctelnet.h +++ b/src/ctelnet.h @@ -36,6 +36,7 @@ #include <QHostAddress> #include <QHostInfo> #include <QPointer> +#include <QScopeGuard> #include <QStringList> #if defined(QT_NO_SSL) #include <QTcpSocket> @@ -51,6 +52,7 @@ #include <iostream> #include <queue> #include <string> +#include <utility> #if defined(Q_OS_WINDOWS) #include <ws2tcpip.h> @@ -186,12 +188,14 @@ public: QMap<QString, QPair<bool, QString>> getNewEnvironDataMap(); bool isMNESVariable(const QString&); void sendInfoNewEnvironValue(const QString&); + void sendInfoNewEnvironValues(const QStringList&); + void sendInfoNewEnvironOSCHyperlinks(); void setATCPVariables(const QByteArray&); void setGMCPVariables(const QByteArray&); void setMSSPVariables(const QByteArray&); void setMSPVariables(const QByteArray&); bool isIPAddress(const QString&); - bool purgeMediaCache(); + std::pair<bool, QString> purgeMediaCache(); void atcpComposerCancel(); void atcpComposerSave(QString); void checkNAWS(); @@ -242,14 +246,23 @@ public: void loopbackTest(QByteArray& data) { ++mLoopbackProcessingDepth; + const auto loopbackGuard = qScopeGuard([this] { + --mLoopbackProcessingDepth; + }); processSocketData(data.data(), data.size(), true); - --mLoopbackProcessingDepth; } int loopbackProcessingDepth() const { return mLoopbackProcessingDepth; } // Each nested processSocketData() puts ~100KB of buffers on the stack, so a // self-feeding feedTelnet() loop overflows a 1MB (Windows) stack in only ~8 // levels - hence a much lower cap than TriggerUnit::scmMaxProcessingDepth. inline static const int scmMaxLoopbackProcessingDepth = 5; + // How many times processSocketData() may re-enter itself to drain data left + // over after a decompression pass (compressed input that did not fit in one + // output buffer, or plain data following the compressed stream). Each level + // puts ~100 KB (out_buffer) on the stack, so this also caps decompressed + // output at ~scmMaxDecompressionRecursion * BUFFER_SIZE per socket read, + // which bounds a decompression bomb. + inline static const int scmMaxDecompressionRecursion = 8; void cancelLoginTimers(); void terminateConnection(); bool currentlySecure() const @@ -321,6 +334,16 @@ signals: private: cTelnet() = default; + // Lets the functional test drive the real download entry point and inspect + // the in-flight reply, reproducing the dialog-swap cancellation cascade. + friend class TelnetTlsPromptTest; + + // Needs to call processSocketData() with a buffer it laid out itself, which + // the public loopbackTest() cannot express - see issue #1065 - and to seed + // mDecompressionRecursionDepth so the over-limit refusal can be reached + // without a real decompression bomb. + friend class cTelnetBufferTest; + #if defined(QT_NO_SSL) void abortLosingSocket(QTcpSocket* losingSocket); #else diff --git a/src/deleteOldProfiles.xml b/src/deleteOldProfiles.xml deleted file mode 100644 index dd1e91639..000000000 --- a/src/deleteOldProfiles.xml +++ /dev/null @@ -1,97 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE MudletPackage> -<MudletPackage version="1.0"> - <TriggerPackage/> - <TimerPackage/> - <AliasPackage> - <Alias isActive="yes" isFolder="no"> - <name>delete old profiles</name> - <script>deleteOldProfiles(matches[3], matches[2]) - ---Syntax examples: "delete old profiles" -> deletes profiles older than 31 days --- "delete old maps 10" -> deletes maps older than 10 days</script> - <command></command> - <packageName></packageName> - <regex>^delete old (profiles|maps|modules)(?: (\d+))?$</regex> - </Alias> - </AliasPackage> - <ActionPackage/> - <ScriptPackage> - <Script isActive="yes" isFolder="no"> - <name>deleteOldProfiles script</name> - <packageName></packageName> - <script>function deleteOldProfiles(keepdays_arg, delete_folder) - --[[ - Deletes old profiles/maps/modules in the "current"/"map"/"moduleBackups" folders of the Mudlet home directory. - The following files are NOT deleted: - - Files newer than the amount of days specified as an argument to deleteOldProfiles(), or 31 days if not specified. - - One file for every month before that. Specifically: The first available file of every month prior to this. - Setting the second argument to true will delete maps instead of profiles. (e.g. deleteOldProfiles(10, true)) - --]] - - -- Ensure correct value is passed for second argument - assert(type(delete_folder) == "string", "Wrong type for delete_folder; expected string, got " .. type(delete_folder)) - assert(table.contains({"profiles", "maps", "modules"}, delete_folder), "delete_folder must be profiles, maps or modules") - - local keepdays = tonumber(keepdays_arg) or 31 - local profile_table = {} - local used_last_mod_months = {} - local slash = (string.char(getMudletHomeDir():byte()) == "/") and "/" or "\\" - local delnum = 0 - - local to_folder = { - profiles = "current", - maps = "map", - } - - local dirpath = delete_folder == "modules" - and getMudletHomeDir()..slash..".."..slash..".."..slash.."moduleBackups" - or getMudletHomeDir()..slash..to_folder[delete_folder] - - -- Traverse the profiles folder and create a table of files: - for filename in lfs.dir(dirpath) do - if filename~="." and filename~=".." then - profile_table[#profile_table+1] = { - name = filename, - last_mod = lfs.attributes(dirpath..slash..filename, "modification") - } - end - end - - -- Sort the table according to last modification date from old to new: - table.sort(profile_table, function (a,b) return a.last_mod < b.last_mod end) - - echo(string.format( - "\nDeleting old %s. Files newer than %d days and one for every month before that will be kept.", - delete_folder, - keepdays - )) - - for i, v in ipairs(profile_table) do - local days = math.floor(os.difftime(os.time(), v.last_mod) / 86400) - local last_mod_month = os.date("%Y/%m", v.last_mod) - if days > keepdays then - -- For profiles older than X days, check if we already kept a table for this month: - if not table.contains(used_last_mod_months, last_mod_month) then - -- If not, do nothing and mark this month as "kept". - used_last_mod_months[#used_last_mod_months+1] = last_mod_month - else - -- Otherwise remove the file: - local success, errorstring = os.remove(dirpath..slash..v.name) - if success then - delnum = delnum + 1 - else - cecho("\n<red>ERROR: "..errorstring) - end - end - end - end - - echo(string.format("\nDeletion complete. %d/%d files were removed successfully.", delnum, #profile_table)) -end -</script> - <eventHandlerList/> - </Script> - </ScriptPackage> - <KeyPackage/> -</MudletPackage> diff --git a/src/discord.cpp b/src/discord.cpp index b1fb4106d..9460c2a86 100644 --- a/src/discord.cpp +++ b/src/discord.cpp @@ -688,55 +688,55 @@ DiscordRichPresence localDiscordPresence::convert() const void localDiscordPresence::setDetailText(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mDetails, sizeof(mDetails), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mDetails, sizeof(mDetails), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setStateText(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mState, sizeof(mState), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mState, sizeof(mState), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setLargeImageText(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mLargeImageText, sizeof(mLargeImageText), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mLargeImageText, sizeof(mLargeImageText), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setLargeImageKey(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mLargeImageKey, sizeof(mLargeImageKey), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mLargeImageKey, sizeof(mLargeImageKey), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setSmallImageText(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mSmallImageText, sizeof(mSmallImageText), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mSmallImageText, sizeof(mSmallImageText), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setSmallImageKey(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mSmallImageKey, sizeof(mSmallImageKey), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mSmallImageKey, sizeof(mSmallImageKey), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setJoinSecret(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mJoinSecret, sizeof(mJoinSecret), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mJoinSecret, sizeof(mJoinSecret), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setMatchSecret(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mMatchSecret, sizeof(mMatchSecret), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mMatchSecret, sizeof(mMatchSecret), utf8Data.constData(), utf8Data.size()); } void localDiscordPresence::setSpectateSecret(const QString& text) { const QByteArray utf8Data = text.toUtf8(); - utils::copyString(mSpectateSecret, sizeof(mSpectateSecret), utf8Data.constData(), utf8Data.size()); + utils::copyUtf8String(mSpectateSecret, sizeof(mSpectateSecret), utf8Data.constData(), utf8Data.size()); } bool Discord::usingMudletsDiscordID(Host* pHost) const diff --git a/src/discord.h b/src/discord.h index 4752b0101..c1662005b 100644 --- a/src/discord.h +++ b/src/discord.h @@ -115,20 +115,30 @@ public: int8_t getInstance() const { return mInstance; } private: - char mState[128]; - char mDetails[128]; + // The limits Discord documents for each field, in bytes (see the struct + // comment above). The buffers are one byte larger than the limit they hold: + // sized at exactly the limit, the null terminator would take the last byte + // and a field of the full documented length would always lose its final + // character. Named in the 'k' form the rest of the codebase gives an array + // size (TArea.cpp's kPixmapDataLineSize) rather than the 'scm' one it gives + // other static class members. + static constexpr size_t kTextByteLimit = 128; + static constexpr size_t kImageKeyByteLimit = 32; + + char mState[kTextByteLimit + 1]; + char mDetails[kTextByteLimit + 1]; int64_t mStartTimestamp = 0; int64_t mEndTimestamp = 0; - char mLargeImageKey[32]; - char mLargeImageText[128]; - char mSmallImageKey[32]; - char mSmallImageText[128]; - char mPartyId[128]; + char mLargeImageKey[kImageKeyByteLimit + 1]; + char mLargeImageText[kTextByteLimit + 1]; + char mSmallImageKey[kImageKeyByteLimit + 1]; + char mSmallImageText[kTextByteLimit + 1]; + char mPartyId[kTextByteLimit + 1]; int mPartySize = 0; int mPartyMax = 0; - char mMatchSecret[128]; - char mJoinSecret[128]; - char mSpectateSecret[128]; + char mMatchSecret[kTextByteLimit + 1]; + char mJoinSecret[kTextByteLimit + 1]; + char mSpectateSecret[kTextByteLimit + 1]; int8_t mInstance = 1; }; diff --git a/src/dlgAboutDialog.cpp b/src/dlgAboutDialog.cpp index 41a899dae..544b43c36 100644 --- a/src/dlgAboutDialog.cpp +++ b/src/dlgAboutDialog.cpp @@ -177,6 +177,16 @@ void dlgAboutDialog::setAboutTab(const QString& htmlHead) const //: about:Leris tr("Does a ton of work in making Mudlet, the website and the wiki accessible to you " "regardless of the language you speak - and promoting our genre!")}); + aboutMakers.append({true, qsl("Piotr Wilczynski"), QString(), qsl("Delwing"), qsl("delwing@gmail.com"), + //: about:Delwing + tr("Joined in 2020, reworking much of the 2D mapper and adding many Lua API features. " + "Outside the client they build Mudlet Web, the documentation extract that powers " + "autocompletion in code editors, and the tools that share Mudlet maps online.")}); + aboutMakers.append({true, qsl("Zooka"), QString(), qsl("ZookaOnGit"), QString(), + //: about:Zooka + tr("Joined in 2023 and works across the whole client - script editor, preferences, package manager " + "and mapper - along with many Lua API additions. Wrote the Mudlet Tutorial profile and " + "maintains the Mudlet package repository.")}); aboutMakers.append({false, qsl("Ahmed Charles"), QString(), qsl("ahmedcharles"), qsl("acharles@outlook.com"), //: about:ahmedcharles tr("Contributions to the Travis integration, CMake and Visual C++ build, " @@ -206,6 +216,10 @@ void dlgAboutDialog::setAboutTab(const QString& htmlHead) const aboutMakers.append({false, qsl("Erik Pettis"), qsl("Etomyutikos#9266"), qsl("Oneymus"), QString(), //: about:Oneymus tr("Developed the Vyzor GUI Manager for Mudlet.")}); + aboutMakers.append({false, qsl("Harrison"), QString(), qsl("Harrison-Teeg"), qsl("harrison.martin@gmail.com"), + //: about:Harrison + tr("Brought the 3D mapper back to life with camera controls, lighting and proper geometry " + "for z-squished rooms, and has fixed a number of console and command line annoyances.")}); aboutMakers.append({false, qsl("ItsTheFae"), qsl("TheFae#9971"), qsl("Kae"), QString(), //: about:TheFae tr("Worked wonders in rejuvenating our Website in 2017 but who prefers a little anonymity - " @@ -221,6 +235,10 @@ void dlgAboutDialog::setAboutTab(const QString& htmlHead) const aboutMakers.append({false, qsl("John Dahlström"), QString(), QString(), qsl("email@johndahlstrom.se"), //: about:John Dahlström tr("Helped develop and debug the Lua API.")}); + aboutMakers.append({false, qsl("John McKisson"), QString(), qsl("jmckisson"), qsl("john.mckisson@gmail.com"), + //: about:John McKisson + tr("Implemented MMCP, so Mudlet can join MudMaster chat networks, and has contributed " + "a range of console and Lua API fixes.")}); aboutMakers.append({false, qsl("Karsten Bock"), QString(), qsl("Beliaar"), QString(), //: about:Beliaar tr("Contributed several improvements and new features for Geyser.")}); @@ -230,6 +248,16 @@ void dlgAboutDialog::setAboutTab(const QString& htmlHead) const aboutMakers.append({false, qsl("Maksym Grinenko"), QString(), QString(), qsl("maksym.grinenko@gmail.com"), //: about:Maksym Grinenko tr("Worked on the manual, forum help and helps with GUI design and documentation.")}); + aboutMakers.append({false, qsl("Manuel Wegmann"), QString(), qsl("Edru2"), QString(), + //: about:Edru2 + tr("Built much of the GUI toolkit you script with between 2020 and 2022: Adjustable Containers, " + "Geyser's ScrollBox, animated labels and Geyser in UserWindows - plus the dark theme toggle " + "and the Package Exporter rework.")}); + aboutMakers.append({false, qsl("Mike Conley"), QString(), qsl("mpconley"), qsl("sousesider@gmail.com"), + //: about:Mike Conley + tr("Joined in 2018 and looks after nearly everything Mudlet plays or negotiates - MCMP media, " + "sound and video, closed captioning, MXP, OSC 8 hyperlinks and text encodings - plus " + "multi-window support with drag-and-drop tabs.")}); aboutMakers.append({false, qsl("Stephen Hansen"), QString(), QString(), qsl("me+mudlet@ixokai.io"), //: about:Stephen Hansen tr("Developed a database Lua API that allows for far easier use of databases and one of the original OSX installers.")}); @@ -237,6 +265,10 @@ void dlgAboutDialog::setAboutTab(const QString& htmlHead) const //: about:Thorsten Wilms tr("Designed our beautiful logo, our splash screen, the about dialog, our website, several icons and badges. " "Visit his homepage at <a href=\"http://thorwil.wordpress.com/\">thorwil.wordpress.com</a>.")}); + aboutMakers.append({false, qsl("Tim Johnson"), QString(), qsl("atari2600tim"), QString(), + //: about:Tim Johnson + tr("Joined in 2020 and made Mudlet work far better with screen readers, alongside secure IRC " + "connections, Discord improvements, and a batch of editor shortcuts and Lua configuration functions.")}); QString aboutMudletBody("<p align=\"center\"><big><b>Credits:</b></big></p>"); QVectorIterator<aboutMaker> iterateMakers(aboutMakers); diff --git a/src/dlgActionMainArea.cpp b/src/dlgActionMainArea.cpp index 39392d7c2..6fe43b32c 100644 --- a/src/dlgActionMainArea.cpp +++ b/src/dlgActionMainArea.cpp @@ -1,7 +1,7 @@ /*************************************************************************** * Copyright (C) 2008-2009 by Heiko Koehn - KoehnHeiko@googlemail.com * * Copyright (C) 2014 by Ahmed Charles - acharles@outlook.com * - * Copyright (C) 2022 by Stephen Lyons - slysven@virginmedia.com * + * Copyright (C) 2022, 2026 by Stephen Lyons - slysven@virginmedia.com * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -30,6 +30,11 @@ dlgActionMainArea::dlgActionMainArea(QWidget* pParentWidget) setupUi(this); connect(lineEdit_action_name, &QLineEdit::editingFinished, this, &dlgActionMainArea::slot_editingNameFinished); + connect(spinBox_action_bar_columns, &QSpinBox::valueChanged, this, &dlgActionMainArea::slot_setMaximumValueForOffset); + connect(comboBox_action_bar_orientation, &QComboBox::currentIndexChanged, this, &dlgActionMainArea::slot_setColumnsOrRowsCountText); + // Hide until we can resurrect icons on menus and buttons: + label_action_icon->hide(); + lineEdit_action_icon->hide(); } void dlgActionMainArea::trimName() @@ -41,3 +46,47 @@ void dlgActionMainArea::slot_editingNameFinished() { trimName(); } + +void dlgActionMainArea::slot_setMaximumValueForOffset(const int value) +{ + // Disable or hide the offset control if required: + if (value > 1) { + spinBox_action_bar_offsetToFirstButton->setMaximum(value - 1); + if (spinBox_action_bar_offsetToFirstButton->value() >= value) { + spinBox_action_bar_offsetToFirstButton->setValue(spinBox_action_bar_offsetToFirstButton->maximum()); + } + spinBox_action_bar_offsetToFirstButton->setEnabled(true); + label_action_bar_offsetToFirstButton->setEnabled(true); + spinBox_action_bar_offsetToFirstButton->setVisible(true); + label_action_bar_offsetToFirstButton->setVisible(true); + } else { + spinBox_action_bar_offsetToFirstButton->setMaximum(0); + spinBox_action_bar_offsetToFirstButton->setValue(0); + if (value == 1) { + spinBox_action_bar_offsetToFirstButton->setEnabled(false); + label_action_bar_offsetToFirstButton->setEnabled(false); + spinBox_action_bar_offsetToFirstButton->setVisible(true); + label_action_bar_offsetToFirstButton->setVisible(true); + } else { + /* A zero value has previously been allowed and it is possible that + * that value was intended to trigger the previous + * "mUseCustomLayout".*/ + spinBox_action_bar_offsetToFirstButton->setEnabled(false); + label_action_bar_offsetToFirstButton->setEnabled(false); + spinBox_action_bar_offsetToFirstButton->setVisible(false); + label_action_bar_offsetToFirstButton->setVisible(false); + } + } +} + +// index: 0 = horizontalm 1 = vertical +void dlgActionMainArea::slot_setColumnsOrRowsCountText(const int index) +{ + if (index > 0) { + //: A toolbar is being set to vertical orientation - so multiple rows of this number of columns + label_action_bar_columns->setText(tr("Number of columns:")); + } else { + //: A toolbar is being set to horizontal orientation - so multiple columns of this number of rows + label_action_bar_columns->setText(tr("Number of rows:")); + } +} diff --git a/src/dlgActionMainArea.h b/src/dlgActionMainArea.h index 320c6ecac..d5d09cefd 100644 --- a/src/dlgActionMainArea.h +++ b/src/dlgActionMainArea.h @@ -4,7 +4,7 @@ /*************************************************************************** * Copyright (C) 2008-2009 by Heiko Koehn - KoehnHeiko@googlemail.com * * Copyright (C) 2014 by Ahmed Charles - acharles@outlook.com * - * Copyright (C) 2022 by Stephen Lyons - slysven@virginmedia.com * + * Copyright (C) 2022, 2026 by Stephen Lyons - slysven@virginmedia.com * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * @@ -40,6 +40,8 @@ public: private slots: void slot_editingNameFinished(); + void slot_setMaximumValueForOffset(const int); + void slot_setColumnsOrRowsCountText(const int); }; #endif // MUDLET_DLGACTIONMAINAREA_H diff --git a/src/dlgConnectionProfiles.cpp b/src/dlgConnectionProfiles.cpp index aba5fb9db..3a64095b9 100644 --- a/src/dlgConnectionProfiles.cpp +++ b/src/dlgConnectionProfiles.cpp @@ -34,21 +34,84 @@ #include "mudlet.h" #include "CredentialManager.h" #include "SecureStringUtils.h" +#include "utils.h" #include <QtConcurrentRun> #include <QtUiTools> +#include <QApplication> #include <QColorDialog> #include <QDir> +#include <QFileInfo> #include <QPointer> #include <QRandomGenerator> #include <QSettings> #include <QSignalBlocker> +#include <QTabBar> #include <QTime> #include <chrono> #include <sstream> using namespace std::chrono_literals; +// Kept to a sub-set of ASCII because the profile name is also used as a +// directory name on all supported OSes; parentheses are included so that +// folders duplicated by a file manager (e.g. "profile (2)") work as-is: +const QString dlgConnectionProfiles::scmAllowedProfileNameChars = qsl(". _()0123456789-#&aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"); + +// Returns the first character not permitted in a (new) profile name, or a +// null QChar if all of them are acceptable. An embedded U+0000 is +// indistinguishable from the all-clear sentinel, but a QLineEdit never lets +// one through: +QChar dlgConnectionProfiles::firstInvalidProfileNameChar(const QString& name) +{ + for (const QChar& c : name) { + if (!scmAllowedProfileNameChars.contains(c)) { + return c; + } + } + return {}; +} + +// Characters that make a name unusable no matter where it came from: +// utils::sanitizeForPath() silently rewrites them out of any path built from +// the profile name, and CredentialManager::generateFilePath() refuses to +// produce a path at all - so a profile named this way could never store or +// retrieve its password. Mirrors the pattern used there: +const QRegularExpression dlgConnectionProfiles::scmUnusableProfileNameChars{qsl(R"REGEX(\.\.|[/\\<>:"|?*\x00-\x1f])REGEX")}; + +// Listing what is expected rather than what to watch out for keeps an +// unrecognised file - a stored password, a character name the user typed - on +// the side of asking. ProfileDeletionSafetyTest fails if the connection form +// comes to write anything this does not name: +const QStringList dlgConnectionProfiles::scmConnectionDetailFiles{qsl("url"), qsl("port"), qsl("ssl_tsl"), qsl("description"), qsl("website"), qsl("autologin"), qsl("autoreconnect")}; + +// A lone "." is made entirely of permitted characters, yet every path built +// from it addresses the profiles directory rather than a profile of its own - +// as does "..", which scmUnusableProfileNameChars already covers: +bool dlgConnectionProfiles::profileNameUsableAsIs(const QString& name) +{ + return !name.isEmpty() && name != qsl(".") && !name.contains(scmUnusableProfileNameChars); +} + +// Resolved textually rather than with QDir::canonicalPath() so that the answer +// does not depend on the folder existing - a predefined game has none until it +// is saved - and so that a symlinked profile folder still resolves. +QString dlgConnectionProfiles::profileFolderPath(const QString& profilesPath, const QString& profile) +{ + // Must precede the parent check below, which QDir::cleanPath() would + // otherwise satisfy by collapsing "../profiles/Foo" straight back in: + if (profile.isEmpty() || profile.contains(QLatin1Char('/')) || profile.contains(QLatin1Char('\\'))) { + return {}; + } + + const QString profilesDir = QDir::cleanPath(profilesPath); + const QString candidate = QDir::cleanPath(qsl("%1/%2").arg(profilesDir, profile)); + if (candidate == profilesDir || QFileInfo(candidate).path() != profilesDir) { + return {}; + } + return candidate; +} + dlgConnectionProfiles::dlgConnectionProfiles(QWidget* parent) : QDialog(parent) { @@ -89,6 +152,46 @@ dlgConnectionProfiles::dlgConnectionProfiles(QWidget* parent) listWidget_profiles->setContextMenuPolicy(Qt::CustomContextMenu); connect(listWidget_profiles, &QWidget::customContextMenuRequested, this, &dlgConnectionProfiles::slot_profileContextMenu); + mpTabBar = new QTabBar(this); + // QTabWidget gives this dialog a second QTabBar, so this one needs a name + mpTabBar->setObjectName(qsl("gamesTabBar")); + //: Tab showing only the games the user already has profiles for + mpTabBar->insertTab(scmMyGamesTab, tr("My games")); + //: Tab showing every game Mudlet has a built-in profile for + mpTabBar->insertTab(scmAllGamesTab, tr("All games")); + mpTabBar->setExpanding(false); + mpTabBar->setAccessibleName(tr("games shown")); + mpTabBar->setAccessibleDescription(tr("Switch between showing only your own games and all of the games Mudlet knows about.")); + verticalLayout_gamesList->insertWidget(0, mpTabBar); + setTabOrder(mpTabBar, listWidget_profiles); + + if (!mudlet::self()->mOnlyShownPredefinedProfiles.isEmpty()) { + // dedicated single-game builds only ever show their own game(s), so + // there is nothing to switch between + mpTabBar->hide(); + } else { + auto& settings = *mudlet::self()->mpSettings; + int initialTab = scmMyGamesTab; + if (settings.contains(qsl("connectionDialogActiveTab"))) { + initialTab = settings.value(qsl("connectionDialogActiveTab")).toInt() == scmAllGamesTab ? scmAllGamesTab : scmMyGamesTab; + } else if (settings.value(qsl("showOnlyMyProfiles"), false).toBool()) { + // migrate the retired "Show my profiles only" context menu filter, + // which the "My games" tab replaces + initialTab = scmMyGamesTab; + settings.setValue(qsl("connectionDialogActiveTab"), initialTab); + } else if (QDir(mudlet::getMudletPath(enums::profilesPath)).entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty()) { + // a newcomer has no profiles yet, so show them the catalog + initialTab = scmAllGamesTab; + } + // the retired filter's setting is dropped even when it was false, so it + // cannot resurface should connectionDialogActiveTab ever go missing + settings.remove(qsl("showOnlyMyProfiles")); + mpTabBar->setCurrentIndex(initialTab); + } + // connected only after the initial tab is set, so that setting it is not + // mistaken for the user switching tabs + connect(mpTabBar, &QTabBar::currentChanged, this, &dlgConnectionProfiles::slot_activeTabChanged); + QAbstractButton* abort = dialog_buttonbox->button(QDialogButtonBox::Cancel); connect_button = dialog_buttonbox->addButton(tr("Connect"), QDialogButtonBox::AcceptRole); connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); @@ -316,6 +419,11 @@ dlgConnectionProfiles::dlgConnectionProfiles(QWidget* parent) dlgConnectionProfiles::~dlgConnectionProfiles() { + // ~QDialog hides the dialog once this destructor is done, and the profile + // name field reacts to losing the focus by emitting editingFinished() into + // slot_saveName() when this object is no longer a valid receiver (#9574) + utils::disconnectChildSignals(this); + if (mPasswordSaveTimer) { mPasswordSaveTimer->stop(); } @@ -337,6 +445,11 @@ dlgConnectionProfiles::~dlgConnectionProfiles() void dlgConnectionProfiles::dismissTutorialInvitation() { mTutorialDismissed = true; + if (!widget_topLeft->isHidden()) { + // the invitation is not up, so there is nothing to restore - and the + // resize below would make the dialog jump in size for no reason + return; + } widget_topLeft->show(); welcome_message->hide(); tabWidget_connectionInfo->show(); @@ -823,6 +936,33 @@ void dlgConnectionProfiles::continueProfileSave(QListWidgetItem* pItem, const QS } } +bool dlgConnectionProfiles::showingOnlyMyProfiles() const +{ + // the tab bar is hidden for dedicated single-game builds, which list their + // own game(s) unfiltered + return !mpTabBar->isHidden() && mpTabBar->currentIndex() == scmMyGamesTab; +} + +void dlgConnectionProfiles::slot_activeTabChanged(const int index) +{ + mudlet::self()->mpSettings->setValue(qsl("connectionDialogActiveTab"), index); + + const auto* pCurrentItem = listWidget_profiles->currentItem(); + const QString previousSelection = pCurrentItem ? pCurrentItem->data(csmNameRole).toString() : QString(); + + fillout_form(); + + if (previousSelection.isEmpty()) { + return; + } + // keep the same game selected if the newly shown tab also lists it, + // otherwise the automatic selection made by fillout_form() stands + const auto pPreviousItems = findData(*listWidget_profiles, previousSelection, csmNameRole); + if (!pPreviousItems.isEmpty()) { + listWidget_profiles->setCurrentItem(pPreviousItems.first()); + } +} + // On a fresh install with no saved profiles the dialog shows the welcome // message in place of the connection details; swap them back in and undo the // shrink that was applied to fit the welcome message. @@ -859,15 +999,16 @@ void dlgConnectionProfiles::slot_addProfile() return; } setItemName(pItem, newname); + // without an icon the item is an invisible blank in the list + pItem->setIcon(customIcon(newname, std::nullopt)); - listWidget_profiles->addItem(pItem); - - // insert newest entry on top of the list as the general sorting - // is always newest item first -> fillout->form() filters - // this is more practical for the user as they use the same profile most of the time + // insert the new entry at the top of the list - appending would bury it + // at the bottom, below all the predefined games + listWidget_profiles->insertItem(0, pItem); // As we are using QAbstractItemView::SingleSelection this will - // automatically unselect the previous item: + // automatically unselect the previous item, and auto-scroll brings the + // new item into view: listWidget_profiles->setCurrentItem(pItem); profile_name_entry->setText(newname); @@ -886,29 +1027,40 @@ void dlgConnectionProfiles::slot_addProfile() connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); } -// enables the deletion button once the correct text (profile name) is entered -void dlgConnectionProfiles::slot_deleteProfileCheck(const QString& text) +void dlgConnectionProfiles::showRemovalProblem(const QString& message) { - const QString profile = listWidget_profiles->currentItem()->data(csmNameRole).toString(); - if (profile != text) { - delete_button->setEnabled(false); - } else { - delete_button->setEnabled(true); - delete_button->setFocus(); - } -} - -// actually performs the deletion once the correct text has been entered -void dlgConnectionProfiles::slot_reallyDeleteProfile() -{ - const QString profile = listWidget_profiles->currentItem()->data(csmNameRole).toString(); - reallyDeleteProfile(profile); + notificationArea->show(); + notificationAreaIconLabelWarning->show(); + notificationAreaIconLabelError->hide(); + notificationAreaIconLabelInformation->hide(); + notificationAreaMessageBox->show(); + notificationAreaMessageBox->setText(message); } void dlgConnectionProfiles::reallyDeleteProfile(const QString& profile) { - QDir dir(mudlet::getMudletPath(enums::profileHomePath, profile)); - dir.removeRecursively(); + const QString profilesPath = mudlet::getMudletPath(enums::profilesPath); + const QString profileFolder = profileFolderPath(profilesPath, profile); + if (profileFolder.isEmpty()) { + qWarning().nospace() << "dlgConnectionProfiles::reallyDeleteProfile(\"" << profile << "\") ERROR - refusing to delete: that name does not address a folder inside \"" << profilesPath << "\"."; + // rebuild the list first: it re-selects a profile, and that clears the + // notification area on its way through validateProfile() + fillout_form(); + //: %1 is a profile name that does not name a folder of its own, so there is nothing that could be removed for it + showRemovalProblem(tr("'%1' has no profile folder of its own, so there is nothing to remove.").arg(profile)); + return; + } + + QDir dir(profileFolder); + if (!dir.removeRecursively()) { + // the profile is still on disk, so its password and its list entry stay: + // removing either would strand the data that is left + qWarning().nospace() << "dlgConnectionProfiles::reallyDeleteProfile(\"" << profile << "\") ERROR - could not completely remove \"" << profileFolder << "\"."; + fillout_form(); + //: %1 is a profile name. Shown when some of the profile's files could not be deleted, e.g. because another program has them open + showRemovalProblem(tr("Could not remove everything belonging to '%1'. Close it if it is open elsewhere, check that you may write to its folder, and try again.").arg(profile)); + return; + } // Clean up keychain entries for the deleted profile // Note: CredentialManager only supports one operation at a time, so we must @@ -945,7 +1097,9 @@ void dlgConnectionProfiles::reallyDeleteProfile(const QString& profile) }); } - // record the deleted default profile so it does not get re-created in the future + // record the deletion; the games catalog deliberately ignores this list + // now - only the self-test entry in fillout_form() still honours it, and + // continueProfileSave() clears the entry on profile re-creation auto& settings = *mudlet::self()->mpSettings; auto deletedDefaultMuds = settings.value(qsl("deletedDefaultMuds"), QStringList()).toStringList(); if (!deletedDefaultMuds.contains(profile)) { @@ -972,9 +1126,17 @@ void dlgConnectionProfiles::slot_deleteProfile() return; } - const QDir profileDirContents(mudlet::getMudletPath(enums::profileXmlFilesPath, profile)); - if (!profileDirContents.exists() || profileDirContents.isEmpty()) { - // shortcut - don't show profile deletion confirmation if there is no data to delete + const QDir profileDir(mudlet::getMudletPath(enums::profileHomePath, profile)); + bool nothingToLose = !profileDir.exists() || profileDir.entryList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot).isEmpty(); + if (nothingToLose) { + for (const QString& fileName : profileDir.entryList(QDir::Files | QDir::Hidden)) { + if (!scmConnectionDetailFiles.contains(fileName)) { + nothingToLose = false; + break; + } + } + } + if (nothingToLose) { reallyDeleteProfile(profile); return; } @@ -991,23 +1153,38 @@ void dlgConnectionProfiles::slot_deleteProfile() file.close(); if (!delete_profile_dialog) { + qWarning() << "dlgConnectionProfiles::slot_deleteProfile() ERROR - the deletion confirmation did not load as a dialog."; + //: %1 is a profile name. Shown when the dialog asking the user to confirm a removal could not be built + showRemovalProblem(tr("Could not open the confirmation, so '%1' has not been removed.").arg(profile)); return; } - delete_profile_lineedit = delete_profile_dialog->findChild<QLineEdit*>(qsl("delete_profile_lineedit")); - delete_button = delete_profile_dialog->findChild<QPushButton*>(qsl("delete_button")); - auto* cancel_button = delete_profile_dialog->findChild<QPushButton*>(qsl("cancel_button")); + auto* nameEntry = delete_profile_dialog->findChild<QLineEdit*>(qsl("delete_profile_lineedit")); + auto* deleteButton = delete_profile_dialog->findChild<QPushButton*>(qsl("delete_button")); + auto* cancelButton = delete_profile_dialog->findChild<QPushButton*>(qsl("cancel_button")); - if (!delete_profile_lineedit || !delete_button || !cancel_button) { + if (!nameEntry || !deleteButton || !cancelButton) { + qWarning() << "dlgConnectionProfiles::slot_deleteProfile() ERROR - the deletion confirmation is missing one of its widgets."; + showRemovalProblem(tr("Could not open the confirmation, so '%1' has not been removed.").arg(profile)); + delete delete_profile_dialog; return; } - connect(delete_profile_lineedit, &QLineEdit::textChanged, this, &dlgConnectionProfiles::slot_deleteProfileCheck); - connect(delete_profile_dialog, &QDialog::accepted, this, &dlgConnectionProfiles::slot_reallyDeleteProfile); + // The confirmation is not modal, so by the time it is answered the selection + // may have moved on, or a second confirmation may be open alongside it: + connect(nameEntry, &QLineEdit::textChanged, delete_profile_dialog, [deleteButton, profile](const QString& text) { + deleteButton->setEnabled(text == profile); + if (deleteButton->isEnabled()) { + deleteButton->setFocus(); + } + }); + connect(delete_profile_dialog, &QDialog::accepted, this, [this, profile]() { + reallyDeleteProfile(profile); + }); - delete_profile_lineedit->setPlaceholderText(profile); - delete_profile_lineedit->setFocus(); - delete_button->setEnabled(false); + nameEntry->setPlaceholderText(profile); + nameEntry->setFocus(); + deleteButton->setEnabled(false); delete_profile_dialog->setWindowTitle(tr("Deleting '%1'").arg(profile)); delete_profile_dialog->setAttribute(Qt::WA_DeleteOnClose); @@ -1032,6 +1209,9 @@ QString dlgConnectionProfiles::readProfileData(const QString& profile, const QSt return ret; } +// A new item here may need adding to scmConnectionDetailFiles above. Unlike +// mudlet::writeProfileData() this does not create the profile's folder, so a +// write before there is one is quietly dropped. QPair<bool, QString> dlgConnectionProfiles::writeProfileData(const QString& profile, const QString& item, const QString& what) { QSaveFile file(mudlet::getMudletPath(enums::profileDataItemPath, profile, item)); @@ -1191,7 +1371,9 @@ void dlgConnectionProfiles::slot_itemClicked(QListWidgetItem* pItem) QDir dir(mudlet::getMudletPath(enums::profileXmlFilesPath, profile_name)); dir.setSorting(QDir::Time); - const QStringList entries = dir.entryList(QDir::Files | QDir::NoDotAndDotDot, QDir::Time); + // Only offer real profile saves (*.xml) as history entries; leftover QSaveFile + // temporaries from an interrupted save (e.g. "....xml.AbCdEf") must not be loadable + const QStringList entries = dir.entryList(QStringList{qsl("*.xml")}, QDir::Files | QDir::NoDotAndDotDot, QDir::Time); for (const auto& entry : entries) { const QRegularExpression rx(qsl("(\\d+)\\-(\\d+)\\-(\\d+)#(\\d+)\\-(\\d+)\\-(\\d+).xml")); @@ -1299,9 +1481,11 @@ void dlgConnectionProfiles::fillout_form() if (!mDialogHeightBeforeShrink || welcome_message->isHidden()) { mDialogHeightBeforeShrink = height(); } - welcome_message->show(); + // hide before show: with both visible for a moment the layout grows + // the dialog to fit them together and it never shrinks back tabWidget_connectionInfo->hide(); informationArea->hide(); + welcome_message->show(); } else { welcome_message->hide(); @@ -1313,35 +1497,45 @@ void dlgConnectionProfiles::fillout_form() QString description; QListWidgetItem* pItem; - auto& settings = *mudlet::self()->mpSettings; - auto deletedDefaultMuds = settings.value(qsl("deletedDefaultMuds"), QStringList()).toStringList(); const QStringList& onlyShownPredefinedProfiles{mudlet::self()->mOnlyShownPredefinedProfiles}; - const bool showOnlyMyProfiles = settings.value(qsl("showOnlyMyProfiles"), false).toBool(); + const bool showOnlyMyProfiles = showingOnlyMyProfiles(); + const QString selfTestProfile = qsl("Mudlet self-test"); + const auto deletedDefaultMuds = mudlet::self()->mpSettings->value(qsl("deletedDefaultMuds"), QStringList()).toStringList(); if (onlyShownPredefinedProfiles.isEmpty()) { const auto defaultGames = TGameDetails::keys(); + // "My games" only lists games with profile data on disk; "All games" + // must keep offering every pre-installed game, even ones whose + // profile was deleted (recorded in deletedDefaultMuds). The self-test + // entry is the exception: it is a testing aid rather than a game, and + // is offered even without profile data on disk, so dismissing it has + // to keep it out of both tabs for (auto& game : defaultGames) { - if (!deletedDefaultMuds.contains(game)) { - if (showOnlyMyProfiles && !mProfileList.contains(game, Qt::CaseInsensitive)) { - continue; - } - pItem = new QListWidgetItem(); - auto details = TGameDetails::findGame(game); - setupMudProfile(pItem, game, (*details).description, (*details).icon); + if (game == selfTestProfile && deletedDefaultMuds.contains(game)) { + continue; } + if (showOnlyMyProfiles && !mProfileList.contains(game, Qt::CaseInsensitive)) { + continue; + } + pItem = new QListWidgetItem(); + auto details = TGameDetails::findGame(game); + setupMudProfile(pItem, game, (*details).description, (*details).icon); } #if defined(QT_DEBUG) - const QString mudServer = qsl("Mudlet self-test"); - if (!deletedDefaultMuds.contains(mudServer) && !mProfileList.contains(mudServer)) { - mProfileList.append(mudServer); - pItem = new QListWidgetItem(); - // Can't use setupMudProfile(...) here as we do not set the icon in the same way: - setItemName(pItem, mudServer); + if (!deletedDefaultMuds.contains(selfTestProfile) && !mProfileList.contains(selfTestProfile)) { + mProfileList.append(selfTestProfile); + // "All games" already listed it from TGameDetails above, only + // "My games" is still missing an entry: + if (findData(*listWidget_profiles, selfTestProfile, csmNameRole).isEmpty()) { + pItem = new QListWidgetItem(); + // Can't use setupMudProfile(...) here as we do not set the icon in the same way: + setItemName(pItem, selfTestProfile); - listWidget_profiles->addItem(pItem); - description = getDescription(qsl("mudlet.org")); - if (!description.isEmpty()) { - pItem->setToolTip(utils::richText(description)); + listWidget_profiles->addItem(pItem); + description = getDescription(qsl("mudlet.org")); + if (!description.isEmpty()) { + pItem->setToolTip(utils::richText(description)); + } } } #endif @@ -1394,6 +1588,12 @@ void dlgConnectionProfiles::fillout_form() break; } } + if (listWidget_profiles->count() == 1 && test_profile_row != 0) { + // The "My games" tab can show a single profile that has not been + // saved to its XML yet, so select it to fill in its details + // instead of leaving the form blank with a game highlighted + toselectRow = 0; + } } else if (predefined_profile_row >= 0) { // If the user is starting one of a MUD's "dedicated" Mudlet versions then // select the first of THAT/THOSE predefined one(s) on first launch: @@ -1538,10 +1738,37 @@ void dlgConnectionProfiles::generateCustomProfile(const QString& profileName) co listWidget_profiles->addItem(pItem); } +// fillout_form() destroys and rebuilds every item, so callers that have let the +// event loop run cannot hold on to one. +void dlgConnectionProfiles::setIconOfListedProfile(const QString& profileName, const QIcon& icon) const +{ + const auto pItems = findData(*listWidget_profiles, profileName, csmNameRole); + if (pItems.isEmpty()) { + return; + } + pItems.first()->setIcon(icon); +} + +// Empty when nothing is selected. The context-menu actions re-check rather than +// trust slot_profileContextMenu(): menu.exec() runs a nested event loop, and the +// profile-copy completion handler calls fillout_form() from it, which can clear +// the selection - or leave a different profile current, in which case the action +// still mis-targets. Only the crash of acting on nothing is handled here. +QString dlgConnectionProfiles::selectedProfileName() const +{ + const auto* pItem = listWidget_profiles->currentItem(); + return pItem ? pItem->data(csmNameRole).toString() : QString(); +} + void dlgConnectionProfiles::slot_profileContextMenu(QPoint pos) { + // "My games" on a fresh install lists nothing, so nothing is current + const auto profileName = selectedProfileName(); + if (profileName.isEmpty()) { + return; + } + const QPoint globalPos = listWidget_profiles->mapToGlobal(pos); - auto profileName = listWidget_profiles->currentItem()->data(csmNameRole).toString(); QMenu menu; if (hasCustomIcon(profileName)) { @@ -1560,26 +1787,15 @@ void dlgConnectionProfiles::slot_profileContextMenu(QPoint pos) &dlgConnectionProfiles::slot_setCustomColor); } - menu.addSeparator(); - - auto& settings = *mudlet::self()->mpSettings; - const bool showOnlyMyProfiles = settings.value(qsl("showOnlyMyProfiles"), false).toBool(); - //: Context menu action to toggle hiding default game profiles that have not been used yet - auto* pAction_showMyProfilesOnly = menu.addAction(tr("Show my profiles only")); - pAction_showMyProfilesOnly->setCheckable(true); - pAction_showMyProfilesOnly->setChecked(showOnlyMyProfiles); - connect(pAction_showMyProfilesOnly, &QAction::toggled, this, [this](const bool checked) { - auto& settings = *mudlet::self()->mpSettings; - settings.setValue(qsl("showOnlyMyProfiles"), checked); - fillout_form(); - }); - menu.exec(globalPos); } void dlgConnectionProfiles::slot_setCustomIcon() { - auto profileName = listWidget_profiles->currentItem()->data(csmNameRole).toString(); + const auto profileName = selectedProfileName(); + if (profileName.isEmpty()) { + return; + } QSettings& settings = *mudlet::getQSettings(); QString lastDir = settings.value("lastFileDialogLocation", QDir::homePath()).toString(); @@ -1598,11 +1814,15 @@ void dlgConnectionProfiles::slot_setCustomIcon() } auto icon = QIcon(QPixmap(imageLocation).scaled(QSize(120, 30), Qt::IgnoreAspectRatio, Qt::SmoothTransformation).copy()); - listWidget_profiles->currentItem()->setIcon(icon); + // the file dialog ran a nested event loop, so the current item may have moved + setIconOfListedProfile(profileName, icon); } void dlgConnectionProfiles::slot_setCustomColor() { - auto profileName = listWidget_profiles->currentItem()->data(csmNameRole).toString(); + const auto profileName = selectedProfileName(); + if (profileName.isEmpty()) { + return; + } QColor color = QColorDialog::getColor(getCustomColor(profileName).value_or(QColor(255, 255, 255))); if (color.isValid()) { auto profileColorPath = mudlet::getMudletPath(enums::profileDataItemPath, profileName, qsl("profilecolor")); @@ -1616,12 +1836,15 @@ void dlgConnectionProfiles::slot_setCustomColor() if (!file.commit()) { qDebug() << "dlgConnectionProfiles::slot_setCustomColor: error saving custom icon color: " << file.errorString(); } - listWidget_profiles->currentItem()->setIcon(customIcon(profileName, {color})); + setIconOfListedProfile(profileName, customIcon(profileName, {color})); } } void dlgConnectionProfiles::slot_resetCustomIcon() { - auto profileName = listWidget_profiles->currentItem()->data(csmNameRole).toString(); + const auto profileName = selectedProfileName(); + if (profileName.isEmpty()) { + return; + } const bool success = mudlet::self()->resetProfileIcon(profileName).first; if (!success) { @@ -1668,10 +1891,31 @@ void dlgConnectionProfiles::slot_copyProfile() mpCopyProfile->setText(tr("Copying...")); mpCopyProfile->setEnabled(false); auto future = QtConcurrent::run(dlgConnectionProfiles::copyFolder, mudlet::getMudletPath(enums::profileHomePath, oldname), mudlet::getMudletPath(enums::profileHomePath, profile_name)); - auto watcher = new QFutureWatcher<bool>; - connect(watcher, &QFutureWatcher<bool>::finished, this, [=, this]() { - mProfileList << profile_name; - slot_itemClicked(pItem); + auto watcher = new QFutureWatcher<bool>(this); + connect(watcher, &QFutureWatcher<bool>::finished, this, [this, profile_name, oldPassword, watcher]() { + if (!mProfileList.contains(profile_name)) { + mProfileList << profile_name; + } + + // The dialog stays usable while the copy runs, and switching the games + // tab calls fillout_form(), which destroys every item - including the + // one made for this copy. Hence look it up by name rather than hold it. + auto pCopiedItems = findData(*listWidget_profiles, profile_name, csmNameRole); + if (pCopiedItems.isEmpty()) { + // that rebuild scanned the profiles directory before the copy + // landed in it + fillout_form(); + pCopiedItems = findData(*listWidget_profiles, profile_name, csmNameRole); + } + if (!pCopiedItems.isEmpty()) { + auto* pCopiedItem = pCopiedItems.first(); + if (listWidget_profiles->currentItem() == pCopiedItem) { + slot_itemClicked(pCopiedItem); + } else { + // reaches slot_itemClicked() through currentItemChanged + listWidget_profiles->setCurrentItem(pCopiedItem); + } + } // restore the password, which won't be copied by the disk copy if stored in the credential manager // Temporarily block textChanged signal to avoid triggering save on programmatic setText @@ -1695,12 +1939,7 @@ void dlgConnectionProfiles::slot_copyProfile() dlgConnectionProfiles::CopiedProfileData dlgConnectionProfiles::captureProfileData() const { - return {host_name_entry->text(), - port_entry->text(), - port_ssl_tsl->isChecked() ? Qt::Checked : Qt::Unchecked, - login_entry->text(), - website_entry->text(), - mud_description_textedit->toPlainText()}; + return {host_name_entry->text(), port_entry->text(), port_ssl_tsl->isChecked() ? Qt::Checked : Qt::Unchecked, login_entry->text(), website_entry->text(), mud_description_textedit->toPlainText()}; } // Copying a default profile (one of the predefined games) has nothing to copy @@ -1861,7 +2100,9 @@ void dlgConnectionProfiles::copyProfileSettingsOnly(const QString& oldname, cons const QDir oldProfiledir(mudlet::getMudletPath(enums::profileXmlFilesPath, oldname)); const QDir newProfiledir(mudlet::getMudletPath(enums::profileXmlFilesPath, newname)); newProfiledir.mkpath(newProfiledir.absolutePath()); - QStringList entries = oldProfiledir.entryList(QDir::Files | QDir::NoDotAndDotDot, QDir::Time); + // Only copy from a real profile save (*.xml): the newest file of any name could + // be a leftover QSaveFile temporary from an interrupted save (e.g. "....xml.AbCdEf") + QStringList entries = oldProfiledir.entryList(QStringList{qsl("*.xml")}, QDir::Files | QDir::NoDotAndDotDot, QDir::Time); if (entries.empty()) { return; } @@ -2030,19 +2271,41 @@ bool dlgConnectionProfiles::validateProfile() if (pItem) { QString name = profile_name_entry->text().trimmed(); - const QString allowedChars = qsl(". _0123456789-#&aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"); - for (int i = 0; i < name.size(); ++i) { - if (!allowedChars.contains(name.at(i))) { - notificationAreaIconLabelWarning->show(); - notificationAreaMessageBox->setText( - qsl("%1\n%2\n%3\n").arg(notificationAreaMessageBox->text(), tr("The %1 character is not permitted. Use one of the following:").arg(name.at(i)), allowedChars)); - name.replace(name.at(i--), QString()); - profile_name_entry->setText(name); - validName = false; - valid = false; - break; - } + // Only check the characters of a new or edited name: a profile folder + // already on disk may have been created outside of Mudlet (e.g. by a + // file manager copying a folder) with characters we would not permit + // for a new name - such a profile must still be loadable. Comparing + // against the trimmed item name covers folders with leading/trailing + // whitespace too, as the entered name always arrives trimmed. Names + // the rest of Mudlet cannot work with get no exemption: renaming them + // is worse for the user than a profile whose password never saves. + // "." and ".." name something that exists without being a profile, so + // the exemption needs a folder that is genuinely the profile's own. + const QString selectedName = pItem->data(csmNameRole).toString(); + const QString selectedFolder = profileFolderPath(mudlet::getMudletPath(enums::profilesPath), selectedName); + const bool nameIsFolderOnDisk = (name == selectedName.trimmed()) && !selectedFolder.isEmpty() && QDir(selectedFolder).exists(); + const bool nameUnchangedAndOnDisk = nameIsFolderOnDisk && profileNameUsableAsIs(name); + const QChar invalidChar = nameUnchangedAndOnDisk ? QChar() : firstInvalidProfileNameChar(name); + if (!invalidChar.isNull()) { + notificationAreaIconLabelWarning->show(); + notificationAreaMessageBox->setText( + qsl("%1\n%2\n%3\n").arg(notificationAreaMessageBox->text(), tr("The %1 character is not permitted. Use one of the following:").arg(invalidChar), scmAllowedProfileNameChars)); + name.remove(invalidChar); + profile_name_entry->setText(name); + validName = false; + valid = false; + } else if (!nameIsFolderOnDisk && !name.isEmpty() && !profileNameUsableAsIs(name)) { + // Nothing to strip here, unlike the branch above: the characters + // are all permitted, it is the whole name that cannot be a folder + notificationAreaIconLabelWarning->show(); + notificationAreaMessageBox->setText( + qsl("%1\n%2\n") + .arg(notificationAreaMessageBox->text(), + //: Shown when a profile name would not name a folder of its own. Keep the quoted dots as they are, they are literal characters the user typed + tr("A profile name cannot be \".\" or contain \"..\", as those refer to other folders on your computer. Please pick a different name."))); + validName = false; + valid = false; } // see if there is an edit that already uses a similar name @@ -2346,11 +2609,8 @@ bool dlgConnectionProfiles::eventFilter(QObject* obj, QEvent* event) if (obj == listWidget_profiles && event->type() == QEvent::KeyPress) { QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event); switch (keyEvent->key()) { - // Process all the keys that could be used in a profile name - // fortunately we limit this to a sub-set of ASCII because we also use - // it for a directory name - based on "allowedChars" list in - // validateProfile() i.e.: - // ". _0123456789-#&aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ" + // Process all the keys that could be used in a profile name, + // i.e. the "scmAllowedProfileNameChars" list default: // For other keys handle them as normal: return QObject::eventFilter(obj, event); @@ -2378,6 +2638,8 @@ bool dlgConnectionProfiles::eventFilter(QObject* obj, QEvent* event) case Qt::Key_Minus: case Qt::Key_NumberSign: case Qt::Key_Ampersand: + case Qt::Key_ParenLeft: + case Qt::Key_ParenRight: case Qt::Key_A: case Qt::Key_B: case Qt::Key_C: diff --git a/src/dlgConnectionProfiles.h b/src/dlgConnectionProfiles.h index 5896d7e6d..affcd9f60 100644 --- a/src/dlgConnectionProfiles.h +++ b/src/dlgConnectionProfiles.h @@ -25,10 +25,12 @@ #include "ui_connection_profiles.h" #include <optional> +#include <QRegularExpression> #include <QTimer> #include <QKeyEvent> class QDir; +class QTabBar; namespace pugi { class xml_document; @@ -50,6 +52,13 @@ public: QList<QListWidgetItem*> findData(const QListWidget& listWidget, const QVariant& what, const int role = Qt::UserRole) const; QList<int> findProfilesBeginningWith(const QString&) const; static const int csmNameRole{Qt::UserRole}; + static QChar firstInvalidProfileNameChar(const QString& name); + static bool profileNameUsableAsIs(const QString& name); + static QString profileFolderPath(const QString& profilesPath, const QString& profile); + static const QString scmAllowedProfileNameChars; + static const QRegularExpression scmUnusableProfileNameChars; + // files whose presence alone does not warrant confirming a profile's removal + static const QStringList scmConnectionDetailFiles; QString btn_connect_enabled_accessDesc; QString btn_load_enabled_accessDesc; @@ -69,13 +78,11 @@ public slots: void slot_updateLogin(const QString&); void slot_updatePassword(const QString&); // Not used: void slot_updateWebsite(const QString&); - void slot_deleteProfileCheck(const QString&); void slot_updateDescription(); void slot_itemClicked(QListWidgetItem*); void slot_addProfile(); void slot_deleteProfile(); - void slot_reallyDeleteProfile(); void slot_updateAutoConnect(int state); void slot_updateAutoReconnect(int state); @@ -120,6 +127,8 @@ private: void loadCustomProfile(const QString&) const; void generateCustomProfile(const QString&) const; void setCustomIcon(const QString&, QListWidgetItem*) const; + void setIconOfListedProfile(const QString& profileName, const QIcon& icon) const; + QString selectedProfileName() const; template <typename L> void loadSecuredPassword(const QString& profile, L callback); void migrateSecuredPassword(const QString& oldProfile, const QString& newProfile); @@ -127,6 +136,7 @@ private: void deleteSecurePassword(const QString& profile); void setupMudProfile(QListWidgetItem*, const QString& mudServer, const QString& serverDescription, const QString& iconFileName); void reallyDeleteProfile(const QString& profile); + void showRemovalProblem(const QString& message); void continueProfileSave(QListWidgetItem* pItem, const QString& newProfileName, const QString& newProfileHost, const QString& newProfilePort, const int newProfileSslTsl); void setItemName(QListWidgetItem*, const QString&) const; QIcon customIcon(const QString&, const std::optional<QColor>&) const; @@ -134,6 +144,10 @@ private: void clearNotificationArea(); void loadPasswordAsync(const QString& profileName); void revealConnectionDetails(); + bool showingOnlyMyProfiles() const; + + static constexpr int scmMyGamesTab = 0; + static constexpr int scmAllGamesTab = 1; // split into 3 properties so each one can be checked individually // important for creation of a folder on disk, for example: name has @@ -148,10 +162,10 @@ private: QPalette mErrorPalette; QPalette mReadOnlyPalette; QAction* mpCopyProfile = nullptr; + // switches the profiles list between the user's own games and the full catalog + QTabBar* mpTabBar = nullptr; QPushButton* offline_button = nullptr; QPushButton* connect_button = nullptr; - QLineEdit* delete_profile_lineedit = nullptr; - QPushButton* delete_button = nullptr; QString mDiscordApplicationId; QString mDiscordInviteURL; QAction* mpAction_revealPassword; @@ -178,6 +192,7 @@ private: private slots: + void slot_activeTabChanged(const int index); void slot_skipToGamesList(); void slot_profileContextMenu(QPoint pos); void slot_setCustomIcon(); diff --git a/src/dlgModuleManager.cpp b/src/dlgModuleManager.cpp index 82be0177a..9cc11d11d 100644 --- a/src/dlgModuleManager.cpp +++ b/src/dlgModuleManager.cpp @@ -39,6 +39,9 @@ dlgModuleManager::dlgModuleManager(QWidget* parent, Host* pHost) { setupUi(this); + // nothing is selected yet, so there is no help to show + helpButton->setDisabled(true); + layoutModules(); connect(uninstallButton, &QAbstractButton::clicked, this, &dlgModuleManager::slot_uninstallModule); connect(installButton, &QAbstractButton::clicked, this, &dlgModuleManager::slot_installModule); @@ -205,11 +208,17 @@ void dlgModuleManager::slot_moduleClicked(QTableWidgetItem* pItem) return; } - if (mpHost->moduleHelp.contains(entry->text())) { - helpButton->setDisabled((!mpHost->moduleHelp.value(entry->text()).contains(qsl("helpURL")) || mpHost->moduleHelp.value(entry->text()).value(qsl("helpURL")).isEmpty())); - } else { - helpButton->setDisabled(true); + helpButton->setDisabled(moduleHelpUrl(entry->text()).isEmpty()); +} + +QString dlgModuleManager::moduleHelpUrl(const QString& moduleName) const +{ + const QString url = mpHost->mModuleInfo.value(moduleName).value(qsl("helpURL")); + if (!url.isEmpty()) { + return url; } + // fall back to the legacy source populated by XML-imported <HelpPackage> data + return mpHost->moduleHelp.value(moduleName).value(qsl("helpURL")); } void dlgModuleManager::slot_moduleChanged(QTableWidgetItem* pItem) @@ -247,22 +256,24 @@ void dlgModuleManager::slot_helpModule() if (!pI) { return; } - if (mpHost->moduleHelp.value(pI->text()).contains(QLatin1String("helpURL")) && !mpHost->moduleHelp.value(pI->text()).value(QLatin1String("helpURL")).isEmpty()) { - if (!mudlet::self()->openWebPage(mpHost->moduleHelp.value(pI->text()).value(QLatin1String("helpURL")))) { - //failed first open, try for a module related path - QTableWidgetItem* item = moduleTable->item(cRow, 3); - if (!item) { - return; - } - const QString itemPath = item->text(); - QStringList path = itemPath.split(QDir::separator()); - path.pop_back(); - path.append(QDir::separator()); - path.append(mpHost->moduleHelp.value(pI->text()).value(QLatin1String("helpURL"))); - const QString path2 = path.join(QString()); - if (!mudlet::self()->openWebPage(path2)) { - helpButton->setDisabled(true); - } + const QString helpUrl = moduleHelpUrl(pI->text()); + if (helpUrl.isEmpty()) { + return; + } + if (!mudlet::self()->openWebPage(helpUrl)) { + //failed first open, try for a module related path + QTableWidgetItem* item = moduleTable->item(cRow, 3); + if (!item) { + return; + } + const QString itemPath = item->text(); + QStringList path = itemPath.split(QDir::separator()); + path.pop_back(); + path.append(QDir::separator()); + path.append(helpUrl); + const QString path2 = path.join(QString()); + if (!mudlet::self()->openWebPage(path2)) { + helpButton->setDisabled(true); } } } diff --git a/src/dlgModuleManager.h b/src/dlgModuleManager.h index 43bfc24e1..b4ed84ee1 100644 --- a/src/dlgModuleManager.h +++ b/src/dlgModuleManager.h @@ -55,6 +55,7 @@ protected: private: void showImportStatus(const QString& message); + QString moduleHelpUrl(const QString& moduleName) const; Host* mpHost = nullptr; }; diff --git a/src/dlgPackageExporter.cpp b/src/dlgPackageExporter.cpp index 4d78dc2e8..d0c9688f8 100644 --- a/src/dlgPackageExporter.cpp +++ b/src/dlgPackageExporter.cpp @@ -558,6 +558,7 @@ void dlgPackageExporter::slot_packageChanged(int index) ui->textEdit_description->setMarkdown(description); const QString version = packageInfo.value(qsl("version")); ui->lineEdit_version->setText(version); + ui->lineEdit_helpUrl->setText(packageInfo.value(qsl("helpURL"))); populateDependencies(); // available dependencies, as opposed to required ones which is next const QStringList dependencies = packageInfo.value(qsl("dependencies")).split(QLatin1Char(',')); ui->comboBox_dependencies->clear(); @@ -1269,6 +1270,18 @@ void dlgPackageExporter::exportXml(bool& isOk, } } +QString dlgPackageExporter::normalizedHelpUrl() const +{ + QString url = ui->lineEdit_helpUrl->text().trimmed(); + // anchored so a "://" buried in a query string does not count as a scheme + static const QRegularExpression schemePattern(qsl("^[a-zA-Z][a-zA-Z0-9+.-]*://")); + if (!url.isEmpty() && !url.contains(schemePattern)) { + // scheme-less URLs silently fail to open in a browser later + url.prepend(qsl("https://")); + } + return url; +} + void dlgPackageExporter::writeConfigFile(const QString& stagingDirName, const QFileInfo& iconFile, const QString& packageDescription) { QStringList dependencies; @@ -1284,6 +1297,7 @@ void dlgPackageExporter::writeConfigFile(const QString& stagingDirName, const QF appendToDetails(qsl("title"), ui->lineEdit_title->text()); appendToDetails(qsl("description"), packageDescription); appendToDetails(qsl("version"), ui->lineEdit_version->text()); + appendToDetails(qsl("helpURL"), normalizedHelpUrl()); appendToDetails(qsl("dependencies"), dependencies.join(",")); const auto iso8601timestamp = utils::dateStamp(); mPackageConfig.append(qsl("created = \"%1\"\n").arg(iso8601timestamp)); diff --git a/src/dlgPackageExporter.h b/src/dlgPackageExporter.h index 474af523b..e663be00d 100644 --- a/src/dlgPackageExporter.h +++ b/src/dlgPackageExporter.h @@ -141,6 +141,7 @@ private: static std::pair<bool, QString> zipPackage(const QString& stagingDirName, const QString& packagePathFileName, const QString& xmlPathFileName, const QString& packageName, const QString& packageComment); static std::pair<bool, QString> copyAssetsToTmp(const QStringList& assetPaths, const QString& tempPath); QFileInfo copyIconToTmp(const QString& tempPath) const; + QString normalizedHelpUrl() const; void writeConfigFile(const QString& stagingDirName, const QFileInfo& iconFile, const QString& packageDescription); void exportXml(bool& isOk, QList<QTreeWidgetItem*>& trigList, diff --git a/src/dlgPackageManager.cpp b/src/dlgPackageManager.cpp index df26deea3..f4a0c43db 100644 --- a/src/dlgPackageManager.cpp +++ b/src/dlgPackageManager.cpp @@ -605,6 +605,16 @@ void dlgPackageManager::slot_openBugWebsite() mudlet::self()->openWebPage(qsl("https://github.com/Mudlet/mudlet-package-repository/issues/new?template=package-bug-or-issue.md&title=[Package%20Bug]%20") + currentItem->text()); } +QString dlgPackageManager::packageHelpUrl(const QString& packageName) const +{ + // a help URL set by the package's author takes precedence over the generic repository website + const QString url = mpHost->mPackageInfo.value(packageName).value(qsl("helpURL")); + if (!url.isEmpty()) { + return url; + } + return packageLookup.value(packageName).value(qsl("helpURL")).toString(); +} + void dlgPackageManager::slot_openPackageWebsite() { const QListWidgetItem* currentItem = packageList->currentItem(); @@ -612,6 +622,12 @@ void dlgPackageManager::slot_openPackageWebsite() return; } + const QString helpUrl = packageHelpUrl(currentItem->text()); + if (!helpUrl.isEmpty()) { + mudlet::self()->openWebPage(helpUrl); + return; + } + mudlet::self()->openWebPage(qsl("https://packages.mudlet.org/packages#pkg-") + currentItem->text()); } diff --git a/src/dlgPackageManager.h b/src/dlgPackageManager.h index f2697461b..4253c4640 100644 --- a/src/dlgPackageManager.h +++ b/src/dlgPackageManager.h @@ -73,6 +73,7 @@ private: void downloadRepositoryIndex(); void fillPackageDetails(const QString& name, const QString& title, const QString& author, const QString& version); bool hasNewerVersion(const QString& installed, const QString& repo) const; + QString packageHelpUrl(const QString& packageName) const; void populatePackagesWithUpdates(); void setupNavigationButtons(); void showImportStatus(const QString& message); diff --git a/src/dlgProfilePreferences.cpp b/src/dlgProfilePreferences.cpp index 9a6d9e278..98893802b 100644 --- a/src/dlgProfilePreferences.cpp +++ b/src/dlgProfilePreferences.cpp @@ -47,6 +47,7 @@ #include "dlgTriggerEditor.h" #include "edbee/views/texteditorscrollarea.h" #include "MMCP.h" +#include "utils.h" #include <chrono> #include <QtConcurrentRun> @@ -216,6 +217,10 @@ dlgProfilePreferences::dlgProfilePreferences(QWidget* pParentWidget, Host* pHost checkbox_noAutomaticUpdates->setChecked(true); checkbox_noAutomaticUpdates->setDisabled(true); checkbox_noAutomaticUpdates->setToolTip(utils::richText(tr("Automatic updates are disabled in development builds to prevent an update from overwriting your Mudlet."))); + } else if (!pMudlet->pUpdater->ready()) { + // Nothing to show a setting for until the platform updater is set up, + // and a checkbox that silently does nothing is worse than no checkbox + groupBox_updates->hide(); } else { checkbox_noAutomaticUpdates->setChecked(!pMudlet->pUpdater->updateAutomatically()); // This is the extra connect(...) relating to settings' changes saved by @@ -379,6 +384,15 @@ dlgProfilePreferences::dlgProfilePreferences(QWidget* pParentWidget, Host* pHost setupPasswordsMigration(); } +dlgProfilePreferences::~dlgProfilePreferences() +{ + // ~QDialog hides the dialog once this destructor is done, and the widget + // that has the keyboard focus then emits its editingFinished() - the chat + // name field and the shortcut editors both act on that one - when this + // object is no longer a valid receiver (#9574) + utils::disconnectChildSignals(this); +} + void dlgProfilePreferences::setupPasswordsMigration() { hidePasswordMigrationLabelTimer = std::make_unique<QTimer>(this); @@ -803,6 +817,8 @@ void dlgProfilePreferences::initWithHost(Host* pHost) checkBox_announceIncomingText->setChecked(pHost->mAnnounceIncomingText); checkBox_advertiseScreenReader->setChecked(pHost->mAdvertiseScreenReader); connect(checkBox_advertiseScreenReader, &QCheckBox::toggled, this, &dlgProfilePreferences::slot_toggleAdvertiseScreenReader); + checkBox_enableOSC8Hyperlinks->setChecked(pHost->mEnableOSC8Hyperlinks); + connect(checkBox_enableOSC8Hyperlinks, &QCheckBox::toggled, this, &dlgProfilePreferences::slot_toggleEnableOSC8Hyperlinks); checkBox_enableClosedCaption->setChecked(pHost->mEnableClosedCaption); connect(checkBox_enableClosedCaption, &QCheckBox::toggled, this, &dlgProfilePreferences::slot_toggleEnableClosedCaption); @@ -900,14 +916,19 @@ void dlgProfilePreferences::initWithHost(Host* pHost) checkBox_discordServerAccessToPartyInfo->setChecked(!(discordFlags & Host::DiscordSetPartyInfo)); checkBox_discordServerAccessToTimerInfo->setChecked(!(discordFlags & Host::DiscordSetTimeInfo)); lineEdit_discordUserName->setText(pHost->mRequiredDiscordUserName); - lineEdit_discordUserName->setToolTip(utils::richText(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase ASCII character set and not any \"Nickname\" that you may have set for a particular Server."))); - lineEdit_discordUserName->setAccessibleDescription(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase ASCII character set and not any \"Nickname\" that you may have set for a particular Server.")); + lineEdit_discordUserName->setToolTip(utils::richText(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). " + "Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted " + "lowercase ASCII character set and not any \"Nickname\" that you may have set for a particular Server."))); + lineEdit_discordUserName->setAccessibleDescription(tr("Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). " + "Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase " + "ASCII character set and not any \"Nickname\" that you may have set for a particular Server.")); const QString currentDiscordUser = Discord::getLoggedInUserName(); if (!currentDiscordUser.isEmpty()) { //: Shows which Discord account is logged in: label_data_discordCurrentUser->setText(currentDiscordUser); - label_data_discordCurrentUser->setToolTip(utils::richText(tr("This is the unique username using a restricted character set for the Discord account, and not necessarily the nickname that you might have set for a particular Server."))); + label_data_discordCurrentUser->setToolTip(utils::richText( + tr("This is the unique username using a restricted character set for the Discord account, and not necessarily the nickname that you might have set for a particular Server."))); } else { label_data_discordCurrentUser->setText(tr("(Not connected)")); //: Tooltip shown when Discord Rich Presence cannot detect a logged-in user @@ -983,17 +1004,17 @@ void dlgProfilePreferences::initWithHost(Host* pHost) } protocolMenu->clear(); - mEnableCHARSET = new QAction(tr("CHARSET: Character Encoding Standard"), nullptr); + mEnableCHARSET = new QAction(tr("CHARSET: Character Encoding Standard"), protocolMenu); mEnableCHARSET->setCheckable(true); mEnableCHARSET->setChecked(pHost->mEnableCHARSET); protocolMenu->addAction(mEnableCHARSET); - mEnableGMCP = new QAction(tr("GMCP: Generic Mud Communication Protocol"), nullptr); + mEnableGMCP = new QAction(tr("GMCP: Generic Mud Communication Protocol"), protocolMenu); mEnableGMCP->setCheckable(true); mEnableGMCP->setChecked(pHost->mEnableGMCP); protocolMenu->addAction(mEnableGMCP); - mEnableMNES = new QAction(tr("MNES: Mud New-Environ Standard"), nullptr); + mEnableMNES = new QAction(tr("MNES: Mud New-Environ Standard"), protocolMenu); mEnableMNES->setCheckable(true); mEnableMNES->setChecked(pHost->mEnableMNES); //: Tooltip for MNES protocol option explaining mutual exclusivity with NEW-ENVIRON @@ -1001,37 +1022,37 @@ void dlgProfilePreferences::initWithHost(Host* pHost) "including OSC link support.")); protocolMenu->addAction(mEnableMNES); - mEnableMSDP = new QAction(tr("MSDP: Mud Server Data Protocol"), nullptr); + mEnableMSDP = new QAction(tr("MSDP: Mud Server Data Protocol"), protocolMenu); mEnableMSDP->setCheckable(true); mEnableMSDP->setChecked(pHost->mEnableMSDP); protocolMenu->addAction(mEnableMSDP); - mEnableMSP = new QAction(tr("MSP: Mud Sound Protocol"), nullptr); + mEnableMSP = new QAction(tr("MSP: Mud Sound Protocol"), protocolMenu); mEnableMSP->setCheckable(true); mEnableMSP->setChecked(pHost->mEnableMSP); protocolMenu->addAction(mEnableMSP); - mEnableMSSP = new QAction(tr("MSSP: Mud Server Status Protocol"), nullptr); + mEnableMSSP = new QAction(tr("MSSP: Mud Server Status Protocol"), protocolMenu); mEnableMSSP->setCheckable(true); mEnableMSSP->setChecked(pHost->mEnableMSSP); protocolMenu->addAction(mEnableMSSP); - mEnableMTTS = new QAction(tr("MTTS: Mud Terminal Type Standard"), nullptr); + mEnableMTTS = new QAction(tr("MTTS: Mud Terminal Type Standard"), protocolMenu); mEnableMTTS->setCheckable(true); mEnableMTTS->setChecked(pHost->mEnableMTTS); protocolMenu->addAction(mEnableMTTS); - mEnableMXP = new QAction(tr("MXP: Mud eXtension Protocol"), nullptr); + mEnableMXP = new QAction(tr("MXP: Mud eXtension Protocol"), protocolMenu); mEnableMXP->setCheckable(true); mEnableMXP->setChecked(pHost->mEnableMXP); protocolMenu->addAction(mEnableMXP); - mEnableNAWS = new QAction(tr("NAWS: Negotiate About Window Size"), nullptr); + mEnableNAWS = new QAction(tr("NAWS: Negotiate About Window Size"), protocolMenu); mEnableNAWS->setCheckable(true); mEnableNAWS->setChecked(pHost->mEnableNAWS); protocolMenu->addAction(mEnableNAWS); - mEnableNEWENVIRON = new QAction(tr("NEW-ENVIRON: Client Variables Standard"), nullptr); + mEnableNEWENVIRON = new QAction(tr("NEW-ENVIRON: Client Variables Standard"), protocolMenu); mEnableNEWENVIRON->setCheckable(true); mEnableNEWENVIRON->setChecked(pHost->mEnableNEWENVIRON); //: Tooltip for NEW-ENVIRON protocol option explaining mutual exclusivity with MNES @@ -1050,7 +1071,7 @@ void dlgProfilePreferences::initWithHost(Host* pHost) pushButton_chooseProfiles->setEnabled(false); pushButton_copyMap->setEnabled(false); if (!mpMenu) { - mpMenu = new QMenu(tr("Other profiles to Map to:")); + mpMenu = new QMenu(tr("Other profiles to Map to:"), this); } mpMenu->clear(); @@ -1063,7 +1084,7 @@ void dlgProfilePreferences::initWithHost(Host* pHost) continue; } - auto pItem = new QAction(s, nullptr); + auto pItem = new QAction(s, mpMenu); pItem->setCheckable(true); pItem->setChecked(false); mpMenu->addAction(pItem); @@ -1327,7 +1348,7 @@ void dlgProfilePreferences::initWithHost(Host* pHost) break; default: { } // There are a significant number of other errors - // that are not handled here! + // that are not handled here! } } } @@ -2081,7 +2102,16 @@ void dlgProfilePreferences::slot_purgeMediaCache() return; } - pHost->mpMedia->purgeMediaCache(); + const auto [purged, message] = pHost->mpMedia->purgeMediaCache(); + + if (!purged) { + //: Shown after the "Clear stored media" button in preferences fails to empty the profile's media directory. %1 is the reason, which is not translated. + pHost->postMessage(tr("[ WARN ] - Could not clear the stored media: %1.").arg(message)); + return; + } + + //: Shown after the "Clear stored media" button in preferences empties the profile's media directory. + pHost->postMessage(tr("[ OK ] - The stored media files for this profile have been cleared.")); } void dlgProfilePreferences::slot_resetColors() @@ -3484,6 +3514,7 @@ void dlgProfilePreferences::slot_saveAndClose() pHost->mMMCPShowSnoopInMainConsole = checkBox_mmcpSnoopInMainConsole->isChecked(); pHost->mAnnounceIncomingText = checkBox_announceIncomingText->isChecked(); pHost->mAdvertiseScreenReader = checkBox_advertiseScreenReader->isChecked(); + pHost->mEnableOSC8Hyperlinks = checkBox_enableOSC8Hyperlinks->isChecked(); pHost->mEnableClosedCaption = checkBox_enableClosedCaption->isChecked(); pHost->setHaveColorSpaceId(checkBox_expectCSpaceIdInColonLessMColorCode->isChecked()); @@ -5012,6 +5043,20 @@ void dlgProfilePreferences::slot_toggleAdvertiseScreenReader(const bool state) } } +void dlgProfilePreferences::slot_toggleEnableOSC8Hyperlinks(const bool state) +{ + Host* pHost = mpHost; + + if (!pHost) { + return; + } + + if (pHost->mEnableOSC8Hyperlinks != state) { + pHost->mEnableOSC8Hyperlinks = state; + pHost->mTelnet.sendInfoNewEnvironOSCHyperlinks(); + } +} + void dlgProfilePreferences::slot_toggleEnableClosedCaption(const bool state) { if (mpHost && mpHost->mEnableClosedCaption != state) { diff --git a/src/dlgProfilePreferences.h b/src/dlgProfilePreferences.h index 5236e1dab..42c985e9c 100644 --- a/src/dlgProfilePreferences.h +++ b/src/dlgProfilePreferences.h @@ -48,6 +48,7 @@ class dlgProfilePreferences : public QDialog, public Ui::profile_preferences public: Q_DISABLE_COPY(dlgProfilePreferences) explicit dlgProfilePreferences(QWidget*, Host* pHost = nullptr); + ~dlgProfilePreferences(); void setTab(QString tab); public slots: @@ -170,6 +171,7 @@ private slots: void slot_setPostingTimeout(const double); void slot_changeControlCharacterHandling(); void slot_toggleAdvertiseScreenReader(const bool); + void slot_toggleEnableOSC8Hyperlinks(const bool); void slot_changeWrapAt(); void slot_toggleUseMaxBufferSize(bool checked); void slot_deleteMap(); diff --git a/src/dlgTriggerEditor.cpp b/src/dlgTriggerEditor.cpp index 9318f378e..95c1f994c 100644 --- a/src/dlgTriggerEditor.cpp +++ b/src/dlgTriggerEditor.cpp @@ -57,6 +57,7 @@ #include "utils.h" #include "edbee/models/textdocumentscopes.h" +#include <QApplication> #include <QCheckBox> #include <QAbstractButton> #include <QColorDialog> @@ -1214,6 +1215,7 @@ dlgTriggerEditor::dlgTriggerEditor(Host* pH) connect(mpActionsMainArea->lineEdit_action_button_command_up, &QLineEdit::editingFinished, this, &dlgTriggerEditor::slot_saveProperty_ActionCommandUp); connect(mpActionsMainArea->checkBox_action_button_isPushDown, &QCheckBox::toggled, this, &dlgTriggerEditor::slot_saveProperty_ActionIsPushDown); connect(mpActionsMainArea->spinBox_action_bar_columns, qOverload<int>(&QSpinBox::valueChanged), this, &dlgTriggerEditor::slot_saveProperty_ActionBarColumns); + connect(mpActionsMainArea->spinBox_action_bar_offsetToFirstButton, qOverload<int>(&QSpinBox::valueChanged), this, &dlgTriggerEditor::slot_saveProperty_ActionBarFillerOffset); connect(mpActionsMainArea->comboBox_action_bar_orientation, qOverload<int>(&QComboBox::currentIndexChanged), this, &dlgTriggerEditor::slot_saveProperty_ActionBarOrientation); connect(mpActionsMainArea->comboBox_action_bar_location, qOverload<int>(&QComboBox::currentIndexChanged), this, &dlgTriggerEditor::slot_saveProperty_ActionBarLocation); connect(mpActionsMainArea->comboBox_action_button_rotation, qOverload<int>(&QComboBox::currentIndexChanged), this, &dlgTriggerEditor::slot_saveProperty_ActionButtonRotation); @@ -1413,6 +1415,23 @@ dlgTriggerEditor::dlgTriggerEditor(Host* pH) } } +dlgTriggerEditor::~dlgTriggerEditor() +{ + // ~QWidget closes the editor once this destructor is done, and whichever + // of the item fields has the keyboard focus then emits editingFinished() + // into one of the slot_saveProperty_...() slots when this object is no + // longer a valid receiver (#9574) + utils::disconnectChildSignals(this); + // The undo stacks are not in this widget's child tree - the edbee one hangs + // off a parentless CharTextDocument - so disconnect them by hand: + if (mpTextUndoStack) { + disconnect(mpTextUndoStack, nullptr, this, nullptr); + } + if (mpUndoStack) { + disconnect(mpUndoStack, nullptr, this, nullptr); + } +} + void dlgTriggerEditor::slot_searchSplitterMoved(const int pos, const int index) { Q_UNUSED(pos) @@ -1870,17 +1889,6 @@ void dlgTriggerEditor::slot_setTreeWidgetIconSize(const int s) void dlgTriggerEditor::closeEvent(QCloseEvent* event) { - // Only disconnect signals and clear undo stack if the dialog is being destroyed (WA_DeleteOnClose set) - // This happens when the profile closes (Host::closeChildren), not when the user just closes the editor window - if (testAttribute(Qt::WA_DeleteOnClose)) { - if (mpTextUndoStack) { - disconnect(mpTextUndoStack, nullptr, this, nullptr); - } - if (mpUndoStack) { - disconnect(mpUndoStack, nullptr, this, nullptr); - } - } - emit editorClosing(); writeSettings(); event->accept(); @@ -3047,6 +3055,7 @@ void dlgTriggerEditor::delete_alias() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); const int itemId = pItem->data(0, Qt::UserRole).toInt(); @@ -3069,6 +3078,7 @@ void dlgTriggerEditor::delete_alias() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); } clearEditorState(EditorViewType::cmAliasView, itemId); delete pT; @@ -3080,6 +3090,12 @@ void dlgTriggerEditor::delete_alias() mpUndoStack->pushCommand(qtCmd); } + // Detaching an item nulls treeWidget() on its whole subtree, which is how a + // newSelection that sat inside another removed subtree is caught here: + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpAliasBaseItem; + } + // Set new selection if (newSelection) { mpCurrentAliasItem = newSelection; @@ -3089,6 +3105,10 @@ void dlgTriggerEditor::delete_alias() mpCurrentAliasItem = nullptr; clearAliasForm(); } + + // Has to stay after the selection handling: the slots it fires still read + // the detached items. + qDeleteAll(removedItems); } void dlgTriggerEditor::delete_action() @@ -3190,6 +3210,7 @@ void dlgTriggerEditor::delete_action() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); const int itemId = pItem->data(0, Qt::UserRole).toInt(); @@ -3217,6 +3238,7 @@ void dlgTriggerEditor::delete_action() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); } clearEditorState(EditorViewType::cmActionView, itemId); delete pT; @@ -3228,6 +3250,10 @@ void dlgTriggerEditor::delete_action() mpUndoStack->pushCommand(qtCmd); } + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpActionBaseItem; + } + // Set new selection if (newSelection) { mpCurrentActionItem = newSelection; @@ -3238,6 +3264,8 @@ void dlgTriggerEditor::delete_action() clearActionForm(); } + qDeleteAll(removedItems); + mpHost->getActionUnit()->updateAllToolbars(); } @@ -3276,6 +3304,7 @@ void dlgTriggerEditor::delete_variable() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); TVar* var = vu->getWVar(pItem); @@ -3293,11 +3322,28 @@ void dlgTriggerEditor::delete_variable() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); + // Deleting the TVar below frees its descendants too and nothing + // unregisters those, so drop the whole detached subtree from the + // lookup maps now: a later pass over a selected descendant would + // otherwise resolve a freed TVar, as would a recycled item address: + QList<QTreeWidgetItem*> pendingPurge{pItem}; + while (!pendingPurge.isEmpty()) { + QTreeWidgetItem* pEntry = pendingPurge.takeLast(); + vu->removeTreeItem(pEntry); + for (int i = 0; i < pEntry->childCount(); ++i) { + pendingPurge.append(pEntry->child(i)); + } + } } delete var; } } + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpVarBaseItem; + } + // Set new selection if (newSelection) { mpCurrentVarItem = newSelection; @@ -3307,6 +3353,8 @@ void dlgTriggerEditor::delete_variable() mpCurrentVarItem = nullptr; clearVarForm(); } + + qDeleteAll(removedItems); } void dlgTriggerEditor::delete_script() @@ -3399,6 +3447,7 @@ void dlgTriggerEditor::delete_script() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); const int itemId = pItem->data(0, Qt::UserRole).toInt(); @@ -3421,6 +3470,7 @@ void dlgTriggerEditor::delete_script() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); } clearEditorState(EditorViewType::cmScriptView, itemId); delete pT; @@ -3432,6 +3482,10 @@ void dlgTriggerEditor::delete_script() mpUndoStack->pushCommand(qtCmd); } + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpScriptsBaseItem; + } + // Set new selection if (newSelection) { mpCurrentScriptItem = newSelection; @@ -3441,6 +3495,8 @@ void dlgTriggerEditor::delete_script() mpCurrentScriptItem = nullptr; clearScriptForm(); } + + qDeleteAll(removedItems); } void dlgTriggerEditor::delete_key() @@ -3533,6 +3589,7 @@ void dlgTriggerEditor::delete_key() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); const int itemId = pItem->data(0, Qt::UserRole).toInt(); @@ -3555,6 +3612,7 @@ void dlgTriggerEditor::delete_key() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); } clearEditorState(EditorViewType::cmKeysView, itemId); delete pT; @@ -3566,6 +3624,10 @@ void dlgTriggerEditor::delete_key() mpUndoStack->pushCommand(qtCmd); } + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpKeyBaseItem; + } + // Set new selection if (newSelection) { mpCurrentKeyItem = newSelection; @@ -3575,6 +3637,8 @@ void dlgTriggerEditor::delete_key() mpCurrentKeyItem = nullptr; clearKeyForm(); } + + qDeleteAll(removedItems); } void dlgTriggerEditor::delete_trigger() @@ -3672,6 +3736,7 @@ void dlgTriggerEditor::delete_trigger() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); const int itemId = pItem->data(0, Qt::UserRole).toInt(); @@ -3694,6 +3759,7 @@ void dlgTriggerEditor::delete_trigger() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); } clearEditorState(EditorViewType::cmTriggerView, itemId); delete pT; @@ -3705,6 +3771,10 @@ void dlgTriggerEditor::delete_trigger() mpUndoStack->pushCommand(qtCmd); } + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpTriggerBaseItem; + } + // Set new selection if (newSelection) { mpCurrentTriggerItem = newSelection; @@ -3714,6 +3784,8 @@ void dlgTriggerEditor::delete_trigger() mpCurrentTriggerItem = nullptr; clearTriggerForm(); } + + qDeleteAll(removedItems); } void dlgTriggerEditor::delete_timer() @@ -3806,6 +3878,7 @@ void dlgTriggerEditor::delete_timer() std::reverse(selectedItems.begin(), selectedItems.end()); QTreeWidgetItem* newSelection = nullptr; + QList<QTreeWidgetItem*> removedItems; for (QTreeWidgetItem* pItem : std::as_const(selectedItems)) { QTreeWidgetItem* pParentItem = pItem->parent(); const int itemId = pItem->data(0, Qt::UserRole).toInt(); @@ -3828,6 +3901,7 @@ void dlgTriggerEditor::delete_timer() } if (pParentItem) { pParentItem->removeChild(pItem); + removedItems.append(pItem); } clearEditorState(EditorViewType::cmTimerView, itemId); delete pT; @@ -3839,6 +3913,10 @@ void dlgTriggerEditor::delete_timer() mpUndoStack->pushCommand(qtCmd); } + if (newSelection && !newSelection->treeWidget()) { + newSelection = mpTimerBaseItem; + } + // Set new selection if (newSelection) { mpCurrentTimerItem = newSelection; @@ -3848,6 +3926,8 @@ void dlgTriggerEditor::delete_timer() mpCurrentTimerItem = nullptr; clearTimerForm(); } + + qDeleteAll(removedItems); } @@ -5369,9 +5449,6 @@ void dlgTriggerEditor::addAction(bool isFolder) QString name = isFolder ? tr("New menu") : tr("New button"); QStringList nameList = {name}; - const QString cmdButtonUp = ""; - const QString cmdButtonDown = ""; - const QString script = ""; QTreeWidgetItem* pParentItem = treeWidget_actions->currentItem(); QTreeWidgetItem* pNewItem = nullptr; @@ -5395,10 +5472,11 @@ void dlgTriggerEditor::addAction(bool isFolder) } } // Otherwise: insert a new root item + // CHECKME: doesn't this HAVE to be a toolbar - surely buttons MUST be in a container? if (!pNewAction) { name = isFolder ? tr("New toolbar") : tr("New button"); pNewAction = new TAction(name, mpHost); - pNewAction->setCommandButtonUp(cmdButtonUp); + pNewAction->setCommandButtonUp(QString()); QStringList nl; nl << name; pNewItem = new QTreeWidgetItem(mpActionBaseItem, nl); @@ -5407,12 +5485,12 @@ void dlgTriggerEditor::addAction(bool isFolder) // Initialize logic object properties pNewAction->setName(name); - pNewAction->setCommandButtonUp(cmdButtonUp); - pNewAction->setCommandButtonDown(cmdButtonDown); + pNewAction->setCommandButtonUp(QString()); + pNewAction->setCommandButtonDown(QString()); pNewAction->setIsPushDownButton(false); pNewAction->mLocation = 1; pNewAction->mOrientation = 1; - pNewAction->setScript(script); + pNewAction->setScript(QString()); pNewAction->setIsFolder(isFolder); pNewAction->setIsActive(false); pNewAction->registerAction(); @@ -6389,13 +6467,15 @@ void dlgTriggerEditor::saveAction() const QString script = mpSourceEditorEdbeeDocument->text(); // currentIndex() can return -1 if no setting was previously made - need to fixup: const int rotation = qMax(0, mpActionsMainArea->comboBox_action_button_rotation->currentIndex()); - const int columns = mpActionsMainArea->spinBox_action_bar_columns->text().toInt(); + const int columns = mpActionsMainArea->spinBox_action_bar_columns->value(); + const int offset = mpActionsMainArea->spinBox_action_bar_offsetToFirstButton->value(); const bool isChecked = mpActionsMainArea->checkBox_action_button_isPushDown->isChecked(); // bottom location is no longer supported i.e. location = 1 = 0 = location top // currentIndex() can return -1 if no setting was previously made - need to fixup: int location = qMax(0, mpActionsMainArea->comboBox_action_bar_location->currentIndex()); if (location > 0) { - location++; + // The comboBox has indexes of 0 to 4 but we don't use 1 so jump over it: + ++location; } // currentIndex() can return -1 if no setting was previously made - need to fixup: @@ -6429,6 +6509,7 @@ void dlgTriggerEditor::saveAction() pA->setIsActive(pA->shouldBeActive()); pA->setButtonRotation(rotation); pA->setButtonColumns(columns); + pA->setButtonFillerOffset(offset); pA->mUseCustomLayout = false; pA->css = mpActionsMainArea->plainTextEdit_action_css->toPlainText(); } @@ -6722,6 +6803,13 @@ void dlgTriggerEditor::saveScript() mpTextUndoStack->clear(); } } + + // If pT's own body uninstalled its package during the compile above, the delete + // was deferred (see TScript::compileScript / ScriptUnit::uninstall). We are now + // done with pT, so flush it before returning to the event loop - otherwise the + // 0ms save uninstallPackage() queued would serialize the "uninstalled" script + // back into the profile: + mpHost->getScriptUnit()->doCleanup(); } void dlgTriggerEditor::clearEditorNotification() @@ -8239,6 +8327,9 @@ void dlgTriggerEditor::slot_actionSelected(QTreeWidgetItem* pItem) mpActionsMainArea->comboBox_action_bar_orientation->setCurrentIndex(0); mpActionsMainArea->comboBox_action_button_rotation->setCurrentIndex(0); mpActionsMainArea->spinBox_action_bar_columns->setValue(1); + mpActionsMainArea->spinBox_action_bar_offsetToFirstButton->setMaximum(0); + mpActionsMainArea->spinBox_action_bar_offsetToFirstButton->setEnabled(false); + mpActionsMainArea->spinBox_action_bar_offsetToFirstButton->setValue(0); mpCurrentActionItem = pItem; //remember what has been clicked to save it // ID will be 0 for the root of the treewidget and it is not appropriate @@ -8257,6 +8348,7 @@ void dlgTriggerEditor::slot_actionSelected(QTreeWidgetItem* pItem) mpActionsMainArea->lineEdit_action_icon->setText(pT->getIcon()); mpActionsMainArea->lineEdit_action_button_command_down->setText(pT->getCommandButtonDown()); mpActionsMainArea->lineEdit_action_button_command_up->setText(pT->getCommandButtonUp()); + mpActionsMainArea->comboBox_action_button_rotation->setCurrentIndex(pT->getButtonRotation()); clearDocument(mpSourceEditorEdbee, pT->getScript()); restoreEditorState(EditorViewType::cmActionView, ID); @@ -8270,6 +8362,7 @@ void dlgTriggerEditor::slot_actionSelected(QTreeWidgetItem* pItem) mpActionsMainArea->comboBox_action_bar_orientation->setCurrentIndex(pT->mOrientation); mpActionsMainArea->comboBox_action_button_rotation->setCurrentIndex(pT->getButtonRotation()); mpActionsMainArea->spinBox_action_bar_columns->setValue(pT->getButtonColumns()); + mpActionsMainArea->spinBox_action_bar_offsetToFirstButton->setValue(pT->getButtonFillerOffset()); mpActionsMainArea->plainTextEdit_action_css->setPlainText(pT->css); if (pT->isFolder()) { if (!pT->mPackageName.isEmpty()) { @@ -9773,10 +9866,20 @@ void dlgTriggerEditor::changeView(EditorViewType view) } mCurrentView = view; - if (mpBannerUndoTimer && mpBannerUndoTimer->isActive()) { - mpBannerUndoTimer->stop(); - mpBannerUndoTimer->deleteLater(); - mpBannerUndoTimer = nullptr; + const bool bannerUndoToastShowing = mpBannerUndoTimer && mpBannerUndoTimer->isActive(); + cancelBannerUndoTimer(); + + // A banner (or the dismissal undo toast) belongs to the view it was shown + // in, so hide it on a view change - otherwise it lingers over the new view + // when that view's own banner is suppressed. showIntro() will put up the + // right banner for the new view if one is allowed. Errors and warnings + // (which clear mCurrentBannerKey) are not hidden by this block, though the + // pre-existing permanently-hidden check below still can hide them. Using + // clearEditorNotification() rather than hideSystemMessageArea() as the + // latter would also discard the current script's unacknowledged loading + // error. + if (bannerUndoToastShowing || !mCurrentBannerKey.isEmpty()) { + clearEditorNotification(); } if (bannerPermanentlyHidden(mCurrentView)) { @@ -9872,13 +9975,13 @@ void dlgTriggerEditor::changeView(EditorViewType view) case EditorViewType::cmActionView: mAddItem->setText(tr("Add Button")); mAddItem->setStatusTip(tr("Add new button")); - mAddGroup->setText(tr("Add Button Group")); - mAddGroup->setStatusTip(tr("Add new group of buttons")); - mDeleteItem->setText(tr("Delete Button")); - mDeleteItem->setStatusTip(tr("Delete the selected button")); - mSaveItem->setText(tr("Save Button")); + mAddGroup->setText(tr("Add Toolbar or Menu")); + mAddGroup->setStatusTip(tr("Add a Toolbar (top level) or Menu (lower levels) to contain menus or buttons")); + mDeleteItem->setText(tr("Delete Button, Menu or Toolbar")); + mDeleteItem->setStatusTip(tr("Delete the selected button, menu or toolbar")); + mSaveItem->setText(tr("Save item")); //: Status tip for saving button changes - mSaveItem->setStatusTip(tr("Apply button changes (does not save to disk).")); + mSaveItem->setStatusTip(tr("Apply button/menu/toolbar changes (does not save to disk).")); break; case EditorViewType::cmKeysView: mAddItem->setText(tr("Add Key")); @@ -10105,6 +10208,9 @@ void dlgTriggerEditor::slot_showAliases() void dlgTriggerEditor::showError(const QString& text) { + // A still-running undo-toast expiry timer would hide this message when it + // fires, so cancel it - the toast's content is gone from the screen anyway + cancelBannerUndoTimer(); mpSystemMessageArea->notificationAreaIconLabelInformation->hide(); mpSystemMessageArea->notificationAreaIconLabelError->show(); mpSystemMessageArea->notificationAreaIconLabelWarning->hide(); @@ -10123,6 +10229,9 @@ void dlgTriggerEditor::showError(const QString& text) void dlgTriggerEditor::showWarning(const QString& text, bool announce) { + // A still-running undo-toast expiry timer would hide this message when it + // fires, so cancel it - the toast's content is gone from the screen anyway + cancelBannerUndoTimer(); mpSystemMessageArea->notificationAreaIconLabelInformation->hide(); mpSystemMessageArea->notificationAreaIconLabelError->hide(); mpSystemMessageArea->notificationAreaIconLabelWarning->show(); @@ -12347,6 +12456,14 @@ void dlgTriggerEditor::doCleanReset() void dlgTriggerEditor::runScheduledCleanReset() { + if (!mpHost) { + // The profile went away between doCleanReset() scheduling this and the timer firing, + // which is the order a teardown destroys them in. There is nothing left to repopulate + // from, and clearing the tree widgets below would re-enter the editor through + // selectionChanged to read the theme and font off the Host that has just gone. + return; + } + // Clear all current item pointers BEFORE attempting to save or clear tree widgets // to prevent heap-use-after-free when the tree widgets are cleared mpCurrentTriggerItem = nullptr; @@ -14299,6 +14416,16 @@ void dlgTriggerEditor::slot_itemsChanged(EditorViewType viewType, QList<int> aff void dlgTriggerEditor::handleBannerDismiss() { + // With no banner on display the close button was pressed on the "Banner + // hidden" undo toast itself - just close it instead of treating it as + // another banner dismissal (which would suppress the whole view's banners + // and stash the toast text as restorable banner content) + if (mCurrentBannerKey.isEmpty()) { + cancelBannerUndoTimer(); + hideSystemMessageArea(); + return; + } + mLastDismissedBannerView = mCurrentView; mLastDismissedBannerContent = mpSystemMessageArea->notificationAreaMessageBox->text(); mLastDismissedBannerKey = mCurrentBannerKey; @@ -14313,12 +14440,18 @@ void dlgTriggerEditor::handleBannerDismiss() showBannerUndoToast(); } -void dlgTriggerEditor::showBannerUndoToast() +void dlgTriggerEditor::cancelBannerUndoTimer() { if (mpBannerUndoTimer) { mpBannerUndoTimer->stop(); mpBannerUndoTimer->deleteLater(); + mpBannerUndoTimer = nullptr; } +} + +void dlgTriggerEditor::showBannerUndoToast() +{ + cancelBannerUndoTimer(); mCurrentBannerKey.clear(); @@ -14380,11 +14513,7 @@ void dlgTriggerEditor::slot_refreshBannerLinkColors() void dlgTriggerEditor::undoBannerDismiss() { - if (mpBannerUndoTimer) { - mpBannerUndoTimer->stop(); - mpBannerUndoTimer->deleteLater(); - mpBannerUndoTimer = nullptr; - } + cancelBannerUndoTimer(); const QString settingsKey = bannerSettingsKey(mLastDismissedBannerView, mLastDismissedBannerKey); if (!settingsKey.isEmpty()) { @@ -14483,7 +14612,7 @@ pushTriggerPropertyCommand(EditorUndoStack* undoStack, Host* host, int triggerID } auto* cmd = new EditorModifyPropertyCommand(EditorViewType::cmTriggerView, triggerID, triggerName, oldStateXML, newStateXML, host); - cmd->setPropertyId(qsl("trigger:%1:%2").arg(triggerID).arg(propertyName)); + cmd->setPropertyId(qsl("trigger:%1:%2").arg(QString::number(triggerID), propertyName)); undoStack->pushCommand(cmd); } @@ -14768,7 +14897,7 @@ static void pushAliasPropertyCommand(EditorUndoStack* undoStack, Host* host, int } auto* cmd = new EditorModifyPropertyCommand(EditorViewType::cmAliasView, aliasID, aliasName, oldStateXML, newStateXML, host); - cmd->setPropertyId(qsl("alias:%1:%2").arg(aliasID).arg(propertyName)); + cmd->setPropertyId(qsl("alias:%1:%2").arg(QString::number(aliasID), propertyName)); undoStack->pushCommand(cmd); } @@ -14883,7 +15012,7 @@ static void pushTimerPropertyCommand(EditorUndoStack* undoStack, Host* host, int } auto* cmd = new EditorModifyPropertyCommand(EditorViewType::cmTimerView, timerID, timerName, oldStateXML, newStateXML, host); - cmd->setPropertyId(qsl("timer:%1:%2").arg(timerID).arg(propertyName)); + cmd->setPropertyId(qsl("timer:%1:%2").arg(QString::number(timerID), propertyName)); undoStack->pushCommand(cmd); } @@ -14980,7 +15109,7 @@ pushScriptPropertyCommand(EditorUndoStack* undoStack, Host* host, int scriptID, } auto* cmd = new EditorModifyPropertyCommand(EditorViewType::cmScriptView, scriptID, scriptName, oldStateXML, newStateXML, host); - cmd->setPropertyId(qsl("script:%1:%2").arg(scriptID).arg(propertyName)); + cmd->setPropertyId(qsl("script:%1:%2").arg(QString::number(scriptID), propertyName)); undoStack->pushCommand(cmd); } @@ -15052,7 +15181,7 @@ static void pushKeyPropertyCommand(EditorUndoStack* undoStack, Host* host, int k } auto* cmd = new EditorModifyPropertyCommand(EditorViewType::cmKeysView, keyID, keyName, oldStateXML, newStateXML, host); - cmd->setPropertyId(qsl("key:%1:%2").arg(keyID).arg(propertyName)); + cmd->setPropertyId(qsl("key:%1:%2").arg(QString::number(keyID), propertyName)); undoStack->pushCommand(cmd); } @@ -15121,7 +15250,7 @@ pushActionPropertyCommand(EditorUndoStack* undoStack, Host* host, int actionID, } auto* cmd = new EditorModifyPropertyCommand(EditorViewType::cmActionView, actionID, actionName, oldStateXML, newStateXML, host); - cmd->setPropertyId(qsl("action:%1:%2").arg(actionID).arg(propertyName)); + cmd->setPropertyId(qsl("action:%1:%2").arg(QString::number(actionID), propertyName)); undoStack->pushCommand(cmd); } @@ -15249,7 +15378,32 @@ void dlgTriggerEditor::slot_saveProperty_ActionBarColumns() pT->setButtonColumns(newValue); QString newStateXML = exportActionToXML(pT); - pushActionPropertyCommand(mpUndoStack, mpHost, actionID, pT->getName(), qsl("barColumns"), oldStateXML, newStateXML); + pushActionPropertyCommand(mpUndoStack, mpHost, actionID, pT->getName(), qsl("buttonColumn"), oldStateXML, newStateXML); +} + +void dlgTriggerEditor::slot_saveProperty_ActionBarFillerOffset() +{ + if (mBlockPropertySave || !mpCurrentActionItem) { + return; + } + + const int actionID = mpCurrentActionItem->data(0, Qt::UserRole).toInt(); + TAction* pT = mpHost->getActionUnit()->getAction(actionID); + if (!pT) { + return; + } + + const int newValue = mpActionsMainArea->spinBox_action_bar_offsetToFirstButton->value(); + + if (pT->getButtonFillerOffset() == newValue) { + return; + } + + QString oldStateXML = exportActionToXML(pT); + pT->setButtonFillerOffset(newValue); + QString newStateXML = exportActionToXML(pT); + + pushActionPropertyCommand(mpUndoStack, mpHost, actionID, pT->getName(), qsl("buttonFillerOffset"), oldStateXML, newStateXML); } void dlgTriggerEditor::slot_saveProperty_ActionBarOrientation() @@ -15264,6 +15418,7 @@ void dlgTriggerEditor::slot_saveProperty_ActionBarOrientation() return; } + // 0 = horizontal, 1 = vertical const int newValue = mpActionsMainArea->comboBox_action_bar_orientation->currentIndex(); if (pT->mOrientation == newValue) { @@ -15274,7 +15429,7 @@ void dlgTriggerEditor::slot_saveProperty_ActionBarOrientation() pT->mOrientation = newValue; QString newStateXML = exportActionToXML(pT); - pushActionPropertyCommand(mpUndoStack, mpHost, actionID, pT->getName(), qsl("barOrientation"), oldStateXML, newStateXML); + pushActionPropertyCommand(mpUndoStack, mpHost, actionID, pT->getName(), qsl("orientation"), oldStateXML, newStateXML); } void dlgTriggerEditor::slot_saveProperty_ActionBarLocation() @@ -15289,6 +15444,7 @@ void dlgTriggerEditor::slot_saveProperty_ActionBarLocation() return; } + // CHECKME: This may need the increment if it isn't zero! const int newValue = mpActionsMainArea->comboBox_action_bar_location->currentIndex(); if (pT->mLocation == newValue) { diff --git a/src/dlgTriggerEditor.h b/src/dlgTriggerEditor.h index 3cf3261ba..a848e639d 100644 --- a/src/dlgTriggerEditor.h +++ b/src/dlgTriggerEditor.h @@ -107,6 +107,7 @@ class dlgTriggerEditor : public QMainWindow, private Ui::trigger_editor // Allow QTest-based test class to access private members friend class dlgTriggerEditorUndoRedoTest; + friend class EditorBannerViewSwitchTest; enum SearchDataRole { // Value is the ID of the item found MUST BE Qt::UserRole to avoid @@ -173,6 +174,7 @@ public: Q_DISABLE_COPY(dlgTriggerEditor) dlgTriggerEditor(Host*); + ~dlgTriggerEditor(); Q_DECLARE_FLAGS(SearchOptions, SearchOption) @@ -374,6 +376,7 @@ private slots: void slot_saveProperty_ActionCommandUp(); void slot_saveProperty_ActionIsPushDown(); void slot_saveProperty_ActionBarColumns(); + void slot_saveProperty_ActionBarFillerOffset(); void slot_saveProperty_ActionBarOrientation(); void slot_saveProperty_ActionBarLocation(); void slot_saveProperty_ActionButtonRotation(); @@ -791,6 +794,7 @@ private: // Banner methods void handleBannerDismiss(); + void cancelBannerUndoTimer(); void showBannerUndoToast(); void undoBannerDismiss(); void handlePermanentBannerDismiss(); diff --git a/src/exitstreewidget.h b/src/exitstreewidget.h index a7d761164..2a25d3682 100644 --- a/src/exitstreewidget.h +++ b/src/exitstreewidget.h @@ -34,21 +34,23 @@ class ExitsTreeWidget : public QTreeWidget friend class dlgRoomExits; // The indexes that are used to identify the columns in the special exits - // treewidget have been converted to constants so that we can + // treewidget have been collected into an enumeration so that we can // tweak them and change all of them correctly - and by making the // dlgRoomExits class a friend that can use the same set as defined here. // Note that if any of these numbers are modified/extended the // corresponding headings in the ./src/ui/room_exits.ui file will need // to be adjusted as well - and visa versa: - static const int colIndex_exitRoomId = 0; - static const int colIndex_exitStatus = 1; - static const int colIndex_lockExit = 2; - static const int colIndex_exitWeight = 3; - static const int colIndex_doorNone = 4; - static const int colIndex_doorOpen = 5; - static const int colIndex_doorClosed = 6; - static const int colIndex_doorLocked = 7; - static const int colIndex_command = 8; + enum ExitsTreeColumn : int { + colIndex_exitRoomId = 0, + colIndex_exitStatus = 1, + colIndex_lockExit = 2, + colIndex_exitWeight = 3, + colIndex_doorNone = 4, + colIndex_doorOpen = 5, + colIndex_doorClosed = 6, + colIndex_doorLocked = 7, + colIndex_command = 8, + }; public: Q_DISABLE_COPY(ExitsTreeWidget) diff --git a/src/lua-function-list.json b/src/lua-function-list.json index 67db21b6a..3bedb77cf 100644 --- a/src/lua-function-list.json +++ b/src/lua-function-list.json @@ -223,7 +223,7 @@ "getMapLabels": "arealabels = getMapLabels(areaID)", "getMapMenus": "getMapMenus()", "getMapSelection": "getMapSelection()", - "getMapUserData": "getMapUserData( key )", + "getMapUserData": "getMapUserData(key)", "getMapZoom": "getMapZoom([areaID])", "getModuleInfo": "getModuleInfo(moduleName, [info])", "getModulePath": "path = getModulePath(module name)", @@ -307,7 +307,7 @@ "hideGauge": "hideGauge(gaugeName)", "hideToolBar": "hideToolBar(name)", "hideWindow": "hideWindow(name)", - "highlightRoom": "highlightRoom( roomID, color1Red, color1Green, color1Blue, color2Red, color2Green, color2Blue, highlightRadius, color1Alpha, color2Alpha)", + "highlightRoom": "highlightRoom(roomID, color1Red, color1Green, color1Blue, color2Red, color2Green, color2Blue, highlightRadius, color1Alpha, color2Alpha)", "hinsertLink": "hinsertLink([windowName], text, command, hint, true)", "hinsertPopup": "hinsertPopup([windowName], text, {commands}, {hints}, [useCurrentFormatElseDefault])", "holdingModifiers": "holdingModifiers(number)", @@ -444,7 +444,7 @@ "selectCaptureGroup": "selectCaptureGroup(groupNumber)", "selectCmdLineText": "selectCmdLineText([commandLine])", "selectCurrentLine": "selectCurrentLine([windowName])", - "selectSection": "selectSection( [windowName], fromPosition, length )", + "selectSection": "selectSection([windowName], fromPosition, length)", "selectString": "selectString([windowName], text, number_of_match)", "send": "send(command, showOnScreen)", "sendAll": "sendAll([time delay], list of things to send, [echo back or not])", diff --git a/src/main.cpp b/src/main.cpp index 279e329c1..d28d36773 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -63,7 +63,6 @@ #include "TAccessibleConsole.h" #include "TAccessibleTextEdit.h" #include "FileOpenHandler.h" -#include "LsanSuppressions.h" #include "SentryWrapper.h" #include "utils.h" #include <QFileInfo> @@ -78,27 +77,6 @@ using namespace std::chrono_literals; -// These hooks are only consulted when the LeakSanitizer runtime is linked in -// (USE_SANITIZER builds, i.e. PTBs and testing builds); elsewhere they are two -// inert functions. They cannot be guarded with an "is ASAN on" macro check: -// Mudlet only applies -fsanitize=address at link time, so no compiler macro is -// set, and Qt's qcompilerdetection.h shims __has_feature to 0 on GCC anyway. - -// Embeds the suppression list into the binary so leak reports shown to users -// by testing/PTB builds exclude third-party noise (GPU drivers, font stack) -// with no LSAN_OPTIONS needed at runtime: -extern "C" const char* __lsan_default_suppressions() -{ - return mudletLsanSuppressions; -} - -// Without this, LeakSanitizer appends a "Suppressions used" summary to every -// clean exit, which reads like an error to users: -extern "C" const char* __lsan_default_options() -{ - return "print_suppressions=0"; -} - extern void qInitResources_mudlet(); extern void qInitResources_qm(); extern void qInitResources_additional_splash_screens(); diff --git a/src/mudlet-lua/lua/CoreMudlet.lua b/src/mudlet-lua/lua/CoreMudlet.lua index a5bbaacaa..0cbe1c854 100644 --- a/src/mudlet-lua/lua/CoreMudlet.lua +++ b/src/mudlet-lua/lua/CoreMudlet.lua @@ -539,10 +539,15 @@ if false then - --- Deletes an alias with the given name. If several aliases have this name, they'll all be deleted. + --- Deletes a tempAlias. Use the alias ID returned by tempAlias() as the name parameter. + --- This function returns true on success and false if the alias has already been killed + --- or is not a temporary alias. Note that non-temporary aliases that you have set up in + --- the GUI cannot be deleted with this function. Use disableAlias() to turn them on or off. --- --- @see killTimer --- @see killTrigger + --- + --- @return true or false function killAlias(name) end @@ -563,6 +568,10 @@ if false then --- Deletes a tempTrigger according to trigger ID. ID is a string value, not a number. + --- This function returns true on success and false if the trigger has already been killed + --- (or has used up its last firing) or is not a temporary trigger. Note that non-temporary + --- triggers that you have set up in the GUI cannot be deleted with this function. + --- Use disableTrigger() to turn them on or off. --- --- @see killAlias --- @see killTimer diff --git a/src/mudlet-lua/lua/DB.lua b/src/mudlet-lua/lua/DB.lua index 6cb910a81..afd038a2e 100644 --- a/src/mudlet-lua/lua/DB.lua +++ b/src/mudlet-lua/lua/DB.lua @@ -1146,10 +1146,10 @@ function db:fetch(sheet, query, order_by, descending) local sql = "SELECT * FROM " .. s_name if query then - if type(query) == "table" then + if type(query) == "table" and not query._isExp then sql = sql .. " WHERE " .. db:AND(unpack(query)) else - sql = sql .. " WHERE " .. query + sql = sql .. " WHERE " .. tostring(query) end end @@ -1202,10 +1202,10 @@ function db:aggregate(field, fn, query, distinct) if query then sql_chunks[#sql_chunks + 1] = "WHERE" - if type(query) == "table" then + if type(query) == "table" and not query._isExp then sql_chunks[#sql_chunks + 1] = db:AND(unpack(query)) else - sql_chunks[#sql_chunks + 1] = query + sql_chunks[#sql_chunks + 1] = tostring(query) end end @@ -1275,7 +1275,7 @@ function db:delete(sheet, query) assert(query, "must pass a query argument to db:delete()") if type(query) == "number" then query = "_row_id = " .. tostring(query) - elseif type(query) == "table" then + elseif type(query) == "table" and not query._isExp then assert(query._row_id, "Passed a non-result table to db:delete, need a _row_id field to continue.") query = "_row_id = " .. tostring(query._row_id) end @@ -1283,7 +1283,7 @@ function db:delete(sheet, query) local sql = "DELETE FROM " .. s_name if query ~= true then - sql = sql .. " WHERE " .. query + sql = sql .. " WHERE " .. tostring(query) end db:echo_sql(sql) @@ -1499,7 +1499,7 @@ function db:set(field, value, query) s_name, field.name, db:_coerce(field, value), - query + tostring(query) ) db:echo_sql(sql) @@ -1563,7 +1563,9 @@ end -- type of the specified field. Strings will be single-quoted (and single-quotes -- within will be properly escaped), numbers will be rendered properly, and such. function db:_coerce(field, value) - if type(value) == "table" and value._isNull then + if type(value) == "table" and value._isExp then + return value._expression + elseif type(value) == "table" and value._isNull then return "NULL" elseif field.type == "number" then return tonumber(value) or ("'" .. value .. "'") @@ -1775,6 +1777,20 @@ end +-- NOT LUADOC +-- The metatable for db:exp values. It renders as the raw expression text whenever +-- concatenated or stringified, so WHERE-position use (db:fetch, db:AND, db:OR, ...) +-- is unchanged, while db:_coerce recognises the _isExp marker and passes the raw +-- expression through (letting db:exp be used as a db:set value, not just in WHERE). +db.__Expression = { + __tostring = function(self) + return self._expression + end, + __concat = function(a, b) + return tostring(a) .. tostring(b) + end, +} + --- Returns the string as-is to the database. <br/><br/> --- --- Use this function with caution, but it is very useful in some circumstances. One of the most @@ -1797,7 +1813,7 @@ end --- --- @see db:fetch function db:exp(text) - return text + return setmetatable({ _expression = text, _isExp = true }, db.__Expression) end @@ -1827,6 +1843,10 @@ end --- --- @see db:fetch function db:OR(left, right) + -- coerce to strings so db:exp sentinels work here as well as plain expressions + left = tostring(left) + right = tostring(right) + if not string.starts(left, "(") then left = "(" .. left .. ")" end @@ -2067,16 +2087,16 @@ end function db.Database:_drop(s_name) local conn = db.__conn[self._db_name] - local schema = db.__schema[self._db_name] + local schema = db.__schema[self._db_name][s_name] - if schema.options._index then - for _, value in schema.options._index do - conn:execute("DROP INDEX IF EXISTS " .. db:_index_name(s_name, value)) + -- _index and _unique can each be a single column name (a string) or a list of + -- them, so normalise to a list before iterating to drop the matching indexes. + local index_groups = { schema.options._index, schema.options._unique } + for _, group in pairs(index_groups) do + if type(group) == "string" then + group = { group } end - end - - if schema.options._unique then - for _, value in schema.options._unique do + for _, value in pairs(group) do conn:execute("DROP INDEX IF EXISTS " .. db:_index_name(s_name, value)) end end diff --git a/src/mudlet-lua/lua/DateTime.lua b/src/mudlet-lua/lua/DateTime.lua index 49933f3f8..a16c8710e 100644 --- a/src/mudlet-lua/lua/DateTime.lua +++ b/src/mudlet-lua/lua/DateTime.lua @@ -125,14 +125,15 @@ function datetime:parse(source, format, as_epoch) dt.month = datetime._abbrev_month_names[m.abbrev_month_name:lower()] end - dt.day = m.day_of_month + dt.day = tonumber(m.day_of_month) if m.hour_12 then assert(m.ampm, "You must use %p (AM|PM) with %I (12-hour time)") - if m.ampm == "PM" then - dt.hour = 12 + tonumber(m.hour_12) - else - dt.hour = tonumber(m.hour_12) + -- 12-hour to 24-hour: 12 AM is 0, 12 PM is 12, so the 12 wraps to 0 before + -- the PM offset is added. The regex is caseless, so compare caselessly too. + dt.hour = tonumber(m.hour_12) % 12 + if m.ampm:upper() == "PM" then + dt.hour = dt.hour + 12 end else dt.hour = tonumber(m.hour_24) diff --git a/src/mudlet-lua/lua/GUIUtils.lua b/src/mudlet-lua/lua/GUIUtils.lua index 4702cf990..f60928a6e 100644 --- a/src/mudlet-lua/lua/GUIUtils.lua +++ b/src/mudlet-lua/lua/GUIUtils.lua @@ -332,7 +332,11 @@ function setGaugeWindow(windowName, gaugeName, x, y, show) windowName = windowName or "main" x = x or 0 y = y or 0 - show = show or true + -- `show or true` would turn an explicit false into true, since false is the + -- one value the `x or default` idiom cannot carry + if show == nil then + show = true + end assert(gaugesTable[gaugeName], "setGaugeWindow: no such gauge exists.") setWindow(windowName, gaugeName .. "_back", x, y, show) setWindow(windowName, gaugeName .. "_front", x, y, show) @@ -413,22 +417,21 @@ end --- Pads a hex number to ensure a minimum of 2 digits. --- ---- @usage Following command will returns "F0". +--- @usage Following command will return "0F". --- <pre> --- PadHexNum("F") --- </pre> function PadHexNum(incString) assert(type(incString) == 'string', 'PadHexNum: bad argument #1 type (expected string, got '..type(incString)..'!)') - local l_Return = incString - if tonumber(incString, 16) < 16 then - if tonumber(incString, 16) < 10 then - l_Return = "0" .. l_Return - elseif tonumber(incString, 16) > 10 then - l_Return = l_Return .. "0" - end + assert(tonumber(incString, 16) ~= nil, 'PadHexNum: bad argument #1 value (hex number as string expected, got "'..incString..'"!)') + + -- the pad goes on the front, and it is the width that decides whether one is + -- needed: "00" is already two digits wide even though its value is below 16 + if #incString < 2 then + return "0" .. incString end - return l_Return + return incString end @@ -614,6 +617,7 @@ function createConsole(windowName, consoleName, fontSize, charsPerLine, numberOf assert(type(consoleName) == 'string', 'createConsole: invalid type for consoleName (expected string, got '..type(consoleName)..'!)') assert(type(fontSize) == 'number', 'createConsole: invalid type for fontSize (expected number, got '..type(fontSize)..'!)') assert(type(charsPerLine) == 'number', 'createConsole: invalid type for charsPerLine (expected number, got '..type(charsPerLine)..'!)') + assert(charsPerLine >= 1, 'createConsole: charsPerLine must be 1 or more, got '..charsPerLine..'!') assert(type(numberOfLines) == 'number', 'createConsole: invalid type for numberOfLines (expected number, got '..type(numberOfLines)..'!)') assert(type(Xpos) == 'number', 'createConsole: invalid type for Xpos (expected number, got '..type(Xpos)..'!)') assert(type(Ypos) == 'number', 'createConsole: invalid type for Ypos (expected number, got '..type(Ypos)..'!)') @@ -2204,7 +2208,10 @@ function suffix(what, func, fgc, bgc, window) window = window or "main" func = insertFuncs[func] or func or insertText local length = utf8.len(getCurrentLine(window)) - moveCursor(window, length - 1, getLineNumber(window)) + moveCursor(window, length, getLineNumber(window)) + -- fg()/bg() also repaint whatever is selected in the window, so drop any + -- live selection first - only the text being added is meant to be coloured + deselect(window) if fgc then fg(window,fgc) end if bgc then bg(window,bgc) end func(window,what) @@ -2226,6 +2233,8 @@ function prefix(what, func, fgc, bgc, window) window = window or "main" func = insertFuncs[func] or func or insertText moveCursor(window, 0, getLineNumber(window)) + -- see suffix() - colouring must not leak onto the current selection + deselect(window) if fgc then fg(window,fgc) end if bgc then bg(window,bgc) end func(window,what) @@ -2239,7 +2248,7 @@ end function moveCursorUp(window, lines, keep_horizontal) if type(window) ~= "string" then lines, window, keep_horizontal = window, "main", lines end lines = tonumber(lines) or 1 - if not type(keep_horizontal) == "boolean" then keep_horizontal = false end + if type(keep_horizontal) ~= "boolean" then keep_horizontal = false end local curLine = getLineNumber(window) if not curLine then return nil, "window does not exist" end local x = 0 @@ -2254,7 +2263,7 @@ end function moveCursorDown(window, lines, keep_horizontal) if type(window) ~= "string" then lines, window, keep_horizontal = window, "main", lines end lines = tonumber(lines) or 1 - if not type(keep_horizontal) == "boolean" then keep_horizontal = false end + if type(keep_horizontal) ~= "boolean" then keep_horizontal = false end local curLine = getLineNumber(window) if not curLine then return nil, "window does not exist" end local x = 0 @@ -2379,7 +2388,12 @@ function getRoomNameOffset(room) local d = getRoomUserData(room, "room.ui_nameOffset") if d == nil or d == "" then return 0,0 end local split = {} - for w in string.gfind(d, '[%.%d]+') do split[#split+1] = tonumber(w) end + -- the minus has to be part of the match: T2DMap parses the same user data with + -- QString::toDouble(), so a negative offset the renderer honours must read + -- back negative here too. This still only scrapes numbers out of the string, + -- so malformed user data reads back as a plausible pair the renderer will not + -- draw - unchanged by the sign fix + for w in string.gfind(d, '%-?[%.%d]+') do split[#split+1] = tonumber(w) end if #split == 1 then return 0,split[1] end if #split >= 2 then return split[1],split[2] end return 0,0 @@ -2668,9 +2682,10 @@ end function scrollUp(window, lines) if type(window) ~= "string" then window, lines = "main", window end lines = tonumber(lines) or 1 - local numLines = getLastLineNumber(window) - if not numLines then return nil, "window does not exist" end + -- getScroll() is what actually answers nil for an unknown window; + -- getLastLineNumber() answers -1, so guarding on it never fires local curScroll = getScroll(window) + if not curScroll then return nil, "window does not exist" end scrollTo(window, math.max(curScroll - lines, 0)) end @@ -2680,10 +2695,11 @@ end function scrollDown(window, lines) if type(window) ~= "string" then window, lines = "main", window end lines = tonumber(lines) or 1 - local numLines = getLastLineNumber(window) - if not numLines then return nil, "window does not exist" end + -- see scrollUp() on why the guard is on getScroll() rather than on the line count local curScroll = getScroll(window) - scrollTo(window, math.min(curScroll + lines, numLines)) + if not curScroll then return nil, "window does not exist" end + -- getScroll() having answered means the window exists, so this cannot fail + scrollTo(window, math.min(curScroll + lines, getLastLineNumber(window))) end --[[ diff --git a/src/mudlet-lua/lua/IDManager.lua b/src/mudlet-lua/lua/IDManager.lua index 9d8c3f72e..441310e4f 100644 --- a/src/mudlet-lua/lua/IDManager.lua +++ b/src/mudlet-lua/lua/IDManager.lua @@ -93,7 +93,12 @@ function IDMgr:stopAllTimers() end function IDMgr:stopAllTriggers() - return self:stopAll("triggers") + -- named triggers live in two stores - registerNamedTrigger() fills "triggers" + -- and registerNamedRegexTrigger() fills "regexTriggers" - so stopping them all + -- has to walk both, exactly as deleteAllTriggers() does + local regex = self:stopAll("regexTriggers") + local substring = self:stopAll("triggers") + return regex and substring end function IDMgr:deleteAllEvents() @@ -175,7 +180,7 @@ end function IDMgr:emergencyStop() self:stopAll("events") self:stopAll("timers") - self:stopAll("triggers") + self:stopAllTriggers() return true end @@ -192,7 +197,9 @@ function IDMgr:getTimers() end function IDMgr:getTriggers() - local triggerNames = table.update(table.keys(self.triggers), table.keys(self.regexTriggers)) + -- substring and regex names live in separate 1..n arrays, so merge them by + -- value (table.n_union), not by index, or entries at the same index collide + local triggerNames = table.n_union(table.keys(self.triggers), table.keys(self.regexTriggers)) table.sort(triggerNames) return triggerNames end diff --git a/src/mudlet-lua/lua/Other.lua b/src/mudlet-lua/lua/Other.lua index 67b5bfc2f..4f2a2b688 100644 --- a/src/mudlet-lua/lua/Other.lua +++ b/src/mudlet-lua/lua/Other.lua @@ -152,31 +152,37 @@ end --- Table of functions used by permGroup to create the appropriate group, based on itemtype. +--- Each perm* binding raises a Lua error on failure (for example a missing parent) +--- rather than returning -1, so permGroup pcalls these and turns a raised error +--- into a false return. local group_creation_functions = { timer = function(name, parent) - return not (permTimer(name, parent, 0, "") == -1) + return permTimer(name, parent, 0, "") end, trigger = function(name, parent) - return not (permSubstringTrigger(name, parent, {}, "") == -1) + return permSubstringTrigger(name, parent, {}, "") end, alias = function(name, parent) - return not (permAlias(name, parent, "", "") == -1) + return permAlias(name, parent, "", "") end, key = function(name, parent) - return not (permKey(name, parent, -1, "") == -1) + return permKey(name, parent, -1, "") end, script = function(name, parent) - return not (permScript(name, parent, "", "") == -1) + return permScript(name, parent, "", "") end } --- Creates a group of a given type that will persist through sessions. --- --- @param name name of the item ---- @param itemtype type of the item - can be trigger, alias, or timer +--- @param itemtype type of the item - can be trigger, alias, timer, key, or script --- @param parent optional name of existing item which the new item --- will be created as a child of --- +--- @return true on success, or false plus an error message if the item could +--- not be created (for example when the named parent does not exist) +--- --- @usage --- <pre> --- --create a new trigger group @@ -193,7 +199,11 @@ function permGroup(name, itemtype, parent) assert(type(name) == "string", "permGroup: need a name for the new thing") parent = parent or "" assert(group_creation_functions[itemtype], "permGroup: " .. tostring(itemtype) .. " isn't a valid type") - return group_creation_functions[itemtype](name, parent) + local ok, err = pcall(group_creation_functions[itemtype], name, parent) + if not ok then + return false, err + end + return true end --- Appends code to an existing script @@ -565,9 +575,9 @@ function _comp(a, b) local a_size = 0 for k, v in pairs(a) do a_size = a_size + 1 - if not b[k] then - return false - end + -- A key missing from b is already caught by the _comp call below, whose + -- first check is a type comparison and so fails against nil. Testing + -- `not b[k]` here as well rejected a legitimate `false` value. if not _comp(v, b[k]) then return false end diff --git a/src/mudlet-lua/lua/TableUtils.lua b/src/mudlet-lua/lua/TableUtils.lua index c1ae31f3b..9fd9596e1 100644 --- a/src/mudlet-lua/lua/TableUtils.lua +++ b/src/mudlet-lua/lua/TableUtils.lua @@ -50,9 +50,10 @@ end --- --- @see display function printTable( map ) + assert(type(map) == 'table', 'printTable: bad argument #1 type (table expected, got '..type(map)..'!)') echo("-------------------------------------------------------\n"); for k, v in pairs( map ) do - echo( "key=" .. k .. " value=" .. v .. "\n" ) + echo( "key=" .. tostring(k) .. " value=" .. tostring(v) .. "\n" ) end echo("-------------------------------------------------------\n"); end @@ -60,7 +61,9 @@ end -- NOT LUADOC --- This is supporting function for printTable(). +-- Prints a single key/value pair into the main console at the cursor. Named as +-- a helper for printTable(), but printTable() has never called it and formats +-- its own lines; kept because it is reachable from scripts. function __printTable( k, v ) insertText("\nkey = " .. tostring(k) .. " value = " .. tostring( v ) ) end @@ -135,9 +138,10 @@ end --- @see display --- @see printTable function listPrint( map ) + assert(type(map) == 'table', 'listPrint: bad argument #1 type (table expected, got '..type(map)..'!)') echo("-------------------------------------------------------\n"); for k, v in ipairs( map ) do - echo( k .. ". ) " .. v .. "\n" ); + echo( k .. ". ) " .. tostring(v) .. "\n" ); end echo("-------------------------------------------------------\n"); end @@ -153,8 +157,10 @@ end --- <b><u>TODO</u></b> listRemove( list, what ) function listRemove( list, what ) - for k, v in ipairs( list ) do - if v == what then + -- iterate backwards so removing an element does not shift a following match + -- down into an index the loop has already passed + for k = #list, 1, -1 do + if list[k] == what then table.remove( list, k ) end end @@ -243,7 +249,11 @@ function table.n_collect(tbl, func) assert(func_type == "function", string.format("table.n_collect: bad argument #2 type (function to run against each item in tbl as function expected, got %s)", func_type)) local matches = {} for key,value in pairs(tbl) do - if func(value) == true and not table.contains(matches, value) then + -- table.contains matches keys and nested values too, so a value equal to + -- an index already in `matches` looked like a duplicate. table.index_of + -- compares by value over ipairs, which is the semantics a list of unique + -- values needs, and is what the sibling table.n_matches already uses. + if func(value) == true and not table.index_of(matches, value) then table.insert(matches, value) end end @@ -348,20 +358,31 @@ end --- ["test2"] = function() return true end, --- } --- </pre> +--- +--- When several tables hold different values for the same key, those values are +--- collected into a new subtable, and any further collision on that key is +--- appended to it. The tables you pass in are never modified. function table.union(...) local sets = { ... } local union = {} + -- `pairs()` never yields a nil value, so `union[key] == nil` is an exact + -- presence test and stays correct for a legitimate `false`. `merged` tracks + -- which keys hold a subtable that we created, so a table that came from a + -- caller is never appended to -- doing that both flattened the result and + -- modified the caller's table in place. + local merged = {} for _, set in ipairs(sets) do for key, val in pairs(set) do - if union[key] and union[key] ~= val then - if type(union[key]) == 'table' then + if union[key] == nil then + union[key] = val + elseif union[key] ~= val then + if merged[key] then table.insert(union[key], val) else union[key] = { union[key], val } + merged[key] = true end - else - union[key] = val end end end diff --git a/src/mudlet-lua/lua/enable-accessibility/config.lua b/src/mudlet-lua/lua/enable-accessibility/config.lua deleted file mode 100644 index f92effcf8..000000000 --- a/src/mudlet-lua/lua/enable-accessibility/config.lua +++ /dev/null @@ -1,12 +0,0 @@ -mpackage = [[enable-accessibility]] -author = [[Mudlet]] -title = [[Enables better accessibility on demand]] -description = [[`mudlet accessibility on` will toggle a few settings for a better visually impaired experience: - -* auto clear input line after sent text -* disable showing sent text -* hide blank lines (on windows) -* offer workaround VoiceOver announce issue (on macOS) -* set Ctrl+Tab as a shortcut to enable caret mode]] -version = [[1.0]] -created = "2022-07-31T19:25:07+02:00" diff --git a/src/mudlet-lua/lua/geyser/GeyserAdjustableContainer.lua b/src/mudlet-lua/lua/geyser/GeyserAdjustableContainer.lua index 3d36132e8..dee8c8fc4 100644 --- a/src/mudlet-lua/lua/geyser/GeyserAdjustableContainer.lua +++ b/src/mudlet-lua/lua/geyser/GeyserAdjustableContainer.lua @@ -60,8 +60,10 @@ end -- @param format A format list to use. 'c' - center, 'l' - left, 'r' - right, 'b' - bold, 'i' - italics, 'u' - underline, 's' - strikethrough, '##' - font size. For example, "cb18" specifies center bold 18pt font be used. Order doesn't matter. function Adjustable.Container:setTitle(text, color, format) self.titleFormat = format or self.titleFormat or "l" - self.titleText = text or self.titleText or string.format("%s - Adjustable Container") - self.titleTxtColor = color or self.titleTxtColor or "green" + self.titleText = text or self.titleText or string.format("%s - Adjustable Container", self.name) + -- the fallback is only reached once resetTitle() has cleared the colour, so + -- it has to be the constructor's default for a reset to restore it + self.titleTxtColor = color or self.titleTxtColor or "grey" if self.locked and (self.connectedContainers or self.lockStyle == "standard" or self.lockStyle == "border" or self.lockStyle == "full") then return end @@ -407,6 +409,14 @@ end function Adjustable.Container:attachToBorder(border) if self.attached then self:detach() end Adjustable.Container.Attached[border] = Adjustable.Container.Attached[border] or {} + -- the registry is keyed by name, so a still live container of the same name + -- has to be taken off the border properly instead of being dropped from it: + -- it would otherwise go on believing it is attached while nothing reserves + -- a border for it, and its own detach() would then delete our entry + local superseded = Adjustable.Container.Attached[border][self.name] + if superseded and superseded ~= self then + superseded:detach() + end Adjustable.Container.Attached[border][self.name] = self self.attached = border self:adjustBorder() @@ -417,8 +427,11 @@ end --- detaches the given container -- this means the mudlet main window border will be reset function Adjustable.Container:detach() - if Adjustable.Container.Attached and Adjustable.Container.Attached[self.attached] then - Adjustable.Container.Attached[self.attached][self.name] = nil + -- a container of the same name may have taken over the registration, so + -- only unregister while it is still ours - the same guard type_delete uses + local attachedTo = Adjustable.Container.Attached and Adjustable.Container.Attached[self.attached] + if attachedTo and attachedTo[self.name] == self then + attachedTo[self.name] = nil end self.borderSize = nil self:resetBorder(self.attached) @@ -857,6 +870,81 @@ function Adjustable.Container:reposition() ) end +-- internal function: a container recreated under the same name builds its labels +-- with the same widget names, so deleting the stale object must not take the live +-- container's widgets with it +-- @param label the label to delete if it is still the registered one +local function deleteIfStillRegistered(label) + -- ask the container the label was added to: menu labels of a container that + -- lives in a user window are registered there, not in Geyser.windowList + local windowList = label and label.delete and label.container and label.container.windowList + if windowList and windowList[label.name] == label then + label:delete() + end +end + +-- internal function to delete the "More..." labels doNestShow adds to a menu that +-- does not fit on screen. They are kept in Geyser.Label.scrollV/scrollH, keyed by +-- the menu they scroll, rather than in the menu's own MenuLabels. +-- @param label the menu label whose scroll labels are to be deleted +local function deleteScrollLabels(label) + for _, cache in pairs({vertical = Geyser.Label.scrollV, horizontal = Geyser.Label.scrollH}) do + local scrollLabels = cache[label] + if scrollLabels then + cache[label] = nil + for _, scrollLabel in ipairs(scrollLabels) do + deleteIfStillRegistered(scrollLabel) + end + end + end +end + +-- internal function to delete the labels of a right click menu and of all its submenus. +-- Menu labels are created as top level Geyser objects rather than as children of +-- the menu they belong to, so Geyser.Container:delete()'s cascade never reaches them. +-- @param menu the menu label whose MenuLabels are to be deleted +local function deleteMenuLabels(menu) + if not menu or not menu.MenuLabels then + return + end + local menuLabels = menu.MenuLabels + menu.MenuLabels = {} + deleteScrollLabels(menu) + for _, label in pairs(menuLabels) do + deleteMenuLabels(label) + deleteIfStillRegistered(label) + end +end + +-- internal function called by Geyser.Container:delete() to clean up what the +-- delete cascade cannot reach: the right click menu labels, the event handlers +-- that keep firing on a deleted container, and the container's entries in +-- Adjustable.Container's own bookkeeping +function Adjustable.Container:type_delete() + deleteMenuLabels(self.adjLabel and self.adjLabel.rightClickMenu) + -- detach() also kills the resize handler and drops the container out of + -- Adjustable.Container.Attached, which otherwise keeps reserving a border + if self.attached then + self:detach() + end + self:disconnect() + -- not disableAutoSave(), which kills an already nil handler and errors + if self.autoSaveHandler then + killAnonymousEventHandler(self.autoSaveHandler) + self.autoSaveHandler = nil + end + self.autoSave = false + -- a container recreated under the same name has taken over the registration, + -- so only unregister while it is still ours + if Adjustable.Container.all[self.name] == self then + Adjustable.Container.all[self.name] = nil + local index = table.index_of(Adjustable.Container.all_windows, self.name) + if index then + table.remove(Adjustable.Container.all_windows, index) + end + end +end + --- deletes the file where your saved settings are stored -- @param dir defines directory where the saved file is in [optional] -- @see Adjustable.Container:save @@ -1040,7 +1128,7 @@ end --@param cons.attLabel.txt text of the "attached menu" item --@param cons.lockStylesLabel.txt text of the "lockstyle menu" item --@param cons.customItemsLabel.txt text of the "custom menu" item ---@param[opt="green"] cons.titleTxtColor color of the title text +--@param[opt="grey"] cons.titleTxtColor color of the title text --@param cons.titleText title text --@param cons.titleFormat a format list to use. 'c' - center, 'l' - left, 'r' - right, 'b' - bold, 'i' - italics, 'u' - underline, 's' - strikethrough, '##' - font size. --@param[opt="standard"] cons.lockStyle choose lockstyle at creation. possible integrated lockstyle are: "standard", "border", "light" and "full" diff --git a/src/mudlet-lua/lua/geyser/GeyserButton.lua b/src/mudlet-lua/lua/geyser/GeyserButton.lua index b4aa5b5e4..b71ee7a7a 100644 --- a/src/mudlet-lua/lua/geyser/GeyserButton.lua +++ b/src/mudlet-lua/lua/geyser/GeyserButton.lua @@ -52,7 +52,12 @@ function Geyser.Button:new(cons, container) local me = self.parent:new(cons, container) setmetatable(me, self) me:setClickCallback(function() me:press() end) - me:setState(me.state) + -- an unusable state constraint would otherwise leave the button holding it + -- and never painted, since setState returns before drawing anything + if not me:setState(me.state) then + me.state = "up" + me:setState("up") + end me:resize() -- to pick up the Geyser.Button default size rather than Geyser.Label's return me end @@ -70,6 +75,9 @@ function Geyser.Button:setState(state) if state ~= "up" and state ~= "down" then return nil, f"bad argument #1 value (state must be one of 'up' or 'down', got {state})" end + if state == "down" and not self.twoState then + return nil, "cannot set a single state button's state to 'down', only 'up'" + end self.state = state if state == "up" then self:echo(self.msg) @@ -85,9 +93,6 @@ function Geyser.Button:setState(state) self:setToolTip(self.tooltip, self.toolTipDuration) return true end - if not self.twoState then - return nil, "cannot set a single state button's state to 'down', only 'up'" - end self:echo(self.downMsg) if self.downStyle then if type(self.downStyle) == "table" then @@ -99,6 +104,9 @@ function Geyser.Button:setState(state) self.parent.setColor(self, self.downColor) end self:setToolTip(self.downTooltip, self.toolTipDuration) + -- both branches report success, otherwise a legitimate 'down' is + -- indistinguishable from the refusals above + return true end --- Handles clicking the button. If the button is twoState, also handles switching the button's state diff --git a/src/mudlet-lua/lua/geyser/GeyserContainer.lua b/src/mudlet-lua/lua/geyser/GeyserContainer.lua index 710eb5f01..552c8a0ce 100644 --- a/src/mudlet-lua/lua/geyser/GeyserContainer.lua +++ b/src/mudlet-lua/lua/geyser/GeyserContainer.lua @@ -372,6 +372,9 @@ function Geyser.Container:new(cons, container) local w, h = getUserWindowSize(me.windowname) return h end + -- so the user window can take this container with it when it is deleted + -- without having to guess at the container by its name + me.rootContainer = container end end @@ -379,6 +382,17 @@ function Geyser.Container:new(cons, container) return me end +-- Internal function: deletes a container's children. A named function rather +-- than an inline loop so that delete() can pcall it without allocating a closure +-- @param container the container whose children are to be deleted +local function deleteChildren(container) + for _, child in pairs(container.windowList) do + if child and child.delete then + child:delete() + end + end +end + --- Deletes this window and removes it from its container's tracking. -- Recursively deletes all child windows first. -- Properly unregisters from all tracking structures including: @@ -386,13 +400,23 @@ end -- - Geyser.parentWindows (for UserWindows and ScrollBoxes) -- - Geyser.windowList (for top-level Geyser objects) function Geyser.Container:delete() - -- Delete all children first - for _, child in pairs(self.windowList) do - if child and child.delete then - child:delete() - end + -- An HBox/VBox lays itself out whenever a child unlinks, so deleting children + -- one at a time costs a layout pass per child, every one of them laying out + -- windows the same loop goes on to destroy. Only self needs the flag: each + -- container in the cascade defers itself when its own delete runs. rawget, so + -- a container inheriting the flag from Geyser goes back to inheriting it. + local wasDeferring = rawget(self, "defer_updates") + self.defer_updates = true + local ok, err = pcall(deleteChildren, self) + self.defer_updates = wasDeferring + if not ok then + -- a container whose cascade failed stays in the tree, holding whatever + -- children the cascade did not reach - and they are still laid out for the + -- child count it started with, so it owes them the pass that was deferred + self:reposition() + error(err, 0) end - + -- Clear references self.windowList = {} self.windows = {} diff --git a/src/mudlet-lua/lua/geyser/GeyserGauge.lua b/src/mudlet-lua/lua/geyser/GeyserGauge.lua index 6e4006ccc..47062c186 100644 --- a/src/mudlet-lua/lua/geyser/GeyserGauge.lua +++ b/src/mudlet-lua/lua/geyser/GeyserGauge.lua @@ -21,47 +21,153 @@ Geyser.Gauge = Geyser.Container:new({ strict = false, orientation = "horizontal" }) ---- Helper function to extract spacing values (margin/border/padding) from CSS --- @param css The CSS string to parse --- @param property The property name to extract (e.g., "margin", "border", "padding") --- @return left, right, top, bottom spacing values in pixels, or 0 if not found -local function extractCSSSpacing(css, property) - if not css then return 0, 0, 0, 0 end - - -- Look for the property (e.g., "margin: 10px 30px;") - local pattern = property .. "%s*:%s*([^;]+)" - local value = css:match(pattern) - - if not value then return 0, 0, 0, 0 end - - -- Parse the values - CSS can have 1-4 values - local values = {} - for num in value:gmatch("(%d+%.?%d*)px") do - table.insert(values, tonumber(num)) +-- Reads one CSS token as a length in pixels. Qt reads a bare number as pixels +-- and a negative length is meaningful, so both are taken; a unit that has no +-- pixel value without knowing the font or the parent - em, %, pt - has none to +-- give here, and neither has a keyword such as "solid". +-- @param token The token to read +-- @return the length in pixels, or nil if the token is not a pixel length +local function pixelLength(token) + local number, unit = token:match("^([+-]?%d*%.?%d+)(.*)$") + if not number then + return nil end - - -- Handle border specially - extract width from "border: 2px solid color" - if property == "border" and #values == 0 then - local borderWidth = value:match("(%d+%.?%d*)px") - if borderWidth then - values = {tonumber(borderWidth)} + unit = unit:lower() + if unit ~= "" and unit ~= "px" then + return nil + end + return tonumber(number) +end + +-- Takes CSS comments out, so a commented out declaration is not read as a live +-- one. +-- @param css The CSS string to clean +-- @return the string without its comments +local function withoutComments(css) + return (css:gsub("/%*.-%*/", "")) +end + +-- Finds the value of a CSS declaration. The property has to start a word of its +-- own, or "qproperty-margin" would be read as a margin, and the value ends at a +-- block brace as well as at a semicolon, so an unterminated +-- "QLabel { margin: 4px }" does not carry the brace into the value. +-- Only the first declaration of a property is read, so a stylesheet that sets +-- one twice, or sets one inside a state block such as ":hover", is read from +-- whichever comes first rather than by the CSS cascade. +-- @param css The CSS string to search, with its comments already taken out +-- @param property The property name +-- @return the value, lower cased and trimmed, or nil +local function cssValue(css, property) + local value = css:match("%f[%w%-]" .. property:gsub("%-", "%%-") .. "%s*:%s*([^;{}]+)") + if not value then + return nil + end + -- "!important" marks the declaration's priority and is not part of its value. + -- Lower casing lets an upper case unit be read and costs nothing else: every + -- part of these values that is kept is a number or a unit. + value = value:lower():gsub("!%s*important", "") + return value:match("^%s*(.-)%s*$") +end + +-- Reads a one to four value CSS box shorthand into its four sides. +-- @param value The declaration value, or nil +-- @return top, right, bottom, left, or nil if any part of the value is not a +-- pixel length: half a shorthand is worse than none, because the +-- lengths that are left would be read in the wrong positions +local function boxShorthand(value) + if not value then + return nil + end + local lengths = {} + for token in value:gmatch("%S+") do + local length = pixelLength(token) + if not length then + return nil + end + lengths[#lengths + 1] = length + end + if #lengths == 0 or #lengths > 4 then + return nil + end + local top, right = lengths[1], lengths[2] or lengths[1] + return top, right, lengths[3] or top, lengths[4] or right +end + +-- Reads the width out of a CSS border shorthand such as "2px solid red", where +-- only the first length is a width and the rest describes the line. +-- @param value The declaration value, or nil +-- @return the width in pixels, or nil +local function borderWidth(value) + if not value then + return nil + end + for token in value:gmatch("%S+") do + local length = pixelLength(token) + if length then + return length end end - - if #values == 0 then - return 0, 0, 0, 0 - elseif #values == 1 then - -- All sides same - return values[1], values[1], values[1], values[1] - elseif #values == 2 then - -- top/bottom, left/right - return values[2], values[2], values[1], values[1] - elseif #values == 4 then - -- top, right, bottom, left - return values[4], values[2], values[1], values[3] - else - return 0, 0, 0, 0 + return nil +end + +--- Helper function to extract spacing values (margin/border/padding) from CSS +-- Shorthands are read first and longhands over the top of them, so +-- "margin: 5px; margin-left: 20px" gives 20 on the left and 5 elsewhere. This +-- is not the CSS cascade: a longhand wins over a shorthand whichever order they +-- are written in. +-- @param css The CSS string to parse +-- @param property The property name to extract ("margin", "border" or "padding") +-- @return left, right, top, bottom spacing values in pixels, 0 for each side +-- the stylesheet says nothing measurable about, and the text of the +-- first declaration that held a length with no pixel reading +local function extractCSSSpacing(css, property) + if not css then return 0, 0, 0, 0 end + css = withoutComments(css) + local spacing = {top = 0, right = 0, bottom = 0, left = 0} + local border = property == "border" + local unreadable + + -- Reads one declaration. reportsUnreadable says whether a value the reader + -- could make nothing of is worth telling the user about: it is for a length, + -- and it is not for a border shorthand, where "border: none" legitimately + -- names no width at all. + local function read(declaration, reader, reportsUnreadable) + local value = cssValue(css, declaration) + if not value then + return nil + end + local first, right, bottom, left = reader(value) + if not first and reportsUnreadable then + unreadable = unreadable or (declaration .. ": " .. value) + end + return first, right, bottom, left end + + if border then + local width = read("border", borderWidth, false) + if width then + spacing.top, spacing.right, spacing.bottom, spacing.left = width, width, width, width + end + end + + local top, right, bottom, left = read(border and "border-width" or property, boxShorthand, true) + if top then + spacing.top, spacing.right, spacing.bottom, spacing.left = top, right, bottom, left + end + + for _, side in ipairs({"top", "right", "bottom", "left"}) do + local length + if border then + length = read("border-" .. side, borderWidth, false) or read("border-" .. side .. "-width", pixelLength, true) + else + length = read(property .. "-" .. side, pixelLength, true) + end + if length then + spacing[side] = length + end + end + + return spacing.left, spacing.right, spacing.top, spacing.bottom, unreadable end --- Sets the gauge amount. @@ -70,9 +176,24 @@ end -- used to set the gauge. -- @param maxValue Maximum numeric value. Optionally nil, see above. -- @param text The text to display on the gauge, it is optional. +-- @return true, or nil and a message if maxValue has no reading to give function Geyser.Gauge:setValue (currentValue, maxValue, text) assert(type(currentValue) == "number", string.format("bad argument #1 type (currentValue as number expected, got %s!)", type(currentValue))) assert(maxValue == nil or type(maxValue) == "number", string.format("bad argument #2 type (optional maxValue as number expected, got %s!)", type(maxValue))) + -- A zero, negative or NaN maximum has no sensible reading: dividing by it leaves + -- the gauge on an infinite or negative value that sticks until the next good + -- call. Games do report these while a stat is still unknown, so refuse the + -- reading and leave the gauge as it was rather than aborting the caller. + if maxValue ~= nil and not (maxValue > 0) then + local message = string.format("Geyser.Gauge:setValue: bad argument #2 value (maxValue must be a positive number, got %s!) - gauge '%s' was left as it was", tostring(maxValue), self.name) + -- latched, because a game that reports a bad maximum reports it every prompt + if not self.warnedBadMaxValue then + self.warnedBadMaxValue = true + debugc(message) + end + return nil, message + end + self.warnedBadMaxValue = nil -- Use sensible defaults for missing parameters. if currentValue < 0 then currentValue = 0 @@ -90,24 +211,39 @@ function Geyser.Gauge:setValue (currentValue, maxValue, text) local leftOffset, rightOffset, topOffset, bottomOffset = 0, 0, 0, 0 if self.backCSS then - local ml, mr, mt, mb = extractCSSSpacing(self.backCSS, "margin") - local bl, br, bt, bb = extractCSSSpacing(self.backCSS, "border") - local pl, pr, pt, pb = extractCSSSpacing(self.backCSS, "padding") - + local ml, mr, mt, mb, marginUnreadable = extractCSSSpacing(self.backCSS, "margin") + local bl, br, bt, bb, borderUnreadable = extractCSSSpacing(self.backCSS, "border") + local pl, pr, pt, pb, paddingUnreadable = extractCSSSpacing(self.backCSS, "padding") + leftOffset = ml + bl + pl rightOffset = mr + br + pr topOffset = mt + bt + pt bottomOffset = mb + bb + pb + + -- Qt still applies a spacing Geyser cannot measure, so the fill bar ends up + -- laid out against the wrong box and spills past the gauge's frame. Say so + -- rather than leave it looking like a Geyser bug - latched, because setValue + -- runs on every prompt. + local unreadable = marginUnreadable or borderUnreadable or paddingUnreadable + if unreadable and not self.warnedUnreadableCSS then + self.warnedUnreadableCSS = true + debugc(string.format( + "Geyser.Gauge: gauge '%s' has a stylesheet spacing Geyser cannot measure in pixels (%s), so it is left out of the fill bar's position - use px or unitless lengths", + self.name, unreadable)) + end end -- Update gauge in the requested orientation -- Note: We use function-based constraints for dynamic sizing that accounts for margins -- The front label can have its own borders and padding (margins are stripped in setStyleSheet) -- Qt applies border/padding outside the widget's content area, so we don't need to compensate for them - + -- The offsets are given as functions rather than as "<n>px": a negative pixel + -- constraint is measured from the opposite edge, which is not what a negative + -- margin asks for + if self.orientation == "horizontal" then -- Position the front label inside the back's content area - self.front:move(leftOffset .. "px", topOffset .. "px") + self.front:move(function() return leftOffset end, function() return topOffset end) -- For width: we want value% of the CONTENT width (back label's content area) -- Content width = back_label_width - leftOffset - rightOffset local totalBackOffset = leftOffset + rightOffset @@ -123,7 +259,7 @@ function Geyser.Gauge:setValue (currentValue, maxValue, text) local totalBackOffset = topOffset + bottomOffset local gaugeValue = self.value self.front:move( - leftOffset .. "px", + function() return leftOffset end, function() return topOffset + math.floor((self.back.get_height() - totalBackOffset) * (1 - gaugeValue / 100) + 0.5) end ) self.front:resize( @@ -138,14 +274,14 @@ function Geyser.Gauge:setValue (currentValue, maxValue, text) local gaugeValue = self.value self.front:move( function() return leftOffset + math.floor((self.back.get_width() - totalBackOffset) * (1 - gaugeValue / 100) + 0.5) end, - topOffset .. "px" + function() return topOffset end ) self.front:resize( function() return math.floor((self.back.get_width() - totalBackOffset) * (gaugeValue / 100) + 0.5) end, function() return math.floor(self.back.get_height() - topOffset - bottomOffset + 0.5) end ) else -- batty (top to bottom) - self.front:move(leftOffset .. "px", topOffset .. "px") + self.front:move(function() return leftOffset end, function() return topOffset end) local totalBackOffset = topOffset + bottomOffset local gaugeValue = self.value self.front:resize( @@ -157,6 +293,7 @@ function Geyser.Gauge:setValue (currentValue, maxValue, text) if text then self.text:echo(text) end + return true end --- Sets the gauge color. @@ -262,15 +399,23 @@ function Geyser.Gauge:setStyleSheet(css, cssback, cssText) self.frontCSS = css self.backCSS = cssback or css self.textCSS = cssText + self.warnedUnreadableCSS = nil -- Apply back stylesheet normally (this has margins/borders/padding) self.back:setStyleSheet(self.backCSS) -- For the front label, strip ONLY margins (borders and padding are safe and allow styling) -- Margins on the front label cause positioning issues, but borders/padding are fine + -- the trailing semicolon is optional: the last declaration in a stylesheet + -- usually carries none, and a margin left on the front label is applied on + -- top of the offset already worked out from the back label, doubling it. + -- The frontier keeps qproperty-margin, which is not a margin, out of it, and + -- the excluded braces and star stop a declaration that carries no semicolon + -- from eating the end of its block or of a comment, which would leave Qt an + -- unparseable sheet and the front label with no styling at all. local frontCSSStripped = css if frontCSSStripped then - frontCSSStripped = frontCSSStripped:gsub("%s*margin[^;]*;", "") + frontCSSStripped = frontCSSStripped:gsub("%s*%f[%w%-]margin[^;{}%*]*;?", "") end self.front:setStyleSheet(frontCSSStripped) diff --git a/src/mudlet-lua/lua/geyser/GeyserHBox.lua b/src/mudlet-lua/lua/geyser/GeyserHBox.lua index f6bc841b9..f35946258 100644 --- a/src/mudlet-lua/lua/geyser/GeyserHBox.lua +++ b/src/mudlet-lua/lua/geyser/GeyserHBox.lua @@ -8,6 +8,19 @@ Geyser.HBox = Geyser.Container:new({ name = "HBoxClass" }) +-- Internal function: lays the box out, or remembers that it still has to be laid +-- out when updates are being deferred, so that the reposition end_update() runs +-- picks the work up again. A box being deleted defers as well and never gets +-- that reposition, which is deliberate - it has no layout left worth doing. +-- @param box the HBox to organize +local function organizeOrDefer(box) + if box.defer_updates then + box.pending_organize = true + else + box:organize() + end +end + function Geyser.HBox:add (window, cons) -- VBox/HBox have their own add function therefore passing off add2 should be possible without -- overwriting their add functions @@ -16,13 +29,27 @@ function Geyser.HBox:add (window, cons) else Geyser.add(self, window, cons) end - if not self.defer_updates then - self:organize() - end + organizeOrDefer(self) +end + +-- add2 has to be overridden as well, otherwise children created with new2 reach +-- Geyser.add2 directly and the box never lays them out +function Geyser.HBox:add2 (window, cons, passAdd2, exclude) + Geyser.add2(self, window, cons, passAdd2, exclude) + organizeOrDefer(self) +end + +-- The base remove only edits the bookkeeping, so without this the survivors +-- keep the geometry that was worked out for the old child count and the box is +-- left with a hole. Every removal path - delete, changeContainer, adding a +-- child to another container - comes through here. +function Geyser.HBox:remove (window) + Geyser.remove(self, window) + organizeOrDefer(self) end --- Responsible for organizing the elements inside the HBox --- Called when a new element is added +-- Called when an element is added or removed function Geyser.HBox:organize() local self_height = self:get_height() local self_width = self:get_width() @@ -61,8 +88,13 @@ end function Geyser.HBox:reposition() Geyser.Container.reposition(self) - if self.contains_fixed then + -- contains_fixed prevents gaps when items have fixed size and is deliberately + -- not deferred, pending_organize + -- flushes a layout that was skipped while updates were deferred. Clearing it + -- only after organize() keeps the work queued if organize() throws. + if self.contains_fixed or (self.pending_organize and not self.defer_updates) then self:organize() + self.pending_organize = nil end end diff --git a/src/mudlet-lua/lua/geyser/GeyserMapper.lua b/src/mudlet-lua/lua/geyser/GeyserMapper.lua index de438c83e..b559a7cc1 100644 --- a/src/mudlet-lua/lua/geyser/GeyserMapper.lua +++ b/src/mudlet-lua/lua/geyser/GeyserMapper.lua @@ -59,6 +59,16 @@ function Geyser.Mapper:show_impl() createMapper(self.windowname, self:get_x(), self:get_y(), self:get_width(), self:get_height()) else openMapWidget() + -- A title only reaches a map window that is on screen, so one this mapper + -- set while it was hidden has to be applied now instead. Only one that did + -- not land: an unconditional apply here would overwrite a title set through + -- setMapWindowTitle() directly every time any mapper is shown. An empty + -- titleText is a reset rather than "no title", which is why this tracks + -- whether the call failed instead of whether the text is empty. + if self.titlePending then + self.titlePending = false + setMapWindowTitle(self.titleText) + end end end @@ -79,12 +89,18 @@ end function Geyser.Mapper:setTitle(text) self.titleText = text - return setMapWindowTitle(text) + local applied, message = setMapWindowTitle(text) + self.titlePending = not applied + return applied, message end function Geyser.Mapper:resetTitle() self.titleText = "" - return resetMapWindowTitle() + -- resetMapWindowTitle() is setMapWindowTitle(""), so show_impl applying + -- titleText covers a reset as well + local applied, message = resetMapWindowTitle() + self.titlePending = not applied + return applied, message end -- Overridden constructor diff --git a/src/mudlet-lua/lua/geyser/GeyserMiniConsole.lua b/src/mudlet-lua/lua/geyser/GeyserMiniConsole.lua index f46ce4d51..0af4900cb 100644 --- a/src/mudlet-lua/lua/geyser/GeyserMiniConsole.lua +++ b/src/mudlet-lua/lua/geyser/GeyserMiniConsole.lua @@ -73,14 +73,20 @@ function Geyser.MiniConsole:getFont() end --- Sets the point at which text is wrapped in this miniconsole unless autoWrap is on --- @param wrapAt The number of characters to start wrapping. +-- @param wrapAt The number of characters to start wrapping. Must be 1 or more. +-- @return true, or nil and an error message if the wrap was not applied. function Geyser.MiniConsole:setWrap (wrapAt) if self.autoWrap then return nil, "autoWrap is enabled in this MiniConsole and that overrides manual wrapping" end - if wrapAt then - self.wrapAt = wrapAt + -- only record the new wrap once Mudlet has accepted it, or a refused width + -- would be re-sent (and refused again) by every later call + local newWrap = wrapAt or self.wrapAt + local ok, err = setWindowWrap(self.name, newWrap) + if not ok then + return nil, err end - setWindowWrap(self.name, self.wrapAt) + self.wrapAt = newWrap + return true end function Geyser.MiniConsole:resetFormat() @@ -431,7 +437,9 @@ function Geyser.MiniConsole:resetAutoWrap() if self.scrollBar then consoleWidth = consoleWidth - 15 end - local charactersWidth = math.floor(consoleWidth / fontWidth) + -- a console narrower than one character (or one the scroll bar leaves no + -- room in) works out as zero columns, which is not a width to wrap at + local charactersWidth = math.max(1, math.floor(consoleWidth / fontWidth)) self.wrapAt = charactersWidth setWindowWrap(self.name, self.wrapAt) diff --git a/src/mudlet-lua/lua/geyser/GeyserReposition.lua b/src/mudlet-lua/lua/geyser/GeyserReposition.lua index c8a351ebd..79e0a820a 100644 --- a/src/mudlet-lua/lua/geyser/GeyserReposition.lua +++ b/src/mudlet-lua/lua/geyser/GeyserReposition.lua @@ -5,16 +5,39 @@ --- Responds to sysWindowResizeEvent and causes all windows managed -- by Geyser to update their sizes and positions. --- @param event a sysWindowResizeEvent or sysUserWindowResizeEvent event +-- Called without an event by Geyser:reposition(), which is how Geyser:end_update() +-- applies the layout it deferred; that call is meant for every window Geyser owns. +-- @param event a sysWindowResizeEvent or sysUserWindowResizeEvent event, or nil to reposition everything -- @param w the new width -- @param h the new height -- @param arg additional arguments function GeyserReposition(event, w, h, arg) - for _, window in pairs(Geyser.windowList) do - if event == "sysUserWindowResizeEvent" and window.type == "userwindow" and arg.."Container" == window.name then - window:reposition() - elseif event == "sysWindowResizeEvent" and window.type ~= "userwindow" then - window:reposition() + if event ~= nil and event ~= "sysWindowResizeEvent" and event ~= "sysUserWindowResizeEvent" then + -- otherwise a mistyped event name is indistinguishable from a no-op + debugc(string.format("GeyserReposition: ignoring the unknown event '%s'", tostring(event))) + return + end + if event == "sysUserWindowResizeEvent" and not arg then + debugc("GeyserReposition: sysUserWindowResizeEvent needs the name of the user window that was resized") + return + end + -- repositioning raises events, and a handler that creates or deletes a top + -- level window would be mutating windowList mid-traversal, so work off a + -- snapshot of the names and re-check each one is still there + local names = {} + for name in pairs(Geyser.windowList) do + names[#names + 1] = name + end + for _, name in ipairs(names) do + local window = Geyser.windowList[name] + if window then + if event == nil then + window:reposition() + elseif event == "sysUserWindowResizeEvent" and window.type == "userwindow" and arg.."Container" == window.name then + window:reposition() + elseif event == "sysWindowResizeEvent" and window.type ~= "userwindow" then + window:reposition() + end end end end diff --git a/src/mudlet-lua/lua/geyser/GeyserScrollBox.lua b/src/mudlet-lua/lua/geyser/GeyserScrollBox.lua index 7d3202c9c..9edac2f64 100644 --- a/src/mudlet-lua/lua/geyser/GeyserScrollBox.lua +++ b/src/mudlet-lua/lua/geyser/GeyserScrollBox.lua @@ -51,6 +51,13 @@ function Geyser.ScrollBox:new (cons, container) createScrollBox(me.windowname, me.name, me:get_x(), me:get_y(), me:get_width(), me:get_height()) + -- add2 asks a new widget to hide itself from inside Geyser.Container:new, + -- which runs before there is a widget to hide, so the hide has to be made + -- good here - as every other Geyser widget constructor does + if me.hidden or me.auto_hidden then + hideWindow(me.name) + end + --ScrollBox needs a special windowname handling as it by itself is a "window" --the given windowname will be saved to the parentWindowName variable me.parentWindowName = me.windowname diff --git a/src/mudlet-lua/lua/geyser/GeyserUserWindow.lua b/src/mudlet-lua/lua/geyser/GeyserUserWindow.lua index 0ea2e1a49..c48dcbe2d 100644 --- a/src/mudlet-lua/lua/geyser/GeyserUserWindow.lua +++ b/src/mudlet-lua/lua/geyser/GeyserUserWindow.lua @@ -49,8 +49,10 @@ function Geyser.UserWindow:resetWindow() end --Override show to keep the dimensions of the UserWindow -function Geyser.UserWindow:show() - self.Parent.show(self) +--@param auto passed on by a container showing its children, and used by the +-- base class to pick which of the two hidden flags to clear +function Geyser.UserWindow:show(auto) + self.Parent.show(self, auto) end --- Your UserWindow will be docked at position (pos): @@ -94,6 +96,30 @@ function Geyser.UserWindow:setStyleSheet(css) self.stylesheet = css end +--- Deletes the UserWindow, along with the root container Geyser made for it +function Geyser.UserWindow:type_delete() + local root = self.rootContainer + Geyser.MiniConsole.type_delete(self) + -- Geyser.Container:new gives every user window a "<name>Container" root + -- container. Left behind it is not inert: its get_width/get_height ask + -- getUserWindowSize for a window that is gone, which answers with the main + -- window's size, so the orphan claims all of it in every layout pass. + -- Unregistering it rather than calling delete() on it keeps this safe when + -- the user window is being deleted by that very container. + if not root or not root.container then + return + end + if table.is_empty(root.windowList) then + root.container:remove(root) + else + -- anything else put in the root container by hand is still using it, so it + -- has to stay - but it measures a user window that is about to be gone + debugc(string.format( + "Geyser.UserWindow: the root container of '%s' still holds other objects, so it is being left in place - it will report the main window's size from now on, because the user window it measured has been deleted", + self.name)) + end +end + Geyser.UserWindow.Parent = Geyser.Window --- Geyser UserWindow constructor @@ -128,6 +154,11 @@ function Geyser.UserWindow:new(cons) if me.restoreLayout then openUserWindow(me.name, me.restoreLayout, me.autoDock) + elseif me.docked == false then + -- this window is floated a few lines further down anyway, and docking it + -- first takes the dock's size off the main window straight away - which is + -- what the percentage constraints below would then be resolved against + openUserWindow(me.name, me.restoreLayout, me.autoDock, "floating") else openUserWindow(me.name, me.restoreLayout, me.autoDock, me.dockPosition) end diff --git a/src/mudlet-lua/lua/geyser/GeyserVBox.lua b/src/mudlet-lua/lua/geyser/GeyserVBox.lua index 40c021c3f..0780eb47c 100644 --- a/src/mudlet-lua/lua/geyser/GeyserVBox.lua +++ b/src/mudlet-lua/lua/geyser/GeyserVBox.lua @@ -8,6 +8,19 @@ Geyser.VBox = Geyser.Container:new({ name = "VBoxClass" }) +-- Internal function: lays the box out, or remembers that it still has to be laid +-- out when updates are being deferred, so that the reposition end_update() runs +-- picks the work up again. A box being deleted defers as well and never gets +-- that reposition, which is deliberate - it has no layout left worth doing. +-- @param box the VBox to organize +local function organizeOrDefer(box) + if box.defer_updates then + box.pending_organize = true + else + box:organize() + end +end + function Geyser.VBox:add (window, cons) -- VBox/HBox have their own add function therefore passing off add2 should be possible without -- overwriting their add functions @@ -16,14 +29,28 @@ function Geyser.VBox:add (window, cons) else Geyser.add(self, window, cons) end - - if not self.defer_updates then - self:organize() - end + + organizeOrDefer(self) +end + +-- add2 has to be overridden as well, otherwise children created with new2 reach +-- Geyser.add2 directly and the box never lays them out +function Geyser.VBox:add2 (window, cons, passAdd2, exclude) + Geyser.add2(self, window, cons, passAdd2, exclude) + organizeOrDefer(self) +end + +-- The base remove only edits the bookkeeping, so without this the survivors +-- keep the geometry that was worked out for the old child count and the box is +-- left with a hole. Every removal path - delete, changeContainer, adding a +-- child to another container - comes through here. +function Geyser.VBox:remove (window) + Geyser.remove(self, window) + organizeOrDefer(self) end --- Responsible for organizing the elements inside the VBox --- Called when a new element is added +-- Called when an element is added or removed function Geyser.VBox:organize() local self_height = self:get_height() local self_width = self:get_width() @@ -62,8 +89,13 @@ end function Geyser.VBox:reposition() Geyser.Container.reposition(self) - if self.contains_fixed then -- prevent gaps when items have fixed size + -- contains_fixed prevents gaps when items have fixed size and is deliberately + -- not deferred, pending_organize + -- flushes a layout that was skipped while updates were deferred. Clearing it + -- only after organize() keeps the work queued if organize() throws. + if self.contains_fixed or (self.pending_organize and not self.defer_updates) then self:organize() + self.pending_organize = nil end end diff --git a/src/mudlet-lua/lua/geyser/GeyserWindow.lua b/src/mudlet-lua/lua/geyser/GeyserWindow.lua index 47a8aaeec..1adf492fa 100644 --- a/src/mudlet-lua/lua/geyser/GeyserWindow.lua +++ b/src/mudlet-lua/lua/geyser/GeyserWindow.lua @@ -26,7 +26,7 @@ Geyser.Window = Geyser.Container:new({ --- Prints a message to the window -- @param message The message to print. Can contain html formatting. function Geyser.Window:echo(message) - self.message = message + self.message = message or self.message echo(self.name, self.message) end @@ -34,21 +34,24 @@ end -- @param message The message to print. Uses color formatting information - -- a message of "<red>Hi" would make 'Hi' red. function Geyser.Window:cecho(message) - self.message = message or self.message cecho(self.name, self.message) + self.message = message or self.message + cecho(self.name, self.message) end --- Prints a message to the window. -- @param message The message to print. Uses color formatting information - -- a message of "<255,0,0>Hi" would make 'Hi' red. function Geyser.Window:decho(message) - self.message = message or self.message decho(self.name, self.message) + self.message = message or self.message + decho(self.name, self.message) end --- Prints a message to the window. -- @param message The message to print. Uses color formatting information - -- a message of "|cff0000Hi" would make 'Hi' red. function Geyser.Window:hecho(message) - self.message = message or self.message hecho(self.name, self.message) + self.message = message or self.message + hecho(self.name, self.message) end --- Get the window's foreground color. diff --git a/src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.mpackage b/src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.mpackage deleted file mode 100644 index 7847f2faa..000000000 Binary files a/src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.mpackage and /dev/null differ diff --git a/src/mudlet-lua/tests/Alias_spec.lua b/src/mudlet-lua/tests/Alias_spec.lua index 99f8769ab..c4e2494d5 100644 --- a/src/mudlet-lua/tests/Alias_spec.lua +++ b/src/mudlet-lua/tests/Alias_spec.lua @@ -270,6 +270,37 @@ describe("Alias processing", function() assert.is_false(killAlias("no_such_alias_name"), "killing a missing alias should return false") end) + it("killAlias returns false the second time, as the alias is already dead", function() + local id = tempAlias("^spec_double_kill_alias$", [[]]) + assert.is_true(killAlias(id), "killing a live temporary alias should report success") + -- the alias is still present here: only the deferred cleanup frees it, so + -- the second kill really is being told about a corpse it can find + assert.are.equal(1, exists(id, "alias"), "the killed alias is still present until cleanup runs") + assert.are.equal(0, isActive(id, "alias"), "a killed alias is no longer active") + assert.is_false(killAlias(id), + "killing an already killed alias achieves nothing and has to say so") + -- an incoming line runs every unit's deferred cleanup, which is what + -- finally frees the alias; the answer has to be the same after it + feedTriggers("\nspec_alias_kill_flush\n") + assert.are.equal(0, exists(id, "alias"), "the alias should be gone after kill and cleanup") + assert.is_false(killAlias(id), "a freed alias cannot be killed either") + end) + + it("killAlias returns false the second time inside the alias's own script", function() + _G.AliasSpec = {} + local id + id = tempAlias("^spec_self_kill_alias$", function() + _G.AliasSpec.killed = killAlias(id) + _G.AliasSpec.killedAgain = killAlias(id) + end) + expandAlias("spec_self_kill_alias") + assert.is_not_nil(_G.AliasSpec.killed, "the alias should have matched and run") + assert.is_true(_G.AliasSpec.killed, + "killAlias should report success from inside the alias's own script") + assert.is_false(_G.AliasSpec.killedAgain, + "killing the same alias twice from its own script must fail the second time") + end) + it("killAlias returns false for a permanent alias (they cannot be killed)", function() local id = permAlias("SpecPermAliasKill", "", "^spec_perm_kill$", [[]]) assert.is_true(id > 0) @@ -295,5 +326,42 @@ describe("Alias processing", function() killAlias(id) end) + it("freeing a temporary alias leaves a same-named permanent one reachable", function() + -- tempAlias names its alias after its id, so a permanent alias called + -- after that number shares the name - and the name lookup table holds + -- several aliases per name + local tempId = tempAlias("^spec_evicted_temp$", [[]]) + local sharedName = tostring(tempId) + -- permanent aliases cannot be deleted from Lua, so earlier local runs + -- can leave same-named ones behind: work from a relative baseline + local before = exists(sharedName, "alias") + assert.is_true(permAlias(sharedName, "", "^spec_evicted_perm$", [[]]) > 0) + finally(function() disableAlias(sharedName) end) + assert.are.equal(before + 1, exists(sharedName, "alias")) + + assert.is_true(killAlias(tempId), "the temporary alias is the one that can be killed") + -- an incoming line runs every unit's deferred cleanup, which frees it + feedTriggers("\nspec_alias_eviction_flush\n") + + assert.are.equal(before, exists(sharedName, "alias"), "only the temporary alias should leave the lookup table") + assert.is_true(enableAlias(sharedName), "the permanent alias must still be reachable by name") + end) + + it("killAlias finds a temporary alias behind a same-named permanent one", function() + -- killAlias walks the root node list in creation order, so a permanent + -- alias restored from the profile sits in front of this session's + -- temporaries: it must be scanned past, not reported as a failure + local seed = tempAlias("^spec_kill_order_seed$", [[]]) + killAlias(seed) + -- permAlias itself takes seed + 1, so the next temporary takes seed + 2 + local sharedName = tostring(seed + 2) + assert.is_true(permAlias(sharedName, "", "^spec_kill_order_perm$", [[]]) > 0) + finally(function() disableAlias(sharedName) end) + + local tempId = tempAlias("^spec_kill_order_temp$", [[]]) + assert.are.equal(seed + 2, tempId, "ids should still be handed out in sequence") + assert.is_true(killAlias(tempId), "killAlias must scan past the permanent alias") + end) + end) end) diff --git a/src/mudlet-lua/tests/DB_spec.lua b/src/mudlet-lua/tests/DB_spec.lua index f5e7047e0..71a5b2f3e 100644 --- a/src/mudlet-lua/tests/DB_spec.lua +++ b/src/mudlet-lua/tests/DB_spec.lua @@ -640,6 +640,18 @@ describe("Tests DB.lua functions", function() assert.is.same(exp_total, total) end) + it("should apply a db:exp query when aggregating.", + function() + local total = db:aggregate(mydb.sheet.count, "total", db:exp("count > 5")) + local exp_total = 0 + for _, v in ipairs(test_data) do + if v.count > 5 then + exp_total = exp_total + v.count + end + end + assert.is.same(exp_total, total) + end) + it("should successfully calculate the average of all numbers.", function() local avg = db:aggregate(mydb.sheet.count, "avg") @@ -1565,4 +1577,1389 @@ describe("Tests DB.lua functions", function() end) end) + describe("Tests db query-expression builders against real fetches", function() + local function names(results) + local t = {} + for _, row in ipairs(results) do + t[#t + 1] = row.name + end + table.sort(t) + return t + end + + before_each(function() + mydb = db:create("exprtestingonly", { + people = { + name = "", + city = "", + level = 0, + _index = { "city" }, + } + }) + db:add(mydb.people, + {name = "Ada", city = "Boston", level = 10}, + {name = "Bram", city = "Chicago", level = 20}, + {name = "Cyra", city = "Boston", level = 30}, + {name = "Drake", city = "Denver", level = 40}, + {name = "Eve", city = "Chicago", level = 50}) + end) + + after_each(function() + db:close() + os.remove(getMudletHomeDir() .. "/Database_exprtestingonly.db") + mydb = nil + end) + + it("db:lt returns rows with a field below the value", function() + assert.are.same({"Ada", "Bram"}, names(db:fetch(mydb.people, db:lt(mydb.people.level, 30)))) + end) + + it("db:lte is inclusive of the boundary", function() + assert.are.same({"Ada", "Bram", "Cyra"}, names(db:fetch(mydb.people, db:lte(mydb.people.level, 30)))) + end) + + it("db:gt returns rows with a field above the value", function() + assert.are.same({"Drake", "Eve"}, names(db:fetch(mydb.people, db:gt(mydb.people.level, 30)))) + end) + + it("db:gte is inclusive of the boundary", function() + assert.are.same({"Cyra", "Drake", "Eve"}, names(db:fetch(mydb.people, db:gte(mydb.people.level, 30)))) + end) + + it("db:eq matches an exact value", function() + assert.are.same({"Ada", "Cyra"}, names(db:fetch(mydb.people, db:eq(mydb.people.city, "Boston")))) + end) + + it("db:eq with case_insensitive matches regardless of case", function() + assert.are.same({"Ada", "Cyra"}, names(db:fetch(mydb.people, db:eq(mydb.people.city, "BOSTON", true)))) + end) + + it("db:not_eq excludes an exact value", function() + assert.are.same({"Bram", "Drake", "Eve"}, names(db:fetch(mydb.people, db:not_eq(mydb.people.city, "Boston")))) + end) + + it("db:not_eq with case_insensitive excludes regardless of case", function() + assert.are.same({"Bram", "Drake", "Eve"}, names(db:fetch(mydb.people, db:not_eq(mydb.people.city, "BOSTON", true)))) + end) + + it("db:like matches SQL LIKE wildcards", function() + assert.are.same({"Ada", "Cyra"}, names(db:fetch(mydb.people, db:like(mydb.people.city, "Bo%")))) + end) + + it("db:not_like excludes SQL LIKE matches", function() + assert.are.same({"Bram", "Drake", "Eve"}, names(db:fetch(mydb.people, db:not_like(mydb.people.city, "Bo%")))) + end) + + it("db:between is inclusive of both bounds", function() + assert.are.same({"Bram", "Cyra", "Drake"}, names(db:fetch(mydb.people, db:between(mydb.people.level, 20, 40)))) + end) + + it("db:not_between excludes the inclusive range", function() + assert.are.same({"Ada", "Eve"}, names(db:fetch(mydb.people, db:not_between(mydb.people.level, 20, 40)))) + end) + + it("db:in_ matches any value in the list", function() + assert.are.same({"Ada", "Cyra", "Drake"}, names(db:fetch(mydb.people, db:in_(mydb.people.city, {"Boston", "Denver"})))) + end) + + it("db:not_in excludes every value in the list", function() + assert.are.same({"Bram", "Eve"}, names(db:fetch(mydb.people, db:not_in(mydb.people.city, {"Boston", "Denver"})))) + end) + + it("db:exp injects a raw SQL WHERE expression", function() + assert.are.same({"Cyra", "Drake", "Eve"}, names(db:fetch(mydb.people, db:exp("level > 25")))) + end) + + it("db:exp still works inside an implicitly-ANDed table query", function() + local results = db:fetch(mydb.people, { + db:exp("level > 25"), + db:eq(mydb.people.city, "Chicago"), + }) + assert.are.same({"Eve"}, names(results)) + end) + + it("db:exp still combines with db:AND and db:OR", function() + local anded = db:fetch(mydb.people, db:AND(db:exp("level > 25"), db:eq(mydb.people.city, "Chicago"))) + assert.are.same({"Eve"}, names(anded)) + local ored = db:fetch(mydb.people, db:OR(db:exp("level < 15"), db:exp("level > 45"))) + assert.are.same({"Ada", "Eve"}, names(ored)) + end) + + it("db:AND requires all sub-expressions to match", function() + local query = db:AND(db:eq(mydb.people.city, "Boston"), db:gt(mydb.people.level, 15)) + assert.are.same({"Cyra"}, names(db:fetch(mydb.people, query))) + end) + + it("db:OR matches either sub-expression", function() + local query = db:OR(db:eq(mydb.people.city, "Denver"), db:eq(mydb.people.city, "Chicago")) + assert.are.same({"Bram", "Drake", "Eve"}, names(db:fetch(mydb.people, query))) + end) + + it("a table-array query is implicitly ANDed", function() + local results = db:fetch(mydb.people, { + db:eq(mydb.people.city, "Chicago"), + db:gt(mydb.people.level, 30), + }) + assert.are.same({"Eve"}, names(results)) + end) + + it("db:is_nil and db:is_not_nil partition rows by NULL", function() + db:set(mydb.people.city, db:Null(), db:eq(mydb.people.name, "Ada")) + assert.are.same({"Ada"}, names(db:fetch(mydb.people, db:is_nil(mydb.people.city)))) + assert.are.same({"Bram", "Cyra", "Drake", "Eve"}, names(db:fetch(mydb.people, db:is_not_nil(mydb.people.city)))) + end) + end) + + describe("Tests db:delete", function() + before_each(function() + mydb = db:create("deletetestingonly", { + sheet = { + name = "", + city = "", + _index = { "name" }, + } + }) + db:add(mydb.sheet, + {name = "Ada", city = "Boston"}, + {name = "Bram", city = "Chicago"}, + {name = "Cyra", city = "Boston"}, + {name = "Drake", city = "Denver"}) + end) + + after_each(function() + db:close() + os.remove(getMudletHomeDir() .. "/Database_deletetestingonly.db") + mydb = nil + end) + + it("deletes a single row by _row_id number", function() + local ada = db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))[1] + db:delete(mydb.sheet, ada._row_id) + assert.are.equal(0, #db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))) + assert.are.equal(3, #db:fetch(mydb.sheet)) + end) + + it("deletes a single row given a fetched result table", function() + local bram = db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Bram"))[1] + db:delete(mydb.sheet, bram) + assert.are.equal(0, #db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Bram"))) + assert.are.equal(3, #db:fetch(mydb.sheet)) + end) + + it("deletes every row matching an expression", function() + db:delete(mydb.sheet, db:eq(mydb.sheet.city, "Boston")) + local remaining = db:fetch(mydb.sheet) + assert.are.equal(2, #remaining) + local cities = {} + for _, row in ipairs(remaining) do + cities[row.city] = true + end + assert.is_nil(cities["Boston"]) + end) + + it("deletes every row matching a db:exp expression", function() + db:delete(mydb.sheet, db:exp("city = 'Boston'")) + local remaining = db:fetch(mydb.sheet) + assert.are.equal(2, #remaining) + local cities = {} + for _, row in ipairs(remaining) do + cities[row.city] = true + end + assert.is_nil(cities["Boston"]) + end) + + it("truncates the whole sheet when the query is true", function() + db:delete(mydb.sheet, true) + assert.are.equal(0, #db:fetch(mydb.sheet)) + end) + + it("errors when no query argument is passed", function() + local ok, err = pcall(function() db:delete(mydb.sheet) end) + assert.is_false(ok) + assert.is_true(string.find(err, "must pass a query argument", 1, true) ~= nil) + end) + + it("errors when passed a table without a _row_id", function() + local ok, err = pcall(function() db:delete(mydb.sheet, {name = "Ada"}) end) + assert.is_false(ok) + assert.is_true(string.find(err, "non-result table", 1, true) ~= nil) + end) + end) + + describe("Tests db:merge_unique", function() + before_each(function() + mydb = db:create("mergetestingonly", { + friends = { + name = "", + city = "", + level = 0, + _unique = { "name" }, + _violations = "REPLACE", + } + }) + db:add(mydb.friends, + {name = "Ada", city = "Boston", level = 10}, + {name = "Bram", city = "Chicago", level = 20}) + end) + + after_each(function() + db:close() + os.remove(getMudletHomeDir() .. "/Database_mergetestingonly.db") + mydb = nil + end) + + it("updates existing rows and inserts new ones in one call", function() + local rows = db:fetch(mydb.friends) + assert.are.equal(2, #rows) + for _, row in ipairs(rows) do + row.city = "Mutantville" + end + rows[#rows + 1] = {name = "Cyra", city = "Denver", level = 5} + db:merge_unique(mydb.friends, rows) + + local after = db:fetch(mydb.friends) + assert.are.equal(3, #after) + local byName = {} + for _, row in ipairs(after) do + byName[row.name] = row + end + assert.are.equal("Mutantville", byName.Ada.city) + assert.are.equal("Mutantville", byName.Bram.city) + assert.are.equal(10, byName.Ada.level) + assert.are.equal("Denver", byName.Cyra.city) + assert.are.equal(5, byName.Cyra.level) + end) + + it("does not duplicate a row when merging an existing unique key", function() + db:merge_unique(mydb.friends, { {name = "Ada", city = "Rome"} }) + local rows = db:fetch(mydb.friends, db:eq(mydb.friends.name, "Ada")) + assert.are.equal(1, #rows) + assert.are.equal("Rome", rows[1].city) + assert.are.equal(10, rows[1].level) + end) + + it("errors when the data argument is not a table", function() + local ok, err = pcall(function() db:merge_unique(mydb.friends, nil) end) + assert.is_false(ok) + assert.is_true(string.find(err, "required table of data", 1, true) ~= nil) + end) + + it("errors when a merged row is missing the unique key", function() + local ok, err = pcall(function() + db:merge_unique(mydb.friends, { {city = "Nowhere"} }) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "does not have the unique key", 1, true) ~= nil) + end) + + it("errors on a sheet whose unique index spans multiple columns", function() + db:close() + os.remove(getMudletHomeDir() .. "/Database_mergetestingonly.db") + mydb = db:create("mergetestingonly", { + friends = { + name = "", + city = "", + _unique = { {"name", "city"} }, + } + }) + db:add(mydb.friends, {name = "Ada", city = "Boston"}) + local ok, err = pcall(function() + db:merge_unique(mydb.friends, { {name = "Ada", city = "Boston"} }) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "single unique index with a single column", 1, true) ~= nil) + end) + end) + + describe("Tests db transaction rollback", function() + before_each(function() + mydb = db:create("rollbacktestingonly", { + sheet = { + name = "", + _index = { "name" }, + } + }) + db:add(mydb.sheet, {name = "committed"}) + end) + + after_each(function() + db:close() + os.remove(getMudletHomeDir() .. "/Database_rollbacktestingonly.db") + mydb = nil + end) + + it("discards uncommitted rows when rolled back", function() + assert.are.equal(1, #db:fetch(mydb.sheet)) + mydb:_begin() + db:add(mydb.sheet, {name = "pending1"}) + db:add(mydb.sheet, {name = "pending2"}) + assert.are.equal(3, #db:fetch(mydb.sheet)) + mydb:_rollback() + mydb:_end() + local after = db:fetch(mydb.sheet) + assert.are.equal(1, #after) + assert.are.equal("committed", after[1].name) + end) + + it("persists committed rows across a close and reopen", function() + mydb:_begin() + db:add(mydb.sheet, {name = "pending"}) + mydb:_commit() + mydb:_end() + -- reopen so the assertion sees the on-disk state, not this connection's + -- own uncommitted view - this is what discriminates commit from a no-op + db:close() + mydb = db:create("rollbacktestingonly", { + sheet = { + name = "", + _index = { "name" }, + } + }) + assert.are.equal(2, #db:fetch(mydb.sheet)) + end) + end) + + describe("Tests db:update and db:set edge cases", function() + before_each(function() + mydb = db:create("updatetestingonly", { + sheet = { + name = "", + city = "", + kills = 0, + _unique = { "name" }, + _violations = "REPLACE", + } + }) + db:add(mydb.sheet, + {name = "Ada", city = "Boston", kills = 3}, + {name = "Bram", city = "Chicago", kills = 7}) + end) + + after_each(function() + db:close() + os.remove(getMudletHomeDir() .. "/Database_updatetestingonly.db") + mydb = nil + end) + + it("updates the changed field and preserves the rest", function() + local ada = db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))[1] + ada.city = "Rome" + db:update(mydb.sheet, ada) + local reread = db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))[1] + assert.are.equal("Rome", reread.city) + assert.are.equal("Ada", reread.name) + assert.are.equal(3, reread.kills) + end) + + it("errors when updating a table without a _row_id", function() + local ok, err = pcall(function() + db:update(mydb.sheet, {name = "Ada", city = "Rome"}) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "_row_id", 1, true) ~= nil) + end) + + it("db:set changes a field for rows matching the query", function() + db:set(mydb.sheet.city, "Rome", db:eq(mydb.sheet.name, "Ada")) + assert.are.equal("Rome", db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))[1].city) + assert.are.equal("Chicago", db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Bram"))[1].city) + end) + + it("db:set without a query updates every row", function() + db:set(mydb.sheet.kills, 0) + local rows = db:fetch(mydb.sheet) + assert.are.equal(2, #rows) + for _, row in ipairs(rows) do + assert.are.equal(0, row.kills) + end + end) + + it("db:set evaluates a db:exp value instead of storing it literally", function() + db:set(mydb.sheet.kills, db:exp("kills + 1"), db:eq(mydb.sheet.name, "Ada")) + assert.are.equal(4, db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))[1].kills) + assert.are.equal(7, db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Bram"))[1].kills) + end) + + it("db:set accepts a db:exp as the WHERE query", function() + db:set(mydb.sheet.city, "Rome", db:exp("kills > 5")) + assert.are.equal("Boston", db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Ada"))[1].city) + assert.are.equal("Rome", db:fetch(mydb.sheet, db:eq(mydb.sheet.name, "Bram"))[1].city) + end) + end) + + describe("Tests db.Database:_drop", function() + before_each(function() + mydb = db:create("droptestingonly", { + people = { + name = "", + city = "", + _index = { "city" }, + _unique = { "name" }, + } + }) + db:add(mydb.people, + {name = "Ada", city = "Boston"}, + {name = "Bram", city = "Chicago"}) + end) + + after_each(function() + pcall(function() db:close() end) + os.remove(getMudletHomeDir() .. "/Database_droptestingonly.db") + mydb = nil + end) + + it("drops the sheet's table and indexes without erroring", function() + local ok, err = pcall(function() mydb:_drop("people") end) + assert.is_true(ok, err) + + -- the table (and hence its rows and indexes) is really gone from the database + local conn = db.__conn[mydb._db_name] + local cur = conn:execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'people'") + local exists = cur and cur ~= 0 and cur:fetch({}, "a") ~= nil + if cur and cur ~= 0 then + cur:close() + end + assert.is_false(exists) + end) + + it("drops a sheet whose _unique index is declared as a string", function() + local sdb = db:create("dropstrtestingonly", { + pets = { + name = "", + _unique = "name", + } + }) + local ok, err = pcall(function() sdb:_drop("pets") end) + db:close("dropstrtestingonly") + os.remove(getMudletHomeDir() .. "/Database_dropstrtestingonly.db") + assert.is_true(ok, err) + end) + end) + + describe("Tests db:close contracts and reopen", function() + after_each(function() + pcall(function() db:close("closetestingonly") end) + os.remove(getMudletHomeDir() .. "/Database_closetestingonly.db") + mydb = nil + end) + + it("closes a named database and reports success", function() + db:create("closetestingonly", { sheet = { name = "" } }) + local ok, msg = db:close("closetestingonly") + assert.is_true(ok) + assert.are.equal("", msg) + end) + + it("returns false when closing a database that does not exist", function() + db:create("closetestingonly", { sheet = { name = "" } }) + local ok, msg = db:close("nonexistentdbxyz") + assert.is_false(ok) + assert.is_true(string.find(msg, "does not exist", 1, true) ~= nil) + db:close("closetestingonly") + end) + + it("returns false when called before any database environment exists", function() + local saved_env = db.__env + db.__env = nil + local ok, msg = db:close("whatever") + db.__env = saved_env + assert.is_false(ok) + assert.is_true(string.find(msg, "environment is nil", 1, true) ~= nil) + end) + + it("errors when db_name is neither a string nor nil", function() + db:create("closetestingonly", { sheet = { name = "" } }) + local ok, err = pcall(function() db:close(12345) end) + assert.is_false(ok) + assert.is_true(string.find(err, "expected db_name to be string or nil", 1, true) ~= nil) + db:close("closetestingonly") + end) + + it("persists data across close and reopen", function() + local d = db:create("closetestingonly", { + sheet = { name = "", city = "", _index = {"name"} } + }) + db:add(d.sheet, {name = "Ada", city = "Boston"}) + db:close("closetestingonly") + + local d2 = db:create("closetestingonly", { + sheet = { name = "", city = "", _index = {"name"} } + }) + local rows = db:fetch(d2.sheet) + assert.are.equal(1, #rows) + assert.are.equal("Ada", rows[1].name) + assert.are.equal("Boston", rows[1].city) + db:close("closetestingonly") + end) + end) + + describe("Tests db:create schema validation", function() + after_each(function() + pcall(function() db:close("badschematestingonly") end) + os.remove(getMudletHomeDir() .. "/Database_badschematestingonly.db") + mydb = nil + end) + + it("errors on an unrecognised _violations option", function() + local ok, err = pcall(function() + db:create("badschematestingonly", { + sheet = { name = "", _unique = { "name" }, _violations = "NONSENSE" } + }) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "_validations must be one of", 1, true) ~= nil) + end) + + it("errors on a non-string _violations option", function() + local ok, err = pcall(function() + db:create("badschematestingonly", { + sheet = { name = "", _unique = { "name" }, _violations = 42 } + }) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "_validations must be a string", 1, true) ~= nil) + end) + + it("errors on a malformed _unique constraint", function() + local ok, err = pcall(function() + db:create("badschematestingonly", { + sheet = { name = "", _unique = { 123 } } + }) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "must be a string or table", 1, true) ~= nil) + end) + + it("errors when _unique is neither a string nor a table", function() + local ok, err = pcall(function() + db:create("badschematestingonly", { + sheet = { name = "", _unique = 42 } + }) + end) + assert.is_false(ok) + assert.is_true(string.find(err, "_unique must be a string or a table", 1, true) ~= nil) + end) + end) + +end) + +describe("Tests db:echo_sql", function() + local saved + + before_each(function() + saved = db.debug_sql + end) + + after_each(function() + db.debug_sql = saved + end) + + it("prints the statement it is handed when SQL debugging is on", function() + db.debug_sql = true + local printSpy = spy.on(_G, "print") + finally(function() print:revert() end) + db:echo_sql("SELECT 1;") + assert.spy(printSpy).was.called(1) + assert.spy(printSpy).was.called_with("SELECT 1;") + end) + + it("stays silent while SQL debugging is off", function() + db.debug_sql = false + local printSpy = spy.on(_G, "print") + finally(function() print:revert() end) + db:echo_sql("SELECT 1;") + assert.spy(printSpy).was_not_called() + end) + + it("is silent by default", function() + assert.is_falsy(saved) + end) +end) + +-- The helpers below all begin with an underscore: they are db's internals, not +-- its public API. They are specced directly because every public db function is +-- built out of them, so a change to one of them moves behaviour everywhere at +-- once, and because the SQL they produce is the only place the escaping and +-- quoting rules are actually written down. +describe("Tests db's internal SQL helpers", function() + + describe("Tests db:_sql_type", function() + it("maps a number to REAL", function() + assert.are.equal("REAL", db:_sql_type(0)) + assert.are.equal("REAL", db:_sql_type(-1.5)) + end) + + it("maps nil to NULL", function() + assert.are.equal("NULL", db:_sql_type(nil)) + end) + + it("maps a timestamp to INTEGER, including the empty one", function() + assert.are.equal("INTEGER", db:_sql_type(db:Timestamp(1234))) + assert.are.equal("INTEGER", db:_sql_type(db:Timestamp("CURRENT_TIMESTAMP"))) + -- db:Timestamp(nil) stores false rather than nil, so it is still a + -- timestamp column and must not fall through to TEXT + assert.are.equal("INTEGER", db:_sql_type(db:Timestamp(nil))) + end) + + it("maps db:Null to NULL", function() + assert.are.equal("NULL", db:_sql_type(db:Null())) + end) + + it("maps everything else, including a plain table, to TEXT", function() + assert.are.equal("TEXT", db:_sql_type("")) + assert.are.equal("TEXT", db:_sql_type("some text")) + assert.are.equal("TEXT", db:_sql_type(true)) + assert.are.equal("TEXT", db:_sql_type({})) + end) + end) + + describe("Tests db:_sql_convert", function() + it("double quotes a string default and doubles up single quotes in it", function() + assert.are.equal('""', db:_sql_convert("")) + assert.are.equal('"plain"', db:_sql_convert("plain")) + assert.are.equal([["it''s"]], db:_sql_convert("it's")) + end) + + it("renders nil and db:Null as the NULL keyword", function() + assert.are.equal("NULL", db:_sql_convert(nil)) + assert.are.equal("NULL", db:_sql_convert(db:Null())) + end) + + it("renders a timestamp as its raw epoch number", function() + assert.are.equal("1234", db:_sql_convert(db:Timestamp(1234))) + end) + + it("renders the empty timestamp as NULL rather than as false", function() + assert.are.equal("NULL", db:_sql_convert(db:Timestamp(nil))) + end) + + it("renders anything else with tostring, unquoted", function() + assert.are.equal("42", db:_sql_convert(42)) + assert.are.equal("true", db:_sql_convert(true)) + end) + end) + + describe("Tests db:_index_name", function() + it("names a single column index after the sheet and the column", function() + assert.are.equal("idx_people_c_city", db:_index_name("people", "city")) + end) + + it("joins every column of a compound index into one name", function() + assert.are.equal("idx_people_c_name_city", db:_index_name("people", {"name", "city"})) + end) + + it("gives two different indexes on one sheet two different names", function() + -- the names have to differ or CREATE INDEX IF NOT EXISTS silently keeps + -- the first index and the second one is never made + assert.are_not.equal(db:_index_name("people", "city"), db:_index_name("people", "name")) + assert.are_not.equal(db:_index_name("people", {"name", "city"}), db:_index_name("people", {"city", "name"})) + end) + + it("refuses anything that is not a string or a table", function() + local ok, err = pcall(function() return db:_index_name("people", 42) end) + assert.is_false(ok) + assert.is_truthy(string.find(err, "Indexes must be either a string or a table.", 1, true)) + end) + end) + + describe("Tests db:_index_valid", function() + local columns = {name = "TEXT", city = "TEXT"} + + it("accepts a single column index that names a real column", function() + assert.is_true(db:_index_valid(columns, "city")) + end) + + it("rejects a single column index that names a column the sheet lacks", function() + assert.is_false(db:_index_valid(columns, "nosuchcolumn")) + end) + + it("accepts a compound index whose columns all exist", function() + assert.is_true(db:_index_valid(columns, {"name", "city"})) + end) + + it("rejects a compound index as soon as one column is missing", function() + assert.is_false(db:_index_valid(columns, {"name", "nosuchcolumn"})) + end) + + it("accepts an empty compound index", function() + assert.is_true(db:_index_valid(columns, {})) + end) + end) + + describe("Tests db:_sql_columns", function() + it("lower cases and double quotes a single column name", function() + assert.are.equal('"city"', db:_sql_columns("City")) + end) + + it("comma separates a list of column names", function() + assert.are.equal('"name","city"', db:_sql_columns({"name", "City"})) + end) + + it("attaches a sort direction to the column before it instead of quoting it", function() + -- db:fetch appends "DESC" as its own list entry, so it must not come out + -- as a column name of its own + assert.are.equal('"name" DESC', db:_sql_columns({"name", "DESC"})) + assert.are.equal('"name" asc,"city" desc', db:_sql_columns({"name", "asc", "city", "desc"})) + end) + + it("refuses anything that is not a string or a table", function() + local ok, err = pcall(function() return db:_sql_columns(42) end) + assert.is_false(ok) + assert.is_truthy(string.find(err, "Must specify either a table array or string for index, not number", 1, true)) + end) + end) + + describe("Tests db:_sql_fields", function() + it("wraps one quoted field name in parentheses", function() + assert.are.equal('("name")', db:_sql_fields({name = "Bob"})) + end) + + it("keeps the case of the field name, unlike db:_sql_columns", function() + assert.are.equal('("Name")', db:_sql_fields({Name = "Bob"})) + end) + + it("produces an empty list for an empty row", function() + assert.are.equal("()", db:_sql_fields({})) + end) + end) + + describe("Tests db:_sql_values", function() + it("single quotes a string and doubles up single quotes in it", function() + assert.are.equal("('plain')", db:_sql_values({name = "plain"})) + assert.are.equal("('it''s')", db:_sql_values({name = "it's"})) + end) + + it("leaves a number unquoted", function() + assert.are.equal("(42)", db:_sql_values({kills = 42})) + end) + + it("turns CURRENT_TIMESTAMP into a call to sqlite's datetime", function() + assert.are.equal("(datetime('now'))", db:_sql_values({when_ = db:Timestamp("CURRENT_TIMESTAMP")})) + end) + + it("turns an epoch timestamp into a unixepoch conversion", function() + assert.are.equal("(datetime('1234', 'unixepoch'))", db:_sql_values({when_ = db:Timestamp(1234)})) + end) + + it("turns the empty timestamp and db:Null into NULL", function() + assert.are.equal("(NULL)", db:_sql_values({when_ = db:Timestamp(nil)})) + assert.are.equal("(NULL)", db:_sql_values({whatever = db:Null()})) + end) + + it("produces an empty list for an empty row", function() + assert.are.equal("()", db:_sql_values({})) + end) + end) + + describe("Tests db:_sql_fields and db:_sql_values together", function() + it("lists the fields and the values of one row in the same order", function() + -- this is the only thing that makes the pair usable: db:add writes + -- "INSERT INTO sheet <fields> VALUES <values>", and both walk the row + -- with pairs(), so the two walks have to agree or every column of every + -- insert lands in the wrong one + local row = {alpha = "a", bravo = "b", charlie = "c", delta = 4, echo = "e"} + + local fields = db:_sql_fields(row):match("^%((.*)%)$") + local values = db:_sql_values(row):match("^%((.*)%)$") + local names, contents = string.split(fields, ","), string.split(values, ",") + + assert.are.equal(5, #names) + assert.are.equal(#names, #contents) + for index, name in ipairs(names) do + local column = name:match('^"(.*)"$') + local expected = type(row[column]) == "string" and ("'" .. row[column] .. "'") or tostring(row[column]) + assert.are.equal(expected, contents[index], "column " .. column .. " did not line up with its value") + end + end) + end) + + describe("Tests db:_validate_validations", function() + it("accepts every documented conflict resolution", function() + for _, option in ipairs({"ABORT", "FAIL", "IGNORE", "REPLACE", "ROLLBACK"}) do + local valid, msg = db:_validate_validations(option) + assert.is_true(valid, option .. " should be a valid _violations option") + assert.are.equal("", msg) + end + end) + + it("rejects an option it does not know and says what it wanted", function() + local valid, msg = db:_validate_validations("NONSENSE") + assert.is_false(valid) + assert.is_truthy(string.find(msg, "_validations must be one of", 1, true)) + assert.is_truthy(string.find(msg, "NONSENSE", 1, true)) + end) + + it("rejects a non-string and names the type it got", function() + local valid, msg = db:_validate_validations(42) + assert.is_false(valid) + assert.are.equal("_validations must be a string. Received number", msg) + end) + + it("is case sensitive", function() + assert.is_false((db:_validate_validations("fail"))) + end) + end) + + describe("Tests db:_validate_unique_contraints", function() + it("accepts a bare column name", function() + local valid, msg = db:_validate_unique_contraints("name") + assert.is_true(valid) + assert.are.equal("", msg) + end) + + it("accepts a list of column names", function() + assert.is_true((db:_validate_unique_contraints({"name", "city"}))) + end) + + it("accepts a compound constraint", function() + assert.is_true((db:_validate_unique_contraints({{"name", "city"}}))) + end) + + it("accepts an empty list", function() + assert.is_true((db:_validate_unique_contraints({}))) + end) + + it("rejects a compound constraint holding something other than a column name", function() + local valid, msg = db:_validate_unique_contraints({{"name", 42}}) + assert.is_false(valid) + assert.is_truthy(string.find(msg, "Multi-column definitions for _unique must be a list of strings", 1, true)) + end) + + it("rejects a member that is neither a string nor a table", function() + local valid, msg = db:_validate_unique_contraints({42}) + assert.is_false(valid) + assert.are.equal("Members of _unique must be a string or table. Received number.", msg) + end) + + it("rejects a constraint that is neither a string nor a table", function() + local valid, msg = db:_validate_unique_contraints(42) + assert.is_false(valid) + assert.are.equal("_unique must be a string or a table. Received number.", msg) + end) + + it("reports every bad member rather than only the first", function() + local valid, msg = db:_validate_unique_contraints({42, true}) + assert.is_false(valid) + assert.are.equal(2, #string.split(msg, "\n")) + end) + end) + + describe("Tests db:_extract_table_constraints", function() + it("returns nothing for no SQL at all", function() + assert.are.equal("", db:_extract_table_constraints(nil)) + assert.are.equal("", db:_extract_table_constraints("")) + end) + + it("returns nothing for SQL that is not a CREATE TABLE", function() + assert.are.equal("", db:_extract_table_constraints("SELECT * FROM people")) + end) + + it("returns nothing for a table with no unique constraints", function() + assert.are.equal("", db:_extract_table_constraints('CREATE TABLE people ("name" TEXT NULL DEFAULT "")')) + end) + + it("extracts a column level unique constraint", function() + assert.are.equal("unique on conflict replace", + db:_extract_table_constraints('CREATE TABLE people ("name" TEXT NULL DEFAULT "" UNIQUE ON CONFLICT REPLACE)')) + end) + + it("extracts a table level unique constraint with its columns", function() + assert.are.equal('unique("name", "city") on conflict fail', + db:_extract_table_constraints('CREATE TABLE people ("name" TEXT NULL, "city" TEXT NULL, UNIQUE("name", "city") ON CONFLICT FAIL)')) + end) + + it("ignores case, newlines and repeated whitespace", function() + local oneLine = 'CREATE TABLE people ("name" TEXT NULL DEFAULT "" UNIQUE ON CONFLICT REPLACE)' + local sprawling = 'create table people\n(\n "name" text null default ""\n unique on conflict replace\n)' + assert.are.equal(db:_extract_table_constraints(oneLine), db:_extract_table_constraints(sprawling)) + end) + + it("orders the constraints so that the same table always compares equal", function() + -- db:_migrate compares this string against the one it built to decide + -- whether to rebuild the table, so two spellings of one schema must match + local first = 'CREATE TABLE people ("a" TEXT UNIQUE ON CONFLICT FAIL, UNIQUE("b", "c") ON CONFLICT IGNORE)' + local second = 'CREATE TABLE people (UNIQUE("b", "c") ON CONFLICT IGNORE, "a" TEXT UNIQUE ON CONFLICT FAIL)' + assert.are.equal(db:_extract_table_constraints(first), db:_extract_table_constraints(second)) + assert.are.equal('unique on conflict fail|unique("b", "c") on conflict ignore', db:_extract_table_constraints(first)) + end) + + it("separates a change of conflict resolution from an unchanged one", function() + local fail = 'CREATE TABLE people ("name" TEXT UNIQUE ON CONFLICT FAIL)' + local replace = 'CREATE TABLE people ("name" TEXT UNIQUE ON CONFLICT REPLACE)' + assert.are_not.equal(db:_extract_table_constraints(fail), db:_extract_table_constraints(replace)) + end) + + it("ignores a column that was added or removed", function() + -- the whole point of comparing constraints instead of the whole statement + local before = 'CREATE TABLE people ("name" TEXT UNIQUE ON CONFLICT FAIL)' + local after = 'CREATE TABLE people ("name" TEXT UNIQUE ON CONFLICT FAIL, "city" TEXT NULL DEFAULT "")' + assert.are.equal(db:_extract_table_constraints(before), db:_extract_table_constraints(after)) + end) + end) + + describe("Tests db:_build_create_table_sql", function() + it("always gives the sheet an autoincrementing _row_id", function() + local sql = db:_build_create_table_sql({columns = {name = ""}, options = {}}, "people") + assert.are.equal('CREATE TABLE people ("_row_id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" TEXT NULL DEFAULT "")', sql) + end) + + it("types a column from its default value", function() + local sql = db:_build_create_table_sql({columns = {kills = 0}, options = {}}, "people") + assert.is_truthy(string.find(sql, '"kills" REAL NULL DEFAULT 0', 1, true)) + end) + + it("adds a column level unique constraint for a single unique column", function() + local sql = db:_build_create_table_sql({columns = {name = ""}, options = {_unique = "name"}}, "people") + assert.is_truthy(string.find(sql, '"name" TEXT NULL DEFAULT "" UNIQUE ON CONFLICT FAIL', 1, true)) + end) + + it("accepts the unique column as a one entry list too", function() + local sql = db:_build_create_table_sql({columns = {name = ""}, options = {_unique = {"name"}}}, "people") + assert.is_truthy(string.find(sql, 'UNIQUE ON CONFLICT FAIL', 1, true)) + end) + + it("adds a table level unique constraint for a compound one", function() + local sql = db:_build_create_table_sql({columns = {name = ""}, options = {_unique = {{"name", "city"}}}}, "people") + assert.is_truthy(string.find(sql, 'UNIQUE("name", "city") ON CONFLICT FAIL', 1, true)) + end) + + it("uses the sheet's conflict resolution rather than the default", function() + local sql = db:_build_create_table_sql({columns = {name = ""}, options = {_unique = "name", _violations = "REPLACE"}}, "people") + assert.is_truthy(string.find(sql, "ON CONFLICT REPLACE", 1, true)) + assert.is_nil(string.find(sql, "ON CONFLICT FAIL", 1, true)) + end) + + it("leaves a column that is not unique alone", function() + local sql = db:_build_create_table_sql({columns = {city = ""}, options = {_unique = "name"}}, "people") + assert.is_nil(string.find(sql, "UNIQUE", 1, true)) + end) + end) +end) + +-- These four run against a real sqlite database rather than against strings: +-- they are the parts of db:create that touch the file on disk. +describe("Tests db's internals against a real database", function() + local dbName = "dbinternalstestingonly" + local dbFile = getMudletHomeDir() .. "/Database_" .. dbName .. ".db" + local mydb + + local function indexNames(sheetName) + local conn = db.__conn[dbName] + local cursor = conn:execute( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = '" .. sheetName .. "' AND sql IS NOT NULL" + ) + local names = {} + local row = cursor:fetch({}, "a") + while row do + names[#names + 1] = row.name + row = cursor:fetch({}, "a") + end + cursor:close() + table.sort(names) + return names + end + + before_each(function() + mydb = db:create(dbName, { + people = { + name = "", + city = "", + kills = 0, + seen = db:Timestamp("CURRENT_TIMESTAMP"), + _index = {"city"} + } + }) + end) + + after_each(function() + db:close() + os.remove(dbFile) + mydb = nil + end) + + describe("Tests db:_isActiveDBName", function() + it("reports an open database whose file is on disk as active", function() + assert.is_truthy(db:_isActiveDBName(dbName)) + end) + + it("sanitises the name it is given first", function() + -- db:create sanitises too, so a caller passing the unsanitised name has + -- to reach the same connection or db:create opens a second one + assert.is_truthy(db:_isActiveDBName("DB Internals Testing Only")) + end) + + it("reports a database that was never created as inactive", function() + assert.is_falsy(db:_isActiveDBName("nosuchdatabaseatall")) + end) + + it("reports a closed database as inactive", function() + assert.is_true((db:close(dbName))) + assert.is_falsy(db:_isActiveDBName(dbName)) + end) + + it("reports an open connection whose file has gone as inactive", function() + -- the file is what db:create reconnects to, so a live handle to a deleted + -- file must not count as active + os.remove(dbFile) + if io.exists(dbFile) then + -- Windows will not unlink a file sqlite still has open, so there is no + -- open-connection-without-a-file state to ask about there + pending("this platform keeps a database file that is still open") + end + assert.is_falsy(db:_isActiveDBName(dbName)) + end) + end) + + describe("Tests db:get_database", function() + it("hands back a reference to a database that db:create already made", function() + local reference = db:get_database(dbName) + assert.is_table(reference) + assert.are.equal("people", reference.people._sht_name) + assert.are.equal("name", reference.people.name.name) + end) + + it("sanitises the name it is given", function() + assert.are.equal(dbName, db:get_database("DB Internals Testing Only")._db_name) + end) + + it("hands back a reference that reads the same rows as db:create's", function() + db:add(mydb.people, {name = "Bob", city = "Ankh-Morpork"}) + local rows = db:fetch(db:get_database(dbName).people) + assert.are.equal(1, #rows) + assert.are.equal("Bob", rows[1].name) + end) + + it("refuses a database that does not exist", function() + local ok, err = pcall(function() return db:get_database("nosuchdatabaseatall") end) + assert.is_false(ok) + assert.is_truthy(string.find(err, "Attempt to access database that does not exist.", 1, true)) + end) + + it("refuses a sheet the database does not have", function() + local ok, err = pcall(function() return db:get_database(dbName).nosuchsheet end) + assert.is_false(ok) + assert.is_truthy(string.find(err, "does not exist", 1, true)) + end) + end) + + describe("Tests db:fetch_sql", function() + before_each(function() + db:add(mydb.people, {name = "Bob", city = "Ankh-Morpork", kills = 3}) + db:add(mydb.people, {name = "Carrot", city = "Ankh-Morpork", kills = 7}) + end) + + it("returns one coerced row per result", function() + local rows = db:fetch_sql(mydb.people, "SELECT * FROM people ORDER BY name") + assert.are.equal(2, #rows) + assert.are.equal("Bob", rows[1].name) + assert.are.equal("Carrot", rows[2].name) + end) + + it("coerces the values it read to the types the sheet declares", function() + local rows = db:fetch_sql(mydb.people, "SELECT * FROM people WHERE name = 'Bob'") + assert.are.equal(3, rows[1].kills) + assert.is_number(rows[1]._row_id) + assert.is_number(rows[1].seen:as_number()) + end) + + it("returns an empty list rather than nil when nothing matched", function() + local rows = db:fetch_sql(mydb.people, "SELECT * FROM people WHERE name = 'Nobody'") + assert.are.same({}, rows) + end) + + it("honours the SQL it is handed rather than fetching the whole sheet", function() + local rows = db:fetch_sql(mydb.people, "SELECT * FROM people WHERE kills > 5") + assert.are.equal(1, #rows) + assert.are.equal("Carrot", rows[1].name) + end) + + it("returns nil for SQL sqlite could not run", function() + assert.is_nil(db:fetch_sql(mydb.people, "SELECT * FROM")) + assert.is_nil(db:fetch_sql(mydb.people, "SELECT * FROM nosuchsheet")) + end) + end) + + describe("Tests db:_coerce", function() + it("passes a raw expression through untouched", function() + assert.are.equal("upper(name)", db:_coerce(mydb.people.name, db:exp("upper(name)"))) + end) + + it("renders db:Null as the NULL keyword", function() + assert.are.equal("NULL", db:_coerce(mydb.people.name, db:Null())) + end) + + it("leaves a number field's value as a number", function() + assert.are.equal(7, db:_coerce(mydb.people.kills, 7)) + assert.are.equal(7, db:_coerce(mydb.people.kills, "7")) + end) + + it("quotes a value a number field cannot hold", function() + assert.are.equal("'lots'", db:_coerce(mydb.people.kills, "lots")) + end) + + it("renders a datetime field's value through sqlite's datetime", function() + assert.are.equal("datetime('now')", db:_coerce(mydb.people.seen, db:Timestamp("CURRENT_TIMESTAMP"))) + assert.are.equal("datetime('1234', 'unixepoch')", db:_coerce(mydb.people.seen, db:Timestamp(1234))) + assert.are.equal("NULL", db:_coerce(mydb.people.seen, db:Timestamp(nil))) + end) + + it("single quotes a text field's value and doubles up single quotes in it", function() + assert.are.equal("'Bob'", db:_coerce(mydb.people.name, "Bob")) + assert.are.equal("'it''s'", db:_coerce(mydb.people.name, "it's")) + end) + end) + + describe("Tests db:_coerce_sheet", function() + it("returns nothing at all when there is no row", function() + assert.is_nil(db:_coerce_sheet(mydb.people, nil)) + end) + + it("turns the sqlite text a row arrives as into the sheet's types", function() + local row = db:_coerce_sheet(mydb.people, {_row_id = "4", name = "Bob", kills = "3", seen = "2020-01-02 03:04:05"}) + assert.are.equal(4, row._row_id) + assert.are.equal(3, row.kills) + assert.are.equal("Bob", row.name) + assert.is_number(row.seen:as_number()) + end) + + it("leaves a number column that does not hold a number alone", function() + local row = db:_coerce_sheet(mydb.people, {_row_id = "1", kills = "lots"}) + assert.are.equal("lots", row.kills) + end) + + it("gives an empty datetime column an empty timestamp", function() + local row = db:_coerce_sheet(mydb.people, {_row_id = "1", seen = nil}, {"seen"}) + assert.is_false(row.seen._timestamp) + assert.is_nil((row.seen:as_number())) + end) + + it("only converts the columns it is told about", function() + local row = db:_coerce_sheet(mydb.people, {_row_id = "1", kills = "3", name = "Bob"}, {"name"}) + assert.are.equal("3", row.kills) + assert.are.equal("Bob", row.name) + end) + end) + + describe("Tests db:_migrate", function() + it("creates a sheet that the schema has but the file does not", function() + db.__schema[dbName].pets = {columns = {name = "", legs = 0}, options = {}} + db:_migrate(dbName, "pets") + + local pets = db:get_database(dbName).pets + db:add(pets, {name = "Gaspode", legs = 4}) + local rows = db:fetch(pets) + assert.are.equal(1, #rows) + assert.are.equal(4, rows[1].legs) + end) + + it("adds a column that the schema gained without losing the rows", function() + db:add(mydb.people, {name = "Bob", city = "Ankh-Morpork"}) + db.__schema[dbName].people.columns.rank = "" + db:_migrate(dbName, "people") + + local rows = db:fetch(db:get_database(dbName).people) + assert.are.equal(1, #rows) + assert.are.equal("Bob", rows[1].name) + assert.are.equal("", rows[1].rank) + end) + + it("runs again over an unchanged sheet without disturbing it", function() + db:add(mydb.people, {name = "Bob", city = "Ankh-Morpork", kills = 3}) + db:_migrate(dbName, "people") + db:_migrate(dbName, "people") + + local rows = db:fetch(mydb.people) + assert.are.equal(1, #rows) + assert.are.equal(3, rows[1].kills) + end) + + it("refuses to drop a column that still holds data unless forced", function() + db:add(mydb.people, {name = "Bob", city = "Ankh-Morpork"}) + db.__schema[dbName].people.columns.city = nil + + local ok, err = pcall(function() db:_migrate(dbName, "people") end) + assert.is_false(ok) + assert.is_truthy(string.find(err, "data present in undefined columns", 1, true)) + end) + + it("drops that column when it is forced to", function() + db:add(mydb.people, {name = "Bob", city = "Ankh-Morpork"}) + db.__schema[dbName].people.columns.city = nil + db:_migrate(dbName, "people", true) + + local rows = db:fetch(db:get_database(dbName).people) + assert.are.equal(1, #rows) + assert.are.equal("Bob", rows[1].name) + assert.is_nil(rows[1].city) + end) + + it("creates the indexes the schema asks for", function() + local conn = db.__conn[dbName] + conn:execute("DROP INDEX IF EXISTS " .. db:_index_name("people", "city")) + assert.are.same({}, indexNames("people")) + + db:_migrate(dbName, "people") + + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + end) + + describe("Tests db:_drop_orphaned_indexes", function() + it("keeps an index the schema still asks for", function() + local schema = db.__schema[dbName].people + local ok, err = db:_drop_orphaned_indexes(db.__conn[dbName], "people", schema) + assert.is_true(ok) + assert.is_nil(err) + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + + it("drops every index once the schema asks for none", function() + local schema = db.__schema[dbName].people + schema.options._index = nil + assert.is_true((db:_drop_orphaned_indexes(db.__conn[dbName], "people", schema))) + assert.are.same({}, indexNames("people")) + end) + + it("drops an index whose columns are no longer in the schema's index list", function() + local schema = db.__schema[dbName].people + schema.options._index = {"name"} + assert.is_true((db:_drop_orphaned_indexes(db.__conn[dbName], "people", schema))) + -- the city index is gone and the name one is not made here, only dropped + assert.are.same({}, indexNames("people")) + end) + + it("matches a compound index by its columns rather than by its name", function() + local conn = db.__conn[dbName] + conn:execute('CREATE INDEX IF NOT EXISTS idx_people_c_handmade ON people ("city", "name")') + local schema = db.__schema[dbName].people + schema.options._index = {{"name", "city"}} + assert.is_true((db:_drop_orphaned_indexes(conn, "people", schema))) + -- the column order differs and the name is nothing db would have picked, + -- but the index covers what the schema asked for, so it stays + assert.are.same({"idx_people_c_handmade"}, indexNames("people")) + end) + + it("drops a unique index, which db does not make any more", function() + local conn = db.__conn[dbName] + conn:execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_people_c_name ON people ("name")') + local schema = db.__schema[dbName].people + schema.options._index = {"name", "city"} + assert.is_true((db:_drop_orphaned_indexes(conn, "people", schema))) + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + + it("has nothing to do for a sheet that is not in the file", function() + -- it asks sqlite_master which indexes the sheet has rather than the sheet + -- itself, so an unknown sheet is an empty answer and not an error + local ok, err = db:_drop_orphaned_indexes(db.__conn[dbName], "nosuchsheet", db.__schema[dbName].people) + assert.is_true(ok) + assert.is_nil(err) + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + end) + + describe("Tests db:_migrate_indexes", function() + local columns = {name = "TEXT", city = "TEXT", kills = "REAL"} + + it("creates an index the sheet does not have yet", function() + local conn = db.__conn[dbName] + db:_migrate_indexes(conn, "people", {columns = {}, options = {_index = {"name"}}}, columns) + assert.are.same({db:_index_name("people", "city"), db:_index_name("people", "name")}, indexNames("people")) + end) + + it("creates a compound index under its compound name", function() + local conn = db.__conn[dbName] + db:_migrate_indexes(conn, "people", {columns = {}, options = {_index = {{"name", "city"}}}}, columns) + assert.is_truthy(table.contains(indexNames("people"), db:_index_name("people", {"name", "city"}))) + end) + + it("skips an index that names a column the sheet does not have", function() + -- silently, on purpose: db:create would otherwise be unable to run at all + -- against a schema that lost a column + local conn = db.__conn[dbName] + db:_migrate_indexes(conn, "people", {columns = {}, options = {_index = {"nosuchcolumn"}}}, columns) + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + + it("does nothing at all for a sheet with no indexes", function() + local conn = db.__conn[dbName] + db:_migrate_indexes(conn, "people", {columns = {}, options = {}}, columns) + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + + it("runs again over an index that already exists without complaining", function() + local conn = db.__conn[dbName] + db:_migrate_indexes(conn, "people", {columns = {}, options = {_index = {"city"}}}, columns) + assert.are.same({db:_index_name("people", "city")}, indexNames("people")) + end) + end) +end) + +-- db:_closeAll is what db:close() with no name does and what the profile calls +-- on shutdown, so the rest of this file already leans on it. These specs pin +-- the two things it reports and the state it leaves behind. +describe("Tests db:_closeAll", function() + local first = "closealltestingonlyone" + local second = "closealltestingonlytwo" + + local function makeDatabases() + db:create(first, {sheet = {name = ""}}) + db:create(second, {sheet = {name = ""}}) + end + + after_each(function() + -- the specs below leave the environment closed about half the time, and + -- closing a closed one is an error rather than a no-op + if db.__env then + db:_closeAll() + end + os.remove(getMudletHomeDir() .. "/Database_" .. first .. ".db") + os.remove(getMudletHomeDir() .. "/Database_" .. second .. ".db") + end) + + it("closes every open database at once and says so", function() + makeDatabases() + local ok, msg = db:_closeAll() + assert.is_true(ok) + assert.are.equal("", msg) + assert.are.same({}, db.__conn) + assert.is_nil(db.__env) + end) + + it("leaves the databases reopenable, with their rows intact", function() + makeDatabases() + local mydb = db:get_database(first) + db:add(mydb.sheet, {name = "survivor"}) + db:_closeAll() + + local reopened = db:create(first, {sheet = {name = ""}}) + local rows = db:fetch(reopened.sheet) + assert.are.equal(1, #rows) + assert.are.equal("survivor", rows[1].name) + end) + + it("refuses when there is no database environment to close", function() + makeDatabases() + db:_closeAll() + local ok, msg = db:_closeAll() + assert.is_false(ok) + assert.are.equal("database environment is nil, did you forget to call db:create?", msg) + end) + + it("names the database that was already closed behind its back", function() + makeDatabases() + db.__conn[first]:close() + local ok, msg = db:_closeAll() + assert.is_false(ok) + assert.are.equal("database object for " .. first .. " is already closed.", msg) + -- the rest still closed, and the environment is still gone + assert.are.same({}, db.__conn) + assert.is_nil(db.__env) + end) + + it("is what db:close() with no name does", function() + makeDatabases() + assert.is_true((db:close())) + assert.is_nil(db.__env) + end) end) diff --git a/src/mudlet-lua/tests/DateTime_spec.lua b/src/mudlet-lua/tests/DateTime_spec.lua index ac4d574a4..092f84c7a 100644 --- a/src/mudlet-lua/tests/DateTime_spec.lua +++ b/src/mudlet-lua/tests/DateTime_spec.lua @@ -25,4 +25,147 @@ describe("Tests DateTime.lua functions", function() end) end) + describe("Tests datetime:parse", function() + it("parses the default ISO format into a date table", function() + local dt = datetime:parse("2025-06-15 19:34:42") + assert.are.equal(2025, dt.year) + assert.are.equal(6, dt.month) + assert.are.equal(15, dt.day) + assert.are.equal("number", type(dt.day)) + assert.are.equal(19, dt.hour) + assert.are.equal(34, dt.min) + assert.are.equal(42, dt.sec) + end) + + it("parses a full month name with %B", function() + local dt = datetime:parse("June 15, 2025", "^%B %d, %Y$") + assert.are.equal(2025, dt.year) + assert.are.equal(6, dt.month) + assert.are.equal(15, dt.day) + end) + + it("parses an abbreviated month name with %b", function() + local dt = datetime:parse("Jun 15 2025", "^%b %d %Y$") + assert.are.equal(6, dt.month) + assert.are.equal(15, dt.day) + end) + + it("parses month names case-insensitively", function() + local dt = datetime:parse("JUNE 15, 2025", "^%B %d, %Y$") + assert.are.equal(6, dt.month) + end) + + it("expands a 2-digit year with %y into the 2000s", function() + local dt = datetime:parse("25-06-15", "^%y-%m-%d$") + assert.are.equal(2025, dt.year) + assert.are.equal(6, dt.month) + end) + + it("converts 12-hour PM times to 24-hour", function() + local dt = datetime:parse("2025-06-15 01:30:00 PM", "^%Y-%m-%d %I:%M:%S %p$") + assert.are.equal(13, dt.hour) + assert.are.equal(30, dt.min) + end) + + it("keeps 12-hour AM times in the morning", function() + local dt = datetime:parse("2025-06-15 07:30:00 AM", "^%Y-%m-%d %I:%M:%S %p$") + assert.are.equal(7, dt.hour) + end) + + it("treats 12 PM as noon, hour 12, not hour 24", function() + local dt = datetime:parse("2020-01-01 12:30 PM", "^%Y-%m-%d %I:%M %p$") + assert.are.equal(12, dt.hour) + assert.are.equal(30, dt.min) + end) + + it("treats 12 AM as midnight, hour 0", function() + local dt = datetime:parse("2020-01-01 12:30 AM", "^%Y-%m-%d %I:%M %p$") + assert.are.equal(0, dt.hour) + assert.are.equal(30, dt.min) + end) + + it("treats a lowercase pm the same as an uppercase PM", function() + local dt = datetime:parse("2020-01-01 01:00 pm", "^%Y-%m-%d %I:%M %p$") + assert.are.equal(13, dt.hour) + end) + + it("treats a lowercase am the same as an uppercase AM", function() + local dt = datetime:parse("2020-01-01 12:00 am", "^%Y-%m-%d %I:%M %p$") + assert.are.equal(0, dt.hour) + end) + + it("errors when %I is used without %p", function() + local ok, err = pcall(function() + datetime:parse("2025-06-15 07:30:00", "^%Y-%m-%d %I:%M:%S$") + end) + assert.is_false(ok) + assert.is_true(string.find(err, "12-hour", 1, true) ~= nil) + end) + + it("returns nil when the source does not match the format", function() + assert.is_nil(datetime:parse("not a date")) + assert.is_nil(datetime:parse("2025-06-15", "^%Y-%m-%d %H:%M:%S$")) + end) + + it("returns a Unix epoch when as_epoch is true", function() + local epoch = datetime:parse("2025-06-15 12:30:45", nil, true) + assert.are.equal("number", type(epoch)) + local back = os.date("*t", epoch) + assert.are.equal(2025, back.year) + assert.are.equal(6, back.month) + assert.are.equal(15, back.day) + assert.are.equal(12, back.hour) + assert.are.equal(30, back.min) + assert.are.equal(45, back.sec) + end) + end) + + describe("Tests datetime:parse round-trips with string formatting", function() + it("round-trips an ISO timestamp through os.date", function() + local s = "2025-06-15 12:30:45" + local epoch = datetime:parse(s, nil, true) + assert.are.equal(s, os.date("%Y-%m-%d %H:%M:%S", epoch)) + end) + + it("round-trips a custom slash/colon format through os.date", function() + local s = "06/15/2025 08:05" + local epoch = datetime:parse(s, "^%m/%d/%Y %H:%M$", true) + assert.are.equal(s, os.date("%m/%d/%Y %H:%M", epoch)) + end) + end) + + describe("Tests datetime:calculate_UTCdiff", function() + it("returns a numeric offset within the valid timezone range", function() + local diff = datetime:calculate_UTCdiff(1718452245) + assert.are.equal("number", type(diff)) + assert.is_true(diff >= -14 * 3600 and diff <= 14 * 3600) + -- every real timezone offset is a whole number of 15-minute steps + assert.are.equal(0, diff % 900) + end) + + it("is deterministic for the same instant", function() + local t = 1718452245 + assert.are.equal(datetime:calculate_UTCdiff(t), datetime:calculate_UTCdiff(t)) + end) + end) + + describe("Tests datetime:_get_pattern", function() + it("caches and returns the same compiled pattern for a format", function() + local fmt = "^%Y-%m-%d$" + datetime._pattern_cache[fmt] = nil + local p1 = datetime:_get_pattern(fmt) + local p2 = datetime:_get_pattern(fmt) + assert.is_not_nil(datetime._pattern_cache[fmt]) + assert.are.equal(p1, p2) + end) + + it("compiles directives into a pattern that matches only valid input", function() + local fmt = "^%Y-%m-%d$" + datetime._pattern_cache[fmt] = nil + local p = datetime:_get_pattern(fmt) + assert.is_not_nil(p:tfind("2025-06-15")) + assert.is_nil(p:tfind("not-a-date")) + end) + end) + end) diff --git a/src/mudlet-lua/tests/DebugTools_spec.lua b/src/mudlet-lua/tests/DebugTools_spec.lua index a0351fb7d..fda996edf 100644 --- a/src/mudlet-lua/tests/DebugTools_spec.lua +++ b/src/mudlet-lua/tests/DebugTools_spec.lua @@ -80,4 +80,115 @@ describe("Tests DebugTools.lua functions", function() end) end) -end) \ No newline at end of file + + describe("Tests the functionality of display", function() + local function mainConsoleText() + return table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + end + + before_each(function() + clearWindow() + end) + + it("Should write an inspected table to the main console", function() + display({alpha = 1, beta = "two"}) + local text = mainConsoleText() + assert.is_truthy(text:find("alpha", 1, true)) + assert.is_truthy(text:find("beta", 1, true)) + assert.is_truthy(text:find("two", 1, true)) + end) + + it("Should write scalars the way inspect renders them", function() + display("hello") + assert.is_truthy(mainConsoleText():find('"hello"', 1, true)) + end) + + it("Should render nil rather than printing nothing", function() + display(nil) + assert.is_truthy(mainConsoleText():find("nil", 1, true)) + end) + + it("Should display each argument in the order it was given", function() + display("first", "second") + local text = mainConsoleText() + local first, second = text:find('"first"', 1, true), text:find('"second"', 1, true) + assert.is_truthy(first) + assert.is_truthy(second) + assert.is_true(first < second, "the arguments should be rendered in order") + end) + + it("Should keep the position of a nil in the middle of its arguments", function() + display("before", nil, "after") + local text = mainConsoleText() + local before = text:find('"before"', 1, true) + local nilAt = text:find("nil", 1, true) + local after = text:find('"after"', 1, true) + assert.is_truthy(before) + assert.is_truthy(nilAt) + assert.is_truthy(after) + assert.is_true(before < nilAt and nilAt < after, "the nil should keep its place between the two strings") + end) + end) + + describe("Tests the functionality of showMultimatches", function() + local savedMultimatches + + before_each(function() + clearWindow() + savedMultimatches = _G.multimatches + end) + + after_each(function() + _G.multimatches = savedMultimatches + end) + + it("Should list every regex and its captures", function() + _G.multimatches = { + {"first whole match", "first capture"}, + {"second whole match"}, + } + showMultimatches() + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_truthy(text:find("multimatches[n][m]", 1, true)) + assert.is_truthy(text:find("regex 1 captured", 1, true)) + assert.is_truthy(text:find("regex 2 captured", 1, true)) + assert.is_truthy(text:find("key=1 value=first whole match", 1, true)) + assert.is_truthy(text:find("key=2 value=first capture", 1, true)) + assert.is_truthy(text:find("key=1 value=second whole match", 1, true)) + end) + + it("Should still print its banner when there is nothing to show", function() + _G.multimatches = {} + showMultimatches() + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_truthy(text:find("multimatches[n][m]", 1, true)) + assert.is_falsy(text:find("captured", 1, true)) + end) + end) + + describe("Tests the functionality of showCaptureGroups", function() + it("Should recolour every capture group of the match", function() + local selectSpy, captured, defaultFormat, groupFormat + local id = tempRegexTrigger("^You wave (goodbye) to (everyone)\\.$", function() + captured = table.size(matches) + selectString("You wave", 1) + defaultFormat = getTextFormat().foreground + selectSpy = spy.on(_G, "selectCaptureGroup") + -- Mudlet swallows errors raised inside a trigger, so revert through + -- pcall rather than leaving the spy installed for the whole process + pcall(showCaptureGroups) + selectCaptureGroup:revert() + selectString("goodbye", 1) + groupFormat = getTextFormat().foreground + end) + feedTriggers("You wave goodbye to everyone.\n") + killTrigger(id) + + assert.is_equal(3, captured, "the whole match plus two capture groups should be present") + assert.spy(selectSpy).was.called(3) + -- the colours it picks are random, so the assertion is that the capture + -- group no longer wears the colour the rest of the line does + assert.are_not.same(defaultFormat, groupFormat) + end) + end) +end) diff --git a/src/mudlet-lua/tests/Discord_spec.lua b/src/mudlet-lua/tests/Discord_spec.lua new file mode 100644 index 000000000..7f84c2298 --- /dev/null +++ b/src/mudlet-lua/tests/Discord_spec.lua @@ -0,0 +1,880 @@ +-- Specs for the Discord rich-presence Lua API. +-- +-- Networking_spec.lua covers the availability contract - every gated function +-- returning the same denial while the discord-rpc library cannot be loaded. +-- These specs need the opposite arrangement: a Discord client to talk to. +-- CI/discord-ipc-fixture.py is one, a fake Discord IPC server that completes +-- the genuine discord-rpc handshake, reports a logged-in user, and appends +-- every frame the library sends it to the capture file named by +-- MUDLET_TEST_DISCORD_CAPTURE_FILE. That capture is what is asserted on here: +-- the SET_ACTIVITY payload that actually reached "Discord", rather than the +-- return value of the setter that produced it. Nothing is mocked - the real +-- libdiscord-rpc does the talking, over a real socket. +-- +-- To run them locally, start the fixture first: +-- python3 CI/discord-ipc-fixture.py --runtime-dir "$(mktemp -d /tmp/mdxdg-XXXX)" \ +-- --capture-file /tmp/discord-frames.jsonl --ready-file /tmp/discord-ready & +-- then start Mudlet with XDG_RUNTIME_DIR set to that runtime directory, +-- MUDLET_TEST_DISCORD_CAPTURE_FILE to that capture file, and LD_LIBRARY_PATH +-- including 3rdparty/discord/rpc/lib so the bundled library can be found. +-- +-- The fixture has to be listening BEFORE Mudlet starts. discord-rpc's +-- reconnect backoff is process-global, survives Discord_Shutdown and gates +-- even the READY read, so a server that only appears after the first failed +-- attempt costs up to a couple of minutes instead of the ~1s a cold start +-- takes. + +local capturePath = os.getenv("MUDLET_TEST_DISCORD_CAPTURE_FILE") +-- A developer's local run without the fixture pends the whole family; CI sets +-- MUDLET_TEST_REQUIRE_DISCORD so that a workflow which stops starting the +-- fixture, or an image where the library cannot be loaded, fails instead of +-- quietly skipping everything. +local requireDiscord = os.getenv("MUDLET_TEST_REQUIRE_DISCORD") + +-- Mudlet's own Discord application, from Discord::mMudletApplicationId +local mudletApplicationId = "450571881909583884" +-- MidMUD's, one of the registered test applications listed in src/discord.cpp +local otherApplicationId = "460618737712889858" + +local function contains(haystack, needle) + return type(haystack) == "string" and haystack:find(needle, 1, true) ~= nil +end + +-- Asserts that calling fn raises a Lua error whose message contains needle. +-- Matching the message rather than merely "did it error?" proves the call +-- reached its own argument validation: an unregistered function would raise a +-- different "attempt to call" error. +local function assertArgError(fn, needle) + local ok, err = pcall(fn) + assert.is_false(ok) + assert.is_true(contains(err, needle), tostring(err)) +end + +-- Every frame the fake Discord client has recorded so far, oldest first, still +-- as JSON text. Only whole lines are taken: the fixture appends one JSON +-- object per line in a single write, so an unterminated tail can only be a +-- record still being written. +local function capturedLines() + local handle = io.open(capturePath, "rb") + if not handle then + return {} + end + local body = handle:read("*a") + handle:close() + local lines = {} + for line in body:gmatch("[^\n]+\n") do + lines[#lines + 1] = line + end + return lines +end + +local function frameCount() + return #capturedLines() +end + +-- The frames recorded after `mark`, as {op = <opcode>, payload = <frame>}. +-- Decoding from `mark` rather than from the start of the file is what keeps +-- the polling below cheap as the capture grows over a suite run. +local function framesAfter(mark) + local lines = capturedLines() + local frames = {} + for index = mark + 1, #lines do + local decoded = select(2, pcall(yajl.to_value, lines[index])) + if type(decoded) == "table" then + frames[#frames + 1] = decoded + end + end + return frames +end + +-- Hands control back to Mudlet's event loop for a moment. The frames arrive on +-- discord-rpc's own IO thread and are written by the fixture process, so the +-- specs can only see them while Mudlet is idle. +local function pumpEventLoop(milliseconds) + tempTimer(milliseconds / 1000, function() raiseEvent("bustedDiscordTick") end) + waitForEvent("bustedDiscordTick", milliseconds + 1000) +end + +-- The rich presence of the first SET_ACTIVITY recorded after `mark` that +-- `accept` is satisfied with. Every new frame is offered to `accept`, not just +-- the newest one, so an update that lands while waiting cannot make the +-- expectation unsatisfiable. On timeout the most recent frame seen is returned +-- instead, so a spec whose expectation is never met reports what Discord +-- really received rather than a bare timeout. +local function waitForActivity(mark, accept, timeoutMilliseconds) + timeoutMilliseconds = timeoutMilliseconds or 5000 + local waited = 0 + local latest + while true do + for _, frame in ipairs(framesAfter(mark)) do + if frame.op == 1 and type(frame.payload) == "table" and frame.payload.cmd == "SET_ACTIVITY" then + latest = frame.payload.args.activity + if latest and (not accept or accept(latest)) then + return latest + end + end + end + if waited >= timeoutMilliseconds then + return latest + end + -- A frame normally lands within a few milliseconds of the setter, so poll + -- finely: over the whole file this is the difference between adding a + -- couple of seconds to the suite and adding ten. + pumpEventLoop(10) + waited = waited + 10 + end +end + +-- Runs `action` and returns the rich presence Discord received because of it. +local function activityFrom(action, accept, timeoutMilliseconds) + local mark = frameCount() + action() + local activity = waitForActivity(mark, accept, timeoutMilliseconds) + assert.is_table(activity, "no SET_ACTIVITY frame reached the fake Discord client in time") + -- Fail here, on the whole payload, rather than leaving the spec's own + -- assertions to index a field that never arrived and report a nil error. + if accept and not accept(activity) then + assert.is_true(false, "the presence Discord received is not the one expected: " .. tostring(select(2, pcall(yajl.to_string, activity)))) + end + return activity +end + +-- How many of the frames recorded after `mark` the fake Discord client could +-- not decode. The fixture files a frame whose payload is not valid JSON (or not +-- valid UTF-8, which JSON decoding of the payload requires) as {"raw": <text>} +-- instead of the parsed object, so this counts exactly the presence updates a +-- real Discord client would have had to throw away whole. +local function undecodableFramesAfter(mark) + local count = 0 + for _, frame in ipairs(framesAfter(mark)) do + if type(frame.payload) == "table" and frame.payload.raw ~= nil then + count = count + 1 + end + end + return count +end + +-- How many presence updates have reached the fake Discord client. Counting +-- SET_ACTIVITY frames rather than all of them keeps an unrelated handshake or +-- subscription from being mistaken for a presence update. +local function activityFrameCount() + local count = 0 + for _, frame in ipairs(framesAfter(0)) do + if frame.op == 1 and type(frame.payload) == "table" and frame.payload.cmd == "SET_ACTIVITY" then + count = count + 1 + end + end + return count +end + +-- The application IDs of the handshakes recorded after `mark`, waited for +-- until at least one arrives. Changing the application ID makes discord-rpc +-- tear its connection down and hand the new ID over in a fresh handshake, +-- which is the only externally visible proof that the switch took effect. +local function waitForHandshakes(mark, timeoutMilliseconds) + local waited = 0 + while true do + local applicationIds = {} + for _, frame in ipairs(framesAfter(mark)) do + if frame.op == 0 then + applicationIds[#applicationIds + 1] = frame.payload.client_id + end + end + if #applicationIds > 0 or waited >= (timeoutMilliseconds or 20000) then + return applicationIds + end + pumpEventLoop(50) + waited = waited + 50 + end +end + +local function discordApiAvailable() + -- A read-access getter: nil plus a message means the API is gated off, any + -- string means the library is loaded and Discord is enabled for this profile. + return getDiscordState() ~= nil +end + +-- Established lazily by connectedToFakeDiscord(): discord-rpc opens its +-- connection on the first presence update and only sends once the READY +-- dispatch has arrived, so the first frame of a run takes about a second while +-- every later one lands within ~50ms. +local connectionProbe +-- Latched once a reset stops coming back, so that a fixture which dies partway +-- through fails the rest of the file at once instead of spending every +-- remaining spec's timeout on a connection that is not going to answer. +local connectionLost = false + +-- What resetDiscordData() puts on the wire: everything cleared but the Mudlet +-- logo, which is not profile data. Recognising it exactly is what lets the +-- reset below double as a drain - once that frame has been seen, no frame from +-- an earlier spec can still be in flight. +local function emptyPresence(activity) + return type(activity.assets) == "table" and activity.assets.large_image == "mudlet" and activity.assets.large_text == nil + and activity.assets.small_image == nil and activity.assets.small_text == nil and activity.details == nil + and activity.state == nil and activity.party == nil and activity.timestamps == nil +end + +-- Clears the presence and waits for the frame that proves it arrived, which is +-- also the whole of the connection probe on the first call. +local function resetPresence(timeoutMilliseconds) + local mark = frameCount() + resetDiscordData() + local activity = waitForActivity(mark, emptyPresence, timeoutMilliseconds) + -- Re-checked, because waitForActivity() hands back the last frame it saw + -- when it times out rather than nothing at all. + return type(activity) == "table" and emptyPresence(activity) +end + +local function connectedToFakeDiscord() + if connectionProbe == nil then + connectionProbe = resetPresence(20000) + end + return connectionProbe +end + +-- Every spec below starts here. Returns false when there is no fake Discord +-- client to talk to, and otherwise leaves the presence empty so that the frame +-- the spec's own call produces is unambiguous. The reset is re-checked every +-- time rather than trusting the first probe: a fixture that dies mid-run would +-- otherwise let the remaining specs pass without asserting anything. +local function readyForDiscord() + local reason + if not capturePath then + reason = "MUDLET_TEST_DISCORD_CAPTURE_FILE is not set (fake Discord IPC server not running)" + elseif not discordApiAvailable() then + reason = "the Discord API is unavailable (discord-rpc could not be loaded, or Discord is disabled for this profile)" + elseif not connectedToFakeDiscord() then + reason = "no presence update reached the fake Discord IPC server" + elseif connectionLost then + reason = "the fake Discord IPC server did not see the cleared presence resetDiscordData() should have sent" + elseif not resetPresence(8000) then + connectionLost = true + reason = "the fake Discord IPC server did not see the cleared presence resetDiscordData() should have sent" + end + if reason then + if requireDiscord then + assert.is_true(false, "MUDLET_TEST_REQUIRE_DISCORD is set but " .. reason) + end + pending(reason) + return false + end + return true +end + +describe("Discord presence reaches Discord", function() + it("completes the IPC handshake with Mudlet's own application ID", function() + if not readyForDiscord() then + return + end + -- The first handshake of the run, which is the one Mudlet's own presence + -- opened before any spec asked for a different application. + assert.equals(mudletApplicationId, waitForHandshakes(0)[1]) + end) + + it("reports that the default Mudlet application ID is in use", function() + if not readyForDiscord() then + return + end + assert.is_true(usingMudletsDiscordID()) + end) + + it("sends the Mudlet logo as the large icon when the profile sets none", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordState("no icon of its own") end) + assert.equals("mudlet", activity.assets.large_image) + end) + + it("leaves every read-access function callable while Discord is available", function() + if not readyForDiscord() then + return + end + -- The mirror of Networking_spec.lua's availability contract: those specs + -- prove one shared denial while the API is gated off, this one proves the + -- same set is reachable once it is not. + local readers = { + "getDiscordDetail", "getDiscordLargeIcon", "getDiscordLargeIconText", + "getDiscordParty", "getDiscordSmallIcon", "getDiscordSmallIconText", + "getDiscordState", "getDiscordTimeStamps", "usingMudletsDiscordID", + } + for _, name in ipairs(readers) do + assert.is_not_nil(_G[name](), name .. " should be reachable while Discord is available") + end + end) +end) + +describe("setDiscordDetail", function() + it("sends the detail text to Discord", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordDetail("Exploring the fixture") end, + function(seen) return seen.details == "Exploring the fixture" end) + assert.equals("Exploring the fixture", activity.details) + end) + + it("reports the detail text it sent", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordDetail("Hunting in the woods")) + assert.equals("Hunting in the woods", getDiscordDetail()) + end) + + it("substitutes a placeholder for an empty detail text", function() + if not readyForDiscord() then + return + end + -- The placeholder is tr("via Mudlet"), so a localised Mudlet sends a + -- different string; what matters is that something took the empty text's + -- place and that it is what the getter reports. + local activity = activityFrom(function() setDiscordDetail("") end, + function(seen) return seen.details ~= nil end) + assert.is_true(#activity.details > 1) + assert.equals(getDiscordDetail(), activity.details) + end) + + it("refuses a one character detail text and leaves the presence alone", function() + if not readyForDiscord() then + return + end + -- Waited for, so that the frame count below can only move if the rejected + -- call produced one of its own. + activityFrom(function() setDiscordDetail("still here") end, + function(seen) return seen.details == "still here" end) + local presenceUpdates = activityFrameCount() + local ok, message = setDiscordDetail("x") + assert.is_nil(ok) + assert.is_true(contains(message, "text of length 1 not allowed by Discord")) + assert.equals("still here", getDiscordDetail()) + -- A rejected setter must not reach Discord at all, so no further presence + -- update is expected - unlike everywhere else, seeing one here is the + -- failure. + pumpEventLoop(500) + assert.equals(presenceUpdates, activityFrameCount()) + end) + + it("sends a detail text containing a percent sequence unchanged", function() + if not readyForDiscord() then + return + end + -- Only what reaches Discord is asserted on here; reading the same text back + -- is the other half, covered by the percent sequence specs below. + local activity = activityFrom(function() setDiscordDetail("Level %d Mage") end, + function(seen) return seen.details ~= nil end) + assert.equals("Level %d Mage", activity.details) + end) + + it("raises a Lua error when the detail text is not a string", function() + if not readyForDiscord() then + return + end + -- Only reachable with the API available: the availability gate is checked + -- before any argument is, so Networking_spec.lua cannot get this far. + assertArgError(function() setDiscordDetail({}) end, "setDiscordDetail: bad argument #1") + end) + + it("truncates a detail text that overflows Discord's 128 byte field", function() + if not readyForDiscord() then + return + end + local overlong = string.rep("a", 200) + local activity = activityFrom(function() setDiscordDetail(overlong) end, + function(seen) return seen.details ~= nil end) + -- The whole documented 128 bytes, not 127: the buffer holding this used to + -- be exactly 128 bytes and lost its last byte to the null terminator + -- (#9634). + assert.equals(128, #activity.details) + assert.equals(string.rep("a", 128), activity.details) + -- Only what Discord is sent is truncated; Mudlet keeps the whole string. + assert.equals(overlong, getDiscordDetail()) + end) + + it("cuts an overlong non-ASCII detail text between characters", function() + if not readyForDiscord() then + return + end + -- #9634: the cut used to be made at the byte limit with no regard for + -- UTF-8, leaving the last character in the field as a lone lead byte. That + -- does not merely damage one field - the payload stops being decodable, so + -- the whole SET_ACTIVITY frame is discarded and every well-formed field in + -- it goes with it. + local mark = frameCount() + -- 65 two-byte characters, 130 bytes: two more than the field holds, so the + -- cut has to fall inside the 65th character. + local activity = activityFrom(function() setDiscordDetail(string.rep("ä", 65)) end, + function(seen) return seen.details ~= nil end) + assert.equals(string.rep("ä", 64), activity.details) + assert.equals(128, #activity.details) + assert.equals(0, undecodableFramesAfter(mark)) + end) +end) + +describe("setDiscordState", function() + it("sends the state text to Discord", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordState("Level 50 Mage") end, + function(seen) return seen.state == "Level 50 Mage" end) + assert.equals("Level 50 Mage", activity.state) + end) + + it("reports the state text it sent", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordState("In combat")) + assert.equals("In combat", getDiscordState()) + end) + + it("omits the state field entirely when the state text is empty", function() + if not readyForDiscord() then + return + end + -- Empty fields are sent as JSON absences, not as "", so Discord hides the + -- line rather than showing a blank one. + local activity = activityFrom(function() setDiscordState("") end) + assert.is_nil(activity.state) + end) + + it("refuses a one character state text", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordState("holding")) + local ok, message = setDiscordState("x") + assert.is_nil(ok) + assert.is_true(contains(message, "text of length 1 not allowed by Discord")) + assert.equals("holding", getDiscordState()) + end) +end) + +describe("setDiscordGame", function() + it("sets both the detail text and the large icon from the game name", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordGame("WoTMUD") end, + function(seen) return seen.details ~= nil and seen.assets.large_image == "wotmud" end) + -- The detail text is tr("Playing %1"), so only the interpolated game name + -- is the same on a localised Mudlet. + assert.is_true(contains(activity.details, "WoTMUD")) + assert.equals("wotmud", activity.assets.large_image) + end) +end) + +describe("setDiscordLargeIcon and setDiscordSmallIcon", function() + it("lower-cases the large icon key on the way to Discord", function() + if not readyForDiscord() then + return + end + -- Discord asset keys are lower case, so Mudlet folds whatever it is given. + local activity = activityFrom(function() setDiscordLargeIcon("Achaea") end, + function(seen) return seen.assets.large_image ~= "mudlet" end) + assert.equals("achaea", activity.assets.large_image) + assert.equals("achaea", getDiscordLargeIcon()) + end) + + it("sends the large icon's tooltip text", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordLargeIconText("Achaea, Dreams of Divine Lands") end, + function(seen) return seen.assets.large_text ~= nil end) + assert.equals("Achaea, Dreams of Divine Lands", activity.assets.large_text) + assert.equals("Achaea, Dreams of Divine Lands", getDiscordLargeIconText()) + end) + + it("lower-cases the small icon key on the way to Discord", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordSmallIcon("Shield") end, + function(seen) return seen.assets.small_image ~= nil end) + assert.equals("shield", activity.assets.small_image) + assert.equals("shield", getDiscordSmallIcon()) + end) + + it("sends a full length icon key without dropping its last character", function() + if not readyForDiscord() then + return + end + -- #9634: a Discord asset key may be the full 32 bytes the API documents, + -- but the buffer was 32 bytes including the terminator, so the last + -- character was cut off and the icon never resolved. + local key = string.rep("a", 32) + local activity = activityFrom(function() setDiscordLargeIcon(key) end, + function(seen) return seen.assets.large_image ~= "mudlet" end) + assert.equals(32, #activity.assets.large_image) + assert.equals(key, activity.assets.large_image) + end) + + it("sends the small icon's tooltip text", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordSmallIconText("Guardian") end, + function(seen) return seen.assets.small_text ~= nil end) + assert.equals("Guardian", activity.assets.small_text) + assert.equals("Guardian", getDiscordSmallIconText()) + end) + + it("refuses a one character large icon text", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordLargeIconText("unchanged")) + local ok, message = setDiscordLargeIconText("x") + assert.is_nil(ok) + assert.is_true(contains(message, "text of length 1 not allowed by Discord")) + assert.equals("unchanged", getDiscordLargeIconText()) + end) + + it("refuses a one character small icon text", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordSmallIconText("unchanged")) + local ok, message = setDiscordSmallIconText("x") + assert.is_nil(ok) + assert.is_true(contains(message, "text of length 1 not allowed by Discord")) + assert.equals("unchanged", getDiscordSmallIconText()) + end) +end) + +describe("presence text containing percent sequences", function() + -- The getters used to hand the stored text to lua_pushfstring() as its format + -- string, so every '%' in it was read as a printf specifier: "Level %d Mage" + -- came back with a garbage number where the %d was, and a "%s" dereferenced a + -- pointer that had never been passed. Presence text can arrive from the game + -- server over GMCP, so a status line with a stray percent sign in it was all + -- it took. All six getters are covered below rather than a sample of them, + -- so a seventh added the old way would be caught here too. + it("reports a detail text containing %d unchanged", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordDetail("Level %d Mage")) + assert.equals("Level %d Mage", getDiscordDetail()) + end) + + it("reports a state text containing %s unchanged", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordState("Wielding %s in the left hand")) + assert.equals("Wielding %s in the left hand", getDiscordState()) + end) + + it("reports an icon tooltip containing percent signs unchanged", function() + if not readyForDiscord() then + return + end + -- A doubled "%%" is the sequence the format-string path did not garble but + -- silently halved, and a bare "% " one it left alone - a caller could not + -- have escaped its way around either. + assert.is_true(setDiscordLargeIconText("100%% health, 50% mana")) + assert.equals("100%% health, 50% mana", getDiscordLargeIconText()) + end) + + it("reports a detail text ending in a percent sign unchanged", function() + if not readyForDiscord() then + return + end + -- The worst shape of the old bug rather than another spelling of the first + -- spec: on a trailing '%' the format-string path stepped one byte past the + -- terminator and scanned on, which ASan reports as a heap buffer overflow. + assert.is_true(setDiscordDetail("mana at 50%")) + assert.equals("mana at 50%", getDiscordDetail()) + end) + + it("reports the icon keys and the small icon tooltip unchanged", function() + if not readyForDiscord() then + return + end + -- The remaining three of the six getters. Icon keys come back lower-cased + -- because that is what Discord's asset names are, which is the only change + -- to them anyone should see. + assert.is_true(setDiscordLargeIcon("Level %d Mage")) + assert.equals("level %d mage", getDiscordLargeIcon()) + assert.is_true(setDiscordSmallIcon("Shield %s")) + assert.equals("shield %s", getDiscordSmallIcon()) + assert.is_true(setDiscordSmallIconText("100%% shielded, 50% rested")) + assert.equals("100%% shielded, 50% rested", getDiscordSmallIconText()) + end) +end) + +describe("setDiscordElapsedStartTime and setDiscordRemainingEndTime", function() + it("sends an elapsed start time and no end time", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordElapsedStartTime(1700000000) end, + function(seen) return seen.timestamps ~= nil end) + assert.equals(1700000000, activity.timestamps.start) + assert.is_nil(activity.timestamps["end"]) + end) + + it("replaces an elapsed start time with a remaining end time", function() + if not readyForDiscord() then + return + end + -- The two are mutually exclusive: Discord shows either "elapsed" or + -- "remaining", so setting one has to clear the other. + assert.is_true(setDiscordElapsedStartTime(1700000000)) + local activity = activityFrom(function() setDiscordRemainingEndTime(1900000000) end, + function(seen) return seen.timestamps and seen.timestamps["end"] ~= nil end) + assert.equals(1900000000, activity.timestamps["end"]) + assert.is_nil(activity.timestamps.start) + end) + + it("reports the timestamps it sent", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordElapsedStartTime(1700000000)) + local startTime, endTime = getDiscordTimeStamps() + assert.equals(1700000000, startTime) + assert.equals(0, endTime) + + assert.is_true(setDiscordRemainingEndTime(1900000000)) + startTime, endTime = getDiscordTimeStamps() + assert.equals(0, startTime) + assert.equals(1900000000, endTime) + end) + + it("drops the timestamps entirely when given zero", function() + if not readyForDiscord() then + return + end + -- Waited for, not just called: the spec below asserts on the first frame + -- that follows, so this one's has to have landed already. + activityFrom(function() setDiscordElapsedStartTime(1700000000) end, + function(seen) return seen.timestamps ~= nil end) + local activity = activityFrom(function() setDiscordElapsedStartTime(0) end) + assert.is_nil(activity.timestamps) + end) + + it("refuses a negative elapsed start time", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordElapsedStartTime(1700000000)) + local ok, message = setDiscordElapsedStartTime(-1) + assert.is_nil(ok) + assert.is_true(contains(message, "the timestamp must be zero")) + assert.equals(1700000000, (getDiscordTimeStamps())) + end) + + it("refuses a negative remaining end time", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordRemainingEndTime(1900000000)) + local ok, message = setDiscordRemainingEndTime(-1) + assert.is_nil(ok) + assert.is_true(contains(message, "the timestamp must be zero")) + assert.equals(1900000000, select(2, getDiscordTimeStamps())) + end) +end) + +describe("setDiscordParty", function() + it("sends the party size and maximum", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordParty(2, 5) end, + function(seen) return seen.party ~= nil end) + assert.same({2, 5}, activity.party.size) + end) + + it("raises the maximum to the size when only a size is given", function() + if not readyForDiscord() then + return + end + local activity = activityFrom(function() setDiscordParty(3) end, + function(seen) return seen.party ~= nil end) + assert.same({3, 3}, activity.party.size) + end) + + it("keeps an established maximum when only a smaller size is given", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordParty(2, 5)) + local activity = activityFrom(function() setDiscordParty(1) end, + function(seen) return seen.party and seen.party.size[1] == 1 end) + assert.same({1, 5}, activity.party.size) + end) + + it("removes the party from the presence when the maximum is zero", function() + if not readyForDiscord() then + return + end + activityFrom(function() setDiscordParty(2, 5) end, function(seen) return seen.party ~= nil end) + local activity = activityFrom(function() setDiscordParty(0, 0) end) + assert.is_nil(activity.party) + end) + + it("reports the party it sent", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordParty(4, 8)) + local size, maximum = getDiscordParty() + assert.equals(4, size) + assert.equals(8, maximum) + end) + + it("refuses a negative party size", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordParty(2, 5)) + local ok, message = setDiscordParty(-1) + assert.is_nil(ok) + assert.is_true(contains(message, "the current party size must be zero or more")) + assert.equals(2, (getDiscordParty())) + end) + + it("refuses a negative party maximum", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordParty(2, 5)) + local ok, message = setDiscordParty(3, -1) + assert.is_nil(ok) + assert.is_true(contains(message, "the optional party maximum size")) + -- Both, so that a rejected call falling through to the size-only overload + -- would be caught rather than looking unchanged. + local size, maximum = getDiscordParty() + assert.equals(2, size) + assert.equals(5, maximum) + end) + + it("raises a Lua error when the party size is not a number", function() + if not readyForDiscord() then + return + end + assertArgError(function() setDiscordParty("a few") end, "setDiscordParty: bad argument #1") + end) +end) + +describe("resetDiscordData", function() + it("clears every presence field it had set", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordDetail("Exploring the forest")) + assert.is_true(setDiscordState("Level 50 Mage")) + assert.is_true(setDiscordLargeIcon("achaea")) + assert.is_true(setDiscordLargeIconText("Achaea")) + assert.is_true(setDiscordSmallIcon("shield")) + assert.is_true(setDiscordSmallIconText("Guardian")) + assert.is_true(setDiscordParty(2, 5)) + assert.is_true(setDiscordElapsedStartTime(1700000000)) + + local activity = activityFrom(function() resetDiscordData() end, + function(seen) return seen.details == nil end) + assert.is_nil(activity.details) + assert.is_nil(activity.state) + assert.is_nil(activity.party) + assert.is_nil(activity.timestamps) + assert.is_nil(activity.assets.large_text) + assert.is_nil(activity.assets.small_image) + assert.is_nil(activity.assets.small_text) + -- The Mudlet logo is not profile data, so it comes back with the reset + -- presence rather than being cleared by it. + assert.equals("mudlet", activity.assets.large_image) + end) + + it("clears what the getters report", function() + if not readyForDiscord() then + return + end + assert.is_true(setDiscordDetail("Exploring the forest")) + assert.is_true(setDiscordSmallIconText("Guardian")) + assert.is_true(setDiscordParty(2, 5)) + assert.is_true(setDiscordElapsedStartTime(1700000000)) + + assert.is_true(resetDiscordData()) + + assert.equals("", getDiscordDetail()) + assert.equals("", getDiscordState()) + assert.equals("", getDiscordLargeIcon()) + assert.equals("", getDiscordSmallIconText()) + assert.equals(0, (getDiscordParty())) + assert.equals(0, (getDiscordTimeStamps())) + end) +end) + +describe("setDiscordApplicationID", function() + it("reconnects to Discord under the new application ID and back again", function() + if not readyForDiscord() then + return + end + -- Should an assertion below leave the other application in place, the + -- following spec would open with a reconnect it does not expect. + finally(function() setDiscordApplicationID() end) + + -- Both directions in one spec on purpose: each switch makes discord-rpc + -- drop its socket and hand the new ID over in a fresh handshake, which + -- costs about a second and a half of real reconnect time. + local mark = frameCount() + assert.is_true(setDiscordApplicationID(otherApplicationId)) + assert.is_false(usingMudletsDiscordID()) + -- The first handshake after the switch, rather than the whole list: a + -- connection attempt that had to be retried would add another. + assert.equals(otherApplicationId, waitForHandshakes(mark)[1]) + + mark = frameCount() + assert.is_true(setDiscordApplicationID()) + assert.is_true(usingMudletsDiscordID()) + assert.equals(mudletApplicationId, waitForHandshakes(mark)[1]) + end) + + it("treats an empty application ID as the request to go back to Mudlet's", function() + if not readyForDiscord() then + return + end + finally(function() setDiscordApplicationID() end) + + local mark = frameCount() + assert.is_true(setDiscordApplicationID(otherApplicationId)) + assert.equals(otherApplicationId, waitForHandshakes(mark)[1]) + + mark = frameCount() + assert.is_true(setDiscordApplicationID("")) + assert.is_true(usingMudletsDiscordID()) + assert.equals(mudletApplicationId, waitForHandshakes(mark)[1]) + end) + + it("refuses an application ID that is not a number", function() + if not readyForDiscord() then + return + end + local ok, message = setDiscordApplicationID("not-an-id") + assert.is_nil(ok) + assert.is_true(contains(message, "can not be converted to the expected numeric Discord application ID")) + assert.is_true(usingMudletsDiscordID()) + end) +end) + +describe("an icon key that has to be truncated", function() + it("cuts a non-ASCII key between characters and keeps the frame decodable", function() + if not readyForDiscord() then + return + end + -- The 32 byte fields cut in the same place as the 128 byte ones, so they + -- broke the frame in the same way (#9634). 16 two-byte characters are 32 + -- bytes, which now fits exactly; a 17th has to go, whole. + local mark = frameCount() + local activity = activityFrom(function() setDiscordLargeIcon(string.rep("é", 17)) end, + function(seen) return seen.assets.large_image ~= "mudlet" end) + assert.equals(string.rep("é", 16), activity.assets.large_image) + assert.equals(32, #activity.assets.large_image) + assert.equals(0, undecodableFramesAfter(mark)) + end) +end) diff --git a/src/mudlet-lua/tests/GMCP_spec.lua b/src/mudlet-lua/tests/GMCP_spec.lua index 3d094b9a0..476eb67ac 100644 --- a/src/mudlet-lua/tests/GMCP_spec.lua +++ b/src/mudlet-lua/tests/GMCP_spec.lua @@ -150,4 +150,157 @@ describe("tests the functionality of the gmod module", function() gmod.disableModule(user2, module2) end) end) -end) \ No newline at end of file +end) + +describe("Tests the argument and disconnected contract of sendGMCP", function() + -- Contract-only checks: sendGMCP is never mocked here and never reaches a + -- live game server. The self-test profile is forced into a disconnected + -- state so the connection guard is exercised deterministically; verifying + -- the actual bytes on the wire is a separate, stub-based effort. + local function contains(haystack, needle) + return type(haystack) == "string" and haystack:find(needle, 1, true) ~= nil + end + + before_each(function() + disconnect() + end) + + it("names the offending value's real type when the message is not a string", function() + -- Regression #9543: the type-name placeholder must be expanded, not printed + -- as a literal "%1". lua_pushfstring only understands C-style "%s". + local ok, err = pcall(function() sendGMCP({}) end) + assert.is_false(ok) + assert.is_true(contains(err, "sendGMCP: bad argument #1 type (message as string expected, got table!)"), tostring(err)) + assert.is_false(contains(err, "%1"), tostring(err)) + + local okBool, errBool = pcall(function() sendGMCP(true) end) + assert.is_false(okBool) + assert.is_true(contains(errBool, "sendGMCP: bad argument #1 type (message as string expected, got boolean!)"), tostring(errBool)) + end) + + it("names the real type when the optional second argument is not a string", function() + local ok, err = pcall(function() sendGMCP("Core.Ping", {}) end) + assert.is_false(ok) + assert.is_true(contains(err, "sendGMCP: bad argument #2 type (what as string is optional, got table!)"), tostring(err)) + assert.is_false(contains(err, "%1"), tostring(err)) + end) + + it("returns nil and an explanatory message while disconnected", function() + local ok, err = sendGMCP("External.Discord.Hello") + assert.is_nil(ok) + assert.is_true(contains(err, "not connected to game server")) + end) +end) + +describe("Tests the functionality of gmod.print", function() + it("Should write the tracker prefixed message to the main console", function() + clearWindow() + gmod.print("a tracker message") + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_truthy(text:find("[GMCP Tracker]", 1, true)) + assert.is_truthy(text:find("a tracker message", 1, true)) + end) + + it("Should colour the prefix yellow and the message white", function() + clearWindow() + gmod.print("coloured message") + -- the message is wrapped in newlines, so park the cursor on its line + -- before selecting: selectString only searches the current line + local lines = getLines("main", 0, getLastLineNumber("main") + 1) + local index + for i, line in ipairs(lines) do + if line:find("[GMCP Tracker]", 1, true) then + index = i - 1 + end + end + assert.is_not_nil(index, "gmod.print should have written a tracker line") + moveCursor(0, index) + selectString("[GMCP Tracker]", 1) + assert.are.same(color_table["yellow"], getTextFormat().foreground) + selectString("coloured message", 1) + assert.are.same(color_table["white"], getTextFormat().foreground) + end) +end) + +describe("Tests the functionality of gmod.reenableModules", function() + local user = "reenableUser" + local module = "OogaBoogaReenableModule" + + after_each(function() + gmod.disableModule(user, module) + gmcp.BustedReenableProbe = nil + end) + + it("Should send nothing while the gmcp table is still empty", function() + -- reenableModules is driven by sysProtocolEnabled, which can fire before + -- the server has sent any GMCP at all + if next(gmcp) then + -- the profile or an earlier spec left GMCP data behind, so the guard + -- this test is about cannot be reached + pending("the gmcp table is not empty in this profile") + end + gmod.enableModule(user, module) + local sg = spy.on(_G, "sendGMCP") + finally(function() sendGMCP:revert() end) + gmod.reenableModules() + assert.spy(sg).was_not_called() + end) + + it("Should re-announce every registered module once GMCP data has arrived", function() + gmod.enableModule(user, module) + gmcp.BustedReenableProbe = {} + local sg = spy.on(_G, "sendGMCP") + finally(function() sendGMCP:revert() end) + gmod.reenableModules() + assert.spy(sg).was_called_with(match.has_match("Core.Supports.Add .*" .. module .. " 1")) + end) + + it("Should send nothing when no module is registered", function() + gmcp.BustedReenableProbe = {} + local sg = spy.on(_G, "sendGMCP") + finally(function() sendGMCP:revert() end) + -- a module registered by an earlier spec would be re-announced too, so + -- measure the difference this test's own module makes + gmod.reenableModules() + local withoutOurs = #sg.calls + gmod.enableModule(user, module) + sendGMCP:clear() + gmod.reenableModules() + assert.is_true(#sg.calls > 0, "a registered module should be re-announced") + assert.are.equal(0, withoutOurs, "nothing should be announced while no module is registered") + end) +end) + +describe("Tests the functionality of __gmcp_merge_gmcp_sub_tables", function() + it("Should fold the staged table into the named sub table", function() + local a = {Char = {name = "old", level = 1}, __needMerge = {name = "new", hp = 50}} + __gmcp_merge_gmcp_sub_tables(a, "Char") + assert.are.same({name = "new", level = 1, hp = 50}, a.Char) + end) + + it("Should clear the staging table afterwards", function() + local a = {Room = {}, __needMerge = {num = 7}} + __gmcp_merge_gmcp_sub_tables(a, "Room") + assert.is_nil(a.__needMerge) + end) + + it("Should leave the sub table alone when nothing is staged", function() + local a = {Room = {num = 7}, __needMerge = {}} + __gmcp_merge_gmcp_sub_tables(a, "Room") + assert.are.same({num = 7}, a.Room) + assert.is_nil(a.__needMerge) + end) + + it("Should raise rather than silently drop data when the sub table is missing", function() + -- the C++ side stages into __needMerge and calls this immediately, so a + -- module arriving before its sub table exists is a real ordering case + assert.has_error(function() __gmcp_merge_gmcp_sub_tables({__needMerge = {a = 1}}, "Char") end) + assert.has_error(function() __gmcp_merge_gmcp_sub_tables({Char = {}}, "Char") end) + end) + + it("Should merge nested tables by replacing them wholesale", function() + local a = {Char = {Vitals = {hp = 1}}, __needMerge = {Vitals = {mp = 2}}} + __gmcp_merge_gmcp_sub_tables(a, "Char") + assert.are.same({Vitals = {mp = 2}}, a.Char) + end) +end) diff --git a/src/mudlet-lua/tests/GUIUtils_spec.lua b/src/mudlet-lua/tests/GUIUtils_spec.lua index 1b3f8838f..06facb385 100644 --- a/src/mudlet-lua/tests/GUIUtils_spec.lua +++ b/src/mudlet-lua/tests/GUIUtils_spec.lua @@ -1067,9 +1067,1296 @@ describe("Tests the GUI utilities as far as possible without mudlet", function() assert.equals(3, funcCalls) end) end) -end) ---[[ - TODO: - replaceLine and variants ---]] + describe("Tests the functionality of PadHexNum", function() + it("Should zero-pad a single hex digit below ten", function() + assert.equals("00", PadHexNum("0")) + assert.equals("05", PadHexNum("5")) + assert.equals("09", PadHexNum("9")) + end) + + it("Should leave an already two digit number alone", function() + assert.equals("FF", PadHexNum("FF")) + assert.equals("0A", PadHexNum("0A")) + assert.equals("10", PadHexNum("10")) + -- "00" is worth its own assertion: its value is below sixteen, so a pad + -- driven by value rather than by width grows it to three digits + assert.equals("00", PadHexNum("00")) + end) + + it("Should error when not given a string", function() + assert.has_error(function() PadHexNum(15) end) + end) + + it("Should error when the string is not a hex number", function() + -- the message matters: the old code reached the same outcome by accident, + -- comparing a nil tonumber() result against a number + assert.has_error(function() PadHexNum("zz") end, + 'PadHexNum: bad argument #1 value (hex number as string expected, got "zz"!)') + assert.has_error(function() PadHexNum("") end, + 'PadHexNum: bad argument #1 value (hex number as string expected, got ""!)') + end) + + it("Should zero-pad single hex digits above nine as well", function() + assert.equals("0A", PadHexNum("A")) + assert.equals("0B", PadHexNum("B")) + assert.equals("0F", PadHexNum("F")) + end) + + it("Should pad every single digit to the same width", function() + for value = 0, 15 do + local padded = PadHexNum(string.format("%X", value)) + assert.equals(2, #padded) + assert.equals(value, tonumber(padded, 16)) + end + end) + end) + + describe("Tests the functionality of RGB2Hex", function() + it("Should convert an r, g, b triple to a six digit hex string", function() + assert.equals("FFFFFF", RGB2Hex(255, 255, 255)) + assert.equals("000000", RGB2Hex(0, 0, 0)) + assert.equals("80C020", RGB2Hex(128, 192, 32)) + end) + + it("Should accept a colour name in place of the triple", function() + assert.equals("FFFFFF", RGB2Hex("white")) + assert.equals("000000", RGB2Hex("black")) + assert.equals(RGB2Hex(getRGB("blue")), RGB2Hex("blue")) + end) + + it("Should error when given no arguments at all", function() + assert.has_error(function() RGB2Hex() end) + end) + + it("Should produce six hex digits for every component below sixteen", function() + assert.equals("0A0B0C", RGB2Hex(10, 11, 12)) + assert.equals("0A0A0A", RGB2Hex(10, 10, 10)) + end) + + it("Should encode a small component as its own value, not a shifted one", function() + -- the damaging case: a well formed six digit string that names the wrong + -- colour, so nothing downstream can notice. 11 must not become 0xB0 (176) + assert.equals("C80B0C", RGB2Hex(200, 11, 12)) + assert.equals("FF0000", RGB2Hex(255, 0, 0)) + end) + + -- in 0-255 only: RGB2Hex range-checks nothing, so an out of range component + -- still produces a longer string. That is a separate defect from the padding + it("Should return six hex digits for every component value in 0-255", function() + for _, component in ipairs({0, 1, 9, 10, 15, 16, 17, 128, 255}) do + local hex = RGB2Hex(component, component, component) + assert.equals(6, #hex) + for position = 1, 5, 2 do + assert.equals(component, tonumber(hex:sub(position, position + 1), 16)) + end + end + end) + end) + + describe("Tests the functionality of getRGB", function() + it("Should return the three components of a named colour", function() + local r, g, b = getRGB("red") + assert.are.same({255, 0, 0}, {r, g, b}) + assert.are.same(color_table["green"], {getRGB("green")}) + end) + + it("Should honour a colour the user has redefined", function() + local original = color_table["ansi_000"] + color_table["ansi_000"] = {1, 2, 3} + local r, g, b = getRGB("ansi_000") + color_table["ansi_000"] = original + assert.are.same({1, 2, 3}, {r, g, b}) + end) + + it("Should error when not given a string", function() + assert.has_error(function() getRGB(42) end) + end) + + it("Should error for a colour name that does not exist", function() + assert.has_error(function() getRGB("definitelyNotAColour") end) + end) + end) + + describe("Tests the functionality of unpack_w_nil", function() + it("Should return every value up to n, including embedded nils", function() + local packed = {1, nil, 3, n = 3} + local a, b, c = unpack_w_nil(packed) + assert.are.same({1, nil, 3}, {a, b, c}) + assert.is_nil(b) + end) + + it("Should start at the counter it is given", function() + local packed = {"a", "b", "c", n = 3} + assert.are.same({"b", "c"}, {unpack_w_nil(packed, 2)}) + end) + + it("Should return a trailing nil rather than stopping short of n", function() + local packed = {"only", nil, n = 2} + -- a plain assignment cannot tell "returned nil" from "returned nothing", + -- so count the results + assert.equals(2, select("#", unpack_w_nil(packed))) + local first, second = unpack_w_nil(packed) + assert.equals("only", first) + assert.is_nil(second) + end) + end) + + describe("Tests the functionality of the custom gauge family", function() + local gaugeName = "guiUtilsTestGauge" + + local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} + end + + before_each(function() + createGauge("main", gaugeName, 300, 20, 30, 300, "start", 0, 255, 0, "horizontal") + end) + + after_each(function() + for _, suffixName in ipairs({"_back", "_front", "_text"}) do + pcall(deleteLabel, gaugeName .. suffixName) + end + gaugesTable[gaugeName] = nil + end) + + describe("Tests the functionality of createGauge", function() + it("Should create the back, front and text labels at the requested geometry", function() + for _, suffixName in ipairs({"_back", "_front", "_text"}) do + assert.equals("label", windowType(gaugeName .. suffixName)) + end + assert.are.same({x = 30, y = 300, width = 300, height = 20}, geometry(gaugeName .. "_back")) + assert.are.same({x = 30, y = 300, width = 300, height = 20}, geometry(gaugeName .. "_text")) + -- a fresh gauge is full, so the front label covers the whole back one + assert.are.same({x = 30, y = 300, width = 300, height = 20}, geometry(gaugeName .. "_front")) + end) + + it("Should record the gauge in gaugesTable and show it", function() + local info = gaugesTable[gaugeName] + assert.equals(300, info.width) + assert.equals(20, info.height) + assert.equals(30, info.x) + assert.equals(300, info.y) + assert.equals("horizontal", info.orientation) + assert.equals(1, info.value) + assert.is_true(windowVisible(gaugeName .. "_back")) + assert.is_true(windowVisible(gaugeName .. "_front")) + end) + + it("Should accept a colour name in place of the r, g, b triple", function() + finally(function() + for _, suffixName in ipairs({"_back", "_front", "_text"}) do + pcall(deleteLabel, "colourNameGauge" .. suffixName) + end + gaugesTable.colourNameGauge = nil + end) + createGauge("colourNameGauge", 100, 10, 0, 0, nil, "green") + assert.are.same({0, 255, 0}, {gaugesTable.colourNameGauge.r, gaugesTable.colourNameGauge.g, gaugesTable.colourNameGauge.b}) + assert.equals("horizontal", gaugesTable.colourNameGauge.orientation) + end) + + it("Should reject an unknown orientation", function() + assert.has_error(function() + createGauge("main", "badOrientationGauge", 10, 10, 0, 0, "", 0, 0, 0, "sideways") + end) + end) + end) + + describe("Tests the functionality of setGauge", function() + it("Should shrink the front label to the fraction given, horizontally", function() + setGauge(gaugeName, 50, 100) + assert.equals(0.5, gaugesTable[gaugeName].value) + assert.are.same({x = 30, y = 300, width = 150, height = 20}, geometry(gaugeName .. "_front")) + -- the backdrop keeps its full size + assert.are.same({x = 30, y = 300, width = 300, height = 20}, geometry(gaugeName .. "_back")) + end) + + it("Should grow a vertical gauge upwards from its bottom edge", function() + gaugesTable[gaugeName].orientation = "vertical" + setGauge(gaugeName, 1, 4) + assert.are.same({x = 30, y = 315, width = 300, height = 5}, geometry(gaugeName .. "_front")) + end) + + it("Should shrink a goofy gauge towards its right edge", function() + gaugesTable[gaugeName].orientation = "goofy" + setGauge(gaugeName, 1, 4) + assert.are.same({x = 255, y = 300, width = 75, height = 20}, geometry(gaugeName .. "_front")) + end) + + it("Should shrink a batty gauge downwards from its top edge", function() + gaugesTable[gaugeName].orientation = "batty" + setGauge(gaugeName, 1, 2) + assert.are.same({x = 30, y = 300, width = 300, height = 10}, geometry(gaugeName .. "_front")) + end) + + it("Should update the caption when one is passed", function() + setGauge(gaugeName, 1, 2, "half") + assert.is_truthy(getLabelText(gaugeName .. "_text"):find("half", 1, true)) + end) + + it("Should let the fill run past the backdrop when the value exceeds the maximum", function() + setGauge(gaugeName, 3, 2) + assert.equals(1.5, gaugesTable[gaugeName].value) + assert.equals(450, select(3, getWindowGeometry(gaugeName .. "_front"))) + end) + + it("Should error for an unknown gauge or a non numeric value", function() + assert.has_error(function() setGauge("noSuchGauge", 1, 1) end) + assert.has_error(function() setGauge(gaugeName, "lots", 1) end) + assert.has_error(function() setGauge(gaugeName, 1, "lots") end) + end) + end) + + describe("Tests the functionality of moveGauge", function() + it("Should move every label of the gauge and remember the new position", function() + moveGauge(gaugeName, 11, 22) + assert.are.same({x = 11, y = 22, width = 300, height = 20}, geometry(gaugeName .. "_back")) + assert.are.same({x = 11, y = 22, width = 300, height = 20}, geometry(gaugeName .. "_text")) + assert.are.same({x = 11, y = 22, width = 300, height = 20}, geometry(gaugeName .. "_front")) + assert.equals(11, gaugesTable[gaugeName].x) + assert.equals(22, gaugesTable[gaugeName].y) + end) + + it("Should keep the current fill when it moves", function() + setGauge(gaugeName, 1, 4) + moveGauge(gaugeName, 5, 6) + assert.are.same({x = 5, y = 6, width = 75, height = 20}, geometry(gaugeName .. "_front")) + end) + + it("Should error for an unknown gauge or non numeric coordinates", function() + assert.has_error(function() moveGauge("noSuchGauge", 1, 1) end) + assert.has_error(function() moveGauge(gaugeName, "1", 1) end) + assert.has_error(function() moveGauge(gaugeName, 1, "1") end) + end) + end) + + describe("Tests the functionality of resizeGauge", function() + it("Should resize every label of the gauge and remember the new size", function() + resizeGauge(gaugeName, 120, 40) + assert.are.same({x = 30, y = 300, width = 120, height = 40}, geometry(gaugeName .. "_back")) + assert.are.same({x = 30, y = 300, width = 120, height = 40}, geometry(gaugeName .. "_text")) + assert.are.same({x = 30, y = 300, width = 120, height = 40}, geometry(gaugeName .. "_front")) + assert.equals(120, gaugesTable[gaugeName].width) + assert.equals(40, gaugesTable[gaugeName].height) + end) + + it("Should rescale the fill to the new width", function() + setGauge(gaugeName, 1, 2) + resizeGauge(gaugeName, 200, 20) + assert.equals(100, select(3, getWindowGeometry(gaugeName .. "_front"))) + end) + + it("Should error for an unknown gauge or non numeric sizes", function() + assert.has_error(function() resizeGauge("noSuchGauge", 1, 1) end) + assert.has_error(function() resizeGauge(gaugeName, "1", 1) end) + assert.has_error(function() resizeGauge(gaugeName, 1, "1") end) + end) + end) + + describe("Tests the functionality of hideGauge and showGauge", function() + it("Should hide and show all three labels", function() + hideGauge(gaugeName) + assert.is_false(windowVisible(gaugeName .. "_back")) + assert.is_false(windowVisible(gaugeName .. "_front")) + assert.is_false(windowVisible(gaugeName .. "_text")) + showGauge(gaugeName) + assert.is_true(windowVisible(gaugeName .. "_back")) + assert.is_true(windowVisible(gaugeName .. "_front")) + assert.is_true(windowVisible(gaugeName .. "_text")) + end) + + it("Should error for an unknown gauge", function() + assert.has_error(function() hideGauge("noSuchGauge") end) + assert.has_error(function() showGauge("noSuchGauge") end) + end) + end) + + describe("Tests the functionality of setGaugeText", function() + it("Should wrap the text in a font tag coloured black by default", function() + setGaugeText(gaugeName, "HP: 100%") + assert.equals([[<font color="#000000">HP: 100%</font>]], gaugesTable[gaugeName].text) + assert.is_truthy(getLabelText(gaugeName .. "_text"):find("HP: 100%", 1, true)) + end) + + it("Should accept a colour name", function() + setGaugeText(gaugeName, "hurt", "red") + assert.equals([[<font color="#FF0000">hurt</font>]], gaugesTable[gaugeName].text) + end) + + it("Should accept an r, g, b triple", function() + setGaugeText(gaugeName, "hurt", 0, 128, 255) + assert.equals([[<font color="#0080FF">hurt</font>]], gaugesTable[gaugeName].text) + end) + + it("Should emit a six digit colour for components below sixteen", function() + setGaugeText(gaugeName, "dim", 10, 11, 12) + assert.equals([[<font color="#0A0B0C">dim</font>]], gaugesTable[gaugeName].text) + end) + + it("Should clear the caption when no text is given", function() + setGaugeText(gaugeName, "something") + setGaugeText(gaugeName) + assert.equals([[<font color="#000000"></font>]], gaugesTable[gaugeName].text) + end) + + it("Should error for an unknown gauge", function() + assert.has_error(function() setGaugeText("noSuchGauge", "x") end) + end) + end) + + describe("Tests the functionality of setGaugeStyleSheet", function() + it("Should apply the stylesheet to the front label and default the others", function() + setGaugeStyleSheet(gaugeName, "background-color: blue;") + assert.equals("background-color: blue;", getLabelStyleSheet(gaugeName .. "_front")) + assert.equals("background-color: blue;", getLabelStyleSheet(gaugeName .. "_back")) + assert.equals("", getLabelStyleSheet(gaugeName .. "_text")) + end) + + it("Should use the separate back and text stylesheets when given", function() + setGaugeStyleSheet(gaugeName, "border: 1px;", "background-color: grey;", "color: white;") + assert.equals("border: 1px;", getLabelStyleSheet(gaugeName .. "_front")) + assert.equals("background-color: grey;", getLabelStyleSheet(gaugeName .. "_back")) + assert.equals("color: white;", getLabelStyleSheet(gaugeName .. "_text")) + end) + + it("Should error for an unknown gauge or a non string stylesheet", function() + assert.has_error(function() setGaugeStyleSheet("noSuchGauge", "a") end) + assert.has_error(function() setGaugeStyleSheet(gaugeName, 5) end) + end) + end) + + describe("Tests the functionality of the gauge tooltip and clickthrough helpers", function() + -- neither a label tooltip nor the clickthrough flag has a getter, so the + -- observable part is which of the gauge's three labels each helper + -- reaches; spy.on keeps the real function underneath + it("Should put the tooltip on the text label and clear it again", function() + local toolTip = spy.on(_G, "setLabelToolTip") + finally(function() toolTip:revert() end) + setGaugeToolTip(gaugeName, "some hint", 3) + assert.spy(toolTip).was.called_with(gaugeName .. "_text", "some hint", 3) + resetGaugeToolTip(gaugeName) + assert.spy(toolTip).was.called_with(gaugeName .. "_text", "") + end) + + it("Should enable and disable clickthrough on all three labels", function() + local enable = spy.on(_G, "enableClickthrough") + finally(function() enable:revert() end) + enableGaugeClickthrough(gaugeName) + assert.spy(enable).was.called(3) + for _, suffixName in ipairs({"_back", "_front", "_text"}) do + assert.spy(enable).was.called_with(gaugeName .. suffixName) + end + + local disable = spy.on(_G, "disableClickthrough") + finally(function() disable:revert() end) + disableGaugeClickthrough(gaugeName) + assert.spy(disable).was.called(3) + for _, suffixName in ipairs({"_back", "_front", "_text"}) do + assert.spy(disable).was.called_with(gaugeName .. suffixName) + end + end) + + it("Should error for an unknown gauge", function() + assert.has_error(function() setGaugeToolTip("noSuchGauge", "hint") end) + assert.has_error(function() resetGaugeToolTip("noSuchGauge") end) + assert.has_error(function() enableGaugeClickthrough("noSuchGauge") end) + assert.has_error(function() disableGaugeClickthrough("noSuchGauge") end) + end) + end) + + describe("Tests the functionality of setGaugeWindow", function() + local userWindow = "guiUtilsGaugeUserWindow" + + setup(function() + openUserWindow(userWindow) + end) + + teardown(function() + closeUserWindow(userWindow) + end) + + it("Should reparent every label of the gauge and record the new position", function() + -- getWindowGeometry is parent relative, so it cannot tell a reparent + -- from a plain move; setWindow is where the reparenting happens + local setWindowSpy = spy.on(_G, "setWindow") + finally(function() setWindowSpy:revert() end) + setGaugeWindow(userWindow, gaugeName, 7, 8) + assert.spy(setWindowSpy).was.called(3) + for _, suffixName in ipairs({"_back", "_front", "_text"}) do + assert.spy(setWindowSpy).was.called_with(userWindow, gaugeName .. suffixName, 7, 8, true) + end + assert.equals(7, gaugesTable[gaugeName].x) + assert.equals(8, gaugesTable[gaugeName].y) + assert.are.same({x = 7, y = 8, width = 300, height = 20}, geometry(gaugeName .. "_back")) + assert.are.same({x = 7, y = 8, width = 300, height = 20}, geometry(gaugeName .. "_front")) + end) + + it("Should error for an unknown gauge", function() + assert.has_error(function() setGaugeWindow(userWindow, "noSuchGauge") end) + end) + + it("Should keep the gauge hidden when show is passed as false", function() + setGaugeWindow(userWindow, gaugeName, 0, 0, false) + assert.is_false(windowVisible(gaugeName .. "_back")) + assert.is_false(windowVisible(gaugeName .. "_front")) + assert.is_false(windowVisible(gaugeName .. "_text")) + end) + + it("Should still show the gauge when show is left out", function() + hideGauge(gaugeName) + setGaugeWindow(userWindow, gaugeName, 0, 0) + assert.is_true(windowVisible(gaugeName .. "_back")) + end) + end) + end) + + describe("Tests the functionality of createConsole", function() + local consoleName = "guiUtilsTestConsole" + + after_each(function() + deleteMiniConsole(consoleName) + end) + + it("Should create a miniconsole wrapped to the requested number of characters", function() + createConsole("main", consoleName, 8, 40, 10, 200, 400) + assert.equals("miniconsole", windowType(consoleName)) + assert.equals(40, getWindowWrap(consoleName)) + assert.equals(8, getFontSize(consoleName)) + end) + + it("Should size the console from the font metrics and place it where asked", function() + createConsole("main", consoleName, 8, 40, 10, 200, 400) + local charWidth, charHeight = calcFontSize(8) + local x, y, width, height = getWindowGeometry(consoleName) + assert.are.same({200, 400}, {x, y}) + assert.are.same({charWidth * 40, charHeight * 10}, {width, height}) + end) + + it("Should start out with a white foreground on a transparent background", function() + createConsole("main", consoleName, 8, 40, 10, 0, 0) + echo(consoleName, "default colours\n") + selectString(consoleName, "default colours", 1) + assert.are.same({255, 255, 255}, {getFgColor(consoleName)}) + end) + + it("Should default the window name to main when it is left out", function() + createConsole(consoleName, 8, 40, 10, 5, 6) + assert.equals("miniconsole", windowType(consoleName)) + local x, y = getWindowGeometry(consoleName) + assert.are.same({5, 6}, {x, y}) + end) + + it("Should error when a size argument is not a number", function() + assert.has_error(function() createConsole("main", consoleName, "8", 40, 10, 0, 0) end) + assert.has_error(function() createConsole("main", consoleName, 8, 40, 10, 0, "0") end) + end) + end) + + describe("Tests the functionality of bg and fg", function() + local windowName = "guiUtilsColourBuffer" + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + createBuffer(windowName) + clearWindow(windowName) + moveCursor(windowName, 0, 0) + resetFormat(windowName) + end) + + -- getBgColor/getFgColor report the colour of the character the selection + -- starts on, so the colour has to be laid down on real text to read it back + it("Should set the background colour of a named window from a colour name", function() + bg(windowName, "blue") + echo(windowName, "coloured\n") + selectString(windowName, "coloured", 1) + assert.are.same(color_table["blue"], {getBgColor(windowName)}) + end) + + it("Should set the foreground colour of a named window from a colour name", function() + fg(windowName, "red") + echo(windowName, "coloured\n") + selectString(windowName, "coloured", 1) + assert.are.same(color_table["red"], {getFgColor(windowName)}) + end) + + it("Should colour the main console when given only a colour name", function() + finally(function() resetFormat() end) + clearWindow() + bg("green") + fg("yellow") + echo("mainColouredSample\n") + selectString("mainColouredSample", 1) + assert.are.same(color_table["green"], {getBgColor("main")}) + assert.are.same(color_table["yellow"], {getFgColor("main")}) + end) + + it("Should error for a colour that does not exist", function() + assert.error_matches(function() bg("notAColour") end, "doesn't exist") + assert.error_matches(function() fg("notAColour") end, "doesn't exist") + end) + + it("Should error when given nothing at all", function() + assert.has_error(function() bg() end) + assert.has_error(function() fg() end) + end) + end) + + describe("Tests the functionality of gagLine", function() + it("Should delete the line the cursor is on", function() + -- gagLine is deprecated and forwards to deleteLine with no arguments, so + -- it always acts on the main console: prove the forwarding, then that a + -- gagged line really leaves the buffer + local deleteLineSpy = spy.on(_G, "deleteLine") + finally(function() deleteLineSpy:revert() end) + clearWindow() + echo("keep me\ngag me\n") + moveCursor(0, 1) + gagLine() + assert.spy(deleteLineSpy).was.called(1) + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_falsy(text:find("gag me", 1, true)) + assert.is_truthy(text:find("keep me", 1, true)) + end) + end) + + describe("Tests the functionality of replaceLine", function() + local windowName = "guiUtilsReplaceLineBuffer" + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + createBuffer(windowName) + clearWindow(windowName) + moveCursor(windowName, 0, 0) + end) + + it("Should replace the whole current line of a named window", function() + echo(windowName, "the original line\n") + moveCursor(windowName, 0, 0) + replaceLine(windowName, "a brand new line") + moveCursor(windowName, 0, 0) + selectCurrentLine(windowName) + assert.equals("a brand new line", getSelection(windowName)) + end) + + it("Should replace the current line of the main console when given only text", function() + clearWindow() + echo("the original main line\n") + moveCursor(0, 0) + replaceLine("a brand new main line") + moveCursor(0, 0) + selectCurrentLine() + assert.equals("a brand new main line", getSelection()) + end) + + it("Should error when the window name is not a string", function() + assert.has_error(function() replaceLine(5, "x") end) + end) + end) + + describe("Tests the functionality of handleWindowResizeEvent", function() + it("Should exist as a do nothing default users can override", function() + assert.equals("function", type(handleWindowResizeEvent)) + assert.are.same({}, {handleWindowResizeEvent()}) + end) + end) + + describe("Tests the functionality of replaceWildcard", function() + local fired + + before_each(function() + fired = nil + end) + + it("Should replace the text a capture group matched", function() + local id = tempRegexTrigger("^You wave (goodbye)\\.$", function() + replaceWildcard(2, "hello") + selectCurrentLine() + fired = getSelection() + end) + feedTriggers("You wave goodbye.\n") + killTrigger(id) + assert.equals("You wave hello.", fired) + end) + + it("Should do nothing when either argument is missing", function() + local id = tempRegexTrigger("^You nod (once)\\.$", function() + replaceWildcard(2) + replaceWildcard(nil, "hello") + selectCurrentLine() + fired = getSelection() + end) + feedTriggers("You nod once.\n") + killTrigger(id) + assert.equals("You nod once.", fired) + end) + end) + + describe("Tests the functionality of showColors", function() + -- showColors writes a clickable swatch per colour to the main console; the + -- text of those swatches is what can be read back + local function mainConsoleText() + return getLines("main", 0, getLastLineNumber("main") + 1) + end + + before_each(function() + clearWindow() + end) + + it("Should list only the colours matching the search string", function() + showColors(1, "cornflower") + local text = table.concat(mainConsoleText(), "\n") + assert.is_truthy(text:find("cornflower_blue", 1, true)) + assert.is_truthy(text:find("CornflowerBlue", 1, true)) + assert.is_falsy(text:find("firebrick", 1, true)) + end) + + it("Should never list the ansi_### colours", function() + showColors(1, "ansi_128") + local text = table.concat(mainConsoleText(), "\n") + assert.is_falsy(text:find("ansi_128", 1, true)) + end) + + it("Should honour the requested number of columns", function() + local function lineHolding(needle) + for index, line in ipairs(mainConsoleText()) do + if line:find(needle, 1, true) then + return index + end + end + end + + showColors(2, "cornflower") + local shared = lineHolding("cornflower_blue") + assert.is_truthy(shared, "showColors should have listed the matching colours") + assert.are.equal(shared, lineHolding("CornflowerBlue"), "two colours should share a line when asked for 2 columns") + + clearWindow() + showColors(1, "cornflower") + local first = lineHolding("cornflower_blue") + assert.is_truthy(first) + assert.are_not.equal(first, lineHolding("CornflowerBlue"), "one column per line means one colour per line") + end) + end) + + describe("Tests the functionality of showAnsiColors", function() + it("Should list the ansi_### colours and nothing else", function() + clearWindow() + showAnsiColors(1) + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_truthy(text:find("ansi_000", 1, true)) + assert.is_truthy(text:find("ansi_255", 1, true)) + assert.is_falsy(text:find("cornflower_blue", 1, true)) + end) + end) + + describe("Tests the functionality of hinsertText and dinsertText", function() + local windowName = "guiUtilsInsertConsole" + + setup(function() + createMiniConsole(windowName, 0, 0, 400, 200) + setWindowWrap(windowName, 60) + end) + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + clearWindow(windowName) + end) + + local function firstLine() + moveCursor(windowName, 0, 0) + selectCurrentLine(windowName) + return getSelection(windowName) + end + + it("Should insert hecho formatted text at the cursor", function() + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + hinsertText(windowName, "#ff0000X") + assert.equals("AXB", firstLine()) + end) + + it("Should insert decho formatted text at the cursor", function() + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + dinsertText(windowName, "<255,0,0>X") + assert.equals("AXB", firstLine()) + end) + + it("Should apply the colour it was given to the inserted text", function() + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + dinsertText(windowName, "<0,255,0>X") + selectSection(windowName, 1, 1) + assert.are.same({0, 255, 0}, getTextFormat(windowName).foreground) + end) + end) + + describe("Tests the functionality of the coloured link and popup echoes", function() + local windowName = "guiUtilsLinkConsole" + + setup(function() + createMiniConsole(windowName, 0, 0, 400, 200) + setWindowWrap(windowName, 60) + end) + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + clearWindow(windowName) + moveCursor(windowName, 0, 0) + end) + + local function currentLine() + selectCurrentLine(windowName) + return getSelection(windowName) + end + + local function firstLine() + moveCursor(windowName, 0, 0) + selectCurrentLine(windowName) + return getSelection(windowName) + end + + -- there is no getter for a link's command or hint, so these cover the text + -- and colour each variant lays down plus the fact that the call succeeds + it("Should echo links with each of the three colour syntaxes", function() + cechoLink(windowName, "<red>click me", "send('x')", "a hint", true) + assert.equals("click me", currentLine()) + selectSection(windowName, 0, 5) + assert.are.same(color_table["red"], getTextFormat(windowName).foreground) + + clearWindow(windowName) + dechoLink(windowName, "<0,255,0>green link", "send('x')", "a hint", true) + assert.equals("green link", currentLine()) + + clearWindow(windowName) + hechoLink(windowName, "#0000ffblue link", "send('x')", "a hint", true) + assert.equals("blue link", currentLine()) + end) + + it("Should insert links with each of the three colour syntaxes", function() + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + cinsertLink(windowName, "<red>C", "send('x')", "a hint", true) + assert.equals("ACB", firstLine()) + + clearWindow(windowName) + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + dinsertLink(windowName, "<0,255,0>D", "send('x')", "a hint", true) + assert.equals("ADB", firstLine()) + + clearWindow(windowName) + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + hinsertLink(windowName, "#0000ffE", "send('x')", "a hint", true) + assert.equals("AEB", firstLine()) + end) + + it("Should echo popups with each of the three colour syntaxes", function() + local commands = {"send('one')", "send('two')"} + local hints = {"first", "second"} + cechoPopup(windowName, "<red>menu", commands, hints, true) + assert.equals("menu", currentLine()) + + clearWindow(windowName) + dechoPopup(windowName, "<0,255,0>dmenu", commands, hints, true) + assert.equals("dmenu", currentLine()) + + clearWindow(windowName) + hechoPopup(windowName, "#0000ffhmenu", commands, hints, true) + assert.equals("hmenu", currentLine()) + end) + + it("Should insert popups with each of the three colour syntaxes", function() + local commands = {"send('one')", "send('two')"} + local hints = {"first", "second"} + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + cinsertPopup(windowName, "<red>C", commands, hints, true) + assert.equals("ACB", firstLine()) + + clearWindow(windowName) + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + dinsertPopup(windowName, "<0,255,0>D", commands, hints, true) + assert.equals("ADB", firstLine()) + + clearWindow(windowName) + echo(windowName, "AB\n") + moveCursor(windowName, 1, 0) + hinsertPopup(windowName, "#0000ffE", commands, hints, true) + assert.equals("AEB", firstLine()) + end) + end) + + describe("Tests the functionality of cfeedTriggers, dfeedTriggers and hfeedTriggers", function() + local seen + + before_each(function() + seen = {} + end) + + -- each variant has to strip its own colour syntax before feeding, so the + -- line the trigger sees must be the bare marker and must carry the colour + local function feedAndInspect(feeder, text, marker) + local result = {} + local id = tempTrigger(marker, function() + selectCurrentLine() + result.line = getSelection() + selectString(marker, 1) + result.foreground = getTextFormat().foreground + seen[#seen + 1] = marker + end) + feeder(text) + killTrigger(id) + return result + end + + it("Should feed cecho coloured text through the trigger engine", function() + local result = feedAndInspect(cfeedTriggers, "<red>cfeedMarker", "cfeedMarker") + assert.equals(1, #seen) + assert.equals("cfeedMarker", result.line) + -- the text goes out as ANSI, so a cecho colour name arrives as its ANSI + -- equivalent: "red" is ANSI 1, not the brighter color_table["red"] + assert.are.same(color_table["ansi_001"], result.foreground) + end) + + it("Should feed decho coloured text through the trigger engine", function() + local result = feedAndInspect(dfeedTriggers, "<0,255,0>dfeedMarker", "dfeedMarker") + assert.equals(1, #seen) + assert.equals("dfeedMarker", result.line) + assert.are.same({0, 255, 0}, result.foreground) + end) + + it("Should feed hecho coloured text through the trigger engine", function() + local result = feedAndInspect(hfeedTriggers, "#0000ffhfeedMarker", "hfeedMarker") + assert.equals(1, #seen) + assert.equals("hfeedMarker", result.line) + assert.are.same({0, 0, 255}, result.foreground) + end) + + it("Should error when not given a string", function() + assert.has_error(function() cfeedTriggers(5) end) + assert.has_error(function() dfeedTriggers(5) end) + assert.has_error(function() hfeedTriggers(5) end) + end) + end) + + describe("Tests the functionality of prefix and suffix", function() + local windowName = "guiUtilsAffixBuffer" + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + createBuffer(windowName) + clearWindow(windowName) + moveCursor(windowName, 0, 0) + echo(windowName, "middle") + moveCursor(windowName, 0, 0) + end) + + local function currentLine() + selectCurrentLine(windowName) + return getSelection(windowName) + end + + it("Should put text at the start of the line", function() + prefix("[", nil, nil, nil, windowName) + assert.equals("[middle", currentLine()) + end) + + it("Should put text at the end of the line", function() + suffix("]", nil, nil, nil, windowName) + assert.equals("middle]", currentLine()) + end) + + it("Should colour what it adds", function() + prefix("[", nil, "red", nil, windowName) + assert.equals("[middle", currentLine()) + selectSection(windowName, 0, 1) + assert.are.same(color_table["red"], getTextFormat(windowName).foreground) + end) + + it("Should accept a colour aware echo function to add the text with", function() + prefix("<red>[", cecho, nil, nil, windowName) + assert.equals("[middle", currentLine()) + selectSection(windowName, 0, 1) + assert.are.same(color_table["red"], getTextFormat(windowName).foreground) + end) + + it("Should error when the text is not a string", function() + assert.has_error(function() prefix(5) end) + assert.has_error(function() suffix(5) end) + end) + + -- A line that has been finished off with a newline is the ordinary trigger + -- case, and the only one where landing a column short is visible: on the + -- unfinished line the block above uses, an insert past the last character + -- is appended either way. + local function completedLine() + clearWindow(windowName) + moveCursor(windowName, 0, 0) + echo(windowName, "PROBE has a TARGET word\n") + moveCursor(windowName, 0, 0) + end + + it("Should put text after the last character of a completed line", function() + completedLine() + suffix(" SUF", nil, nil, nil, windowName) + assert.equals("PROBE has a TARGET word SUF", currentLine()) + end) + + it("Should put text after the last character of a completed line when colouring it", function() + completedLine() + suffix(" SUF", nil, "red", nil, windowName) + assert.equals("PROBE has a TARGET word SUF", currentLine()) + end) + + it("Should not recolour the current selection when prefixing", function() + selectSection(windowName, 0, 6) + prefix("[", nil, "red", nil, windowName) + -- "middle" now starts one column along, and must have kept its colour + selectSection(windowName, 1, 6) + assert.are_not.same(color_table["red"], getTextFormat(windowName).foreground) + end) + + it("Should not recolour the current selection when suffixing", function() + selectSection(windowName, 0, 6) + suffix("]", nil, "red", nil, windowName) + selectSection(windowName, 0, 6) + assert.are_not.same(color_table["red"], getTextFormat(windowName).foreground) + end) + + it("Should not repaint the background of the current selection either", function() + selectSection(windowName, 0, 6) + prefix("[", nil, nil, "blue", windowName) + selectSection(windowName, 1, 6) + assert.are_not.same(color_table["blue"], getTextFormat(windowName).background) + end) + + it("Should suffix onto an empty line", function() + clearWindow(windowName) + moveCursor(windowName, 0, 0) + echo(windowName, "\n") + moveCursor(windowName, 0, 0) + suffix("added", nil, nil, nil, windowName) + assert.equals("added", currentLine()) + end) + + it("Should still colour what it adds when something is selected", function() + selectSection(windowName, 0, 6) + prefix("[", nil, "red", nil, windowName) + selectSection(windowName, 0, 1) + assert.are.same(color_table["red"], getTextFormat(windowName).foreground) + end) + end) + + describe("Tests the functionality of moveCursorDown", function() + local windowName = "guiUtilsCursorBuffer" + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + createBuffer(windowName) + clearWindow(windowName) + echo(windowName, "one\ntwo\nthree\nfour\n") + moveCursor(windowName, 0, 0) + end) + + it("Should move the cursor down one line by default", function() + moveCursorDown(windowName) + assert.equals(1, getLineNumber(windowName)) + end) + + it("Should move the cursor down the number of lines given", function() + moveCursorDown(windowName, 2) + assert.equals(2, getLineNumber(windowName)) + end) + + it("Should stop at the last line of the buffer", function() + moveCursorDown(windowName, 500) + assert.equals(getLastLineNumber(windowName), getLineNumber(windowName)) + end) + + it("Should reset the column unless asked to keep it", function() + moveCursor(windowName, 2, 0) + moveCursorDown(windowName, 1) + assert.equals(0, getColumnNumber(windowName)) + moveCursor(windowName, 2, 0) + moveCursorDown(windowName, 1, true) + assert.equals(2, getColumnNumber(windowName)) + end) + + it("Should report an unknown window rather than raising", function() + local ok, err = moveCursorDown("guiUtilsNoSuchWindow", 1) + assert.is_nil(ok) + assert.equals("window does not exist", err) + end) + + it("Should treat a non-boolean keep_horizontal as false", function() + moveCursor(windowName, 2, 0) + moveCursorDown(windowName, 1, "yes") + assert.equals(0, getColumnNumber(windowName)) + moveCursor(windowName, 2, 1) + moveCursorUp(windowName, 1, "yes") + assert.equals(0, getColumnNumber(windowName)) + end) + + -- pairs with the assertion above: without this, "coerced to false" and + -- "keep_horizontal ignored entirely" would look the same for moveCursorUp + it("Should let moveCursorUp keep the column when asked with a boolean", function() + moveCursor(windowName, 2, 1) + moveCursorUp(windowName, 1, true) + assert.equals(2, getColumnNumber(windowName)) + end) + end) + + describe("Tests the functionality of creplace, dreplace and hreplace", function() + local windowName = "guiUtilsColourReplaceBuffer" + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + createBuffer(windowName) + clearWindow(windowName) + moveCursor(windowName, 0, 0) + echo(windowName, "hello world") + moveCursor(windowName, 0, 0) + end) + + local function currentLine() + selectCurrentLine(windowName) + return getSelection(windowName) + end + + it("Should replace the selection with cecho formatted text", function() + selectString(windowName, "world", 1) + creplace(windowName, "<red>earth") + assert.equals("hello earth", currentLine()) + end) + + it("Should replace the selection with decho formatted text", function() + selectString(windowName, "world", 1) + dreplace(windowName, "<0,255,0>earth") + assert.equals("hello earth", currentLine()) + end) + + it("Should replace the selection with hecho formatted text", function() + selectString(windowName, "world", 1) + hreplace(windowName, "#0000ffearth") + assert.equals("hello earth", currentLine()) + end) + + it("Should colour what it puts down", function() + selectString(windowName, "world", 1) + dreplace(windowName, "<0,255,0>earth") + selectString(windowName, "earth", 1) + assert.are.same({0, 255, 0}, getTextFormat(windowName).foreground) + end) + + it("Should replace a whole line with dreplaceLine and hreplaceLine", function() + dreplaceLine(windowName, "<0,255,0>brand new") + assert.equals("brand new", currentLine()) + hreplaceLine(windowName, "#0000ffnewer still") + assert.equals("newer still", currentLine()) + end) + + it("Should error when the window name is not a string", function() + assert.has_error(function() creplace(5, "x") end) + assert.has_error(function() dreplace(5, "x") end) + assert.has_error(function() hreplace(5, "x") end) + assert.has_error(function() dreplaceLine(5, "x") end) + assert.has_error(function() hreplaceLine(5, "x") end) + end) + end) + + describe("Tests the functionality of scrollUp and scrollDown", function() + local windowName = "guiUtilsScrollConsole" + + -- The scroll position getScroll reports is copied out of the buffer while + -- the pane repaints, and the very first scroll of a console is deferred to + -- the next event loop turn so its split screen lower pane can appear. Both + -- need one turn of the event loop before the new position can be read. + local function pumpEventLoop() + tempTimer(0, function() raiseEvent("guiUtilsScrollPump") end) + waitForEvent("guiUtilsScrollPump", 2000) + end + + -- the repaint that publishes the new position is only posted, so poll for + -- it rather than trusting a single turn of the event loop + local function scrollSettlesAt(expected) + for _ = 1, 20 do + if getScroll(windowName) == expected then + return getScroll(windowName) + end + pumpEventLoop() + end + return getScroll(windowName) + end + + -- BUG: scrollTo does not move to the line it is given, it subtracts a + -- delta from the cursor the pane last copied out of the buffer while + -- painting, and the first scroll out of tail mode is deferred a turn and + -- padded by the lower pane's row count. So the first scrollTo of a console + -- lands short by a font-metric-dependent amount, and a second one issued + -- before the pane has repainted lands short again. Re-issue it until it + -- sticks: once the pane's copy has caught up the delta is exact. + local function parkAt(line) + for _ = 1, 10 do + scrollTo(windowName, line) + if scrollSettlesAt(line) == line then + return line + end + end + return getScroll(windowName) + end + + setup(function() + createMiniConsole(windowName, 0, 0, 200, 100) + enableScrolling(windowName) + end) + + teardown(function() + deleteMiniConsole(windowName) + end) + + before_each(function() + clearWindow(windowName) + for i = 1, 200 do + echo(windowName, "scroll line " .. i .. "\n") + end + assert.equals(150, parkAt(150), "the console should be parked mid buffer before each scroll test") + end) + + it("Should move the view up by the number of lines given", function() + scrollUp(windowName, 5) + assert.equals(145, scrollSettlesAt(145)) + end) + + it("Should move the view back down again", function() + scrollDown(windowName, 4) + assert.equals(154, scrollSettlesAt(154)) + end) + + it("Should never scroll above the first line", function() + scrollUp(windowName, 10000) + assert.equals(0, scrollSettlesAt(0)) + end) + + it("Should never scroll past the last line", function() + scrollDown(windowName, 10000) + local lastLine = getLastLineNumber(windowName) + assert.equals(lastLine, scrollSettlesAt(lastLine)) + end) + + it("Should default to a single line when no count is given", function() + scrollUp(windowName) + assert.equals(149, scrollSettlesAt(149)) + end) + + it("Should report an unknown window rather than raising", function() + local ok, err = scrollUp("guiUtilsNoSuchWindow", 1) + assert.is_nil(ok) + assert.equals("window does not exist", err) + ok, err = scrollDown("guiUtilsNoSuchWindow", 1) + assert.is_nil(ok) + assert.equals("window does not exist", err) + end) + end) + + describe("Tests the functionality of setLabelCursor and resetLabelCursor", function() + local labelName = "guiUtilsCursorLabel" + + before_each(function() + createLabel(labelName, 0, 0, 50, 50, 1) + hideWindow(labelName) + end) + + after_each(function() + pcall(deleteLabel, labelName) + end) + + it("Should map a cursor name to the id the C++ layer wants", function() + -- the name to id mapping is the Lua half of this function; the C++ half + -- only accepts a number, so a name that is not in mudlet.cursor has to + -- reach it as nil and be refused + assert.is_true(setLabelCursor(labelName, "OpenHand")) + assert.is_true(setLabelCursor(labelName, mudlet.cursor.OpenHand)) + assert.is_nil(mudlet.cursor.definitelyNotACursor) + assert.has_error(function() setLabelCursor(labelName, "definitelyNotACursor") end) + end) + + it("Should reset the cursor by asking for shape -1", function() + setLabelCursor(labelName, "OpenHand") + assert.is_true(resetLabelCursor(labelName)) + end) + + it("Should report an unknown label", function() + local ok, err = setLabelCursor("guiUtilsNoSuchLabel", "OpenHand") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("Should error when resetLabelCursor is not given a string", function() + assert.has_error(function() resetLabelCursor(5) end) + end) + end) + + describe("Tests the functionality of setBackgroundImage", function() + local consoleName = "guiUtilsBackgroundConsole" + -- a Qt resource that ships with every Mudlet, so no fixture file is needed + local imagePath = ":/icons/mudlet.png" + + before_each(function() + createMiniConsole(consoleName, 0, 0, 100, 100) + end) + + after_each(function() + deleteMiniConsole(consoleName) + end) + + it("Should accept each mode name a console supports", function() + for _, name in ipairs({"border", "center", "tile", "style"}) do + assert.is_true(setBackgroundImage(consoleName, imagePath, name), "mode " .. name .. " should be accepted") + end + end) + + it("Should accept the numeric mode the names map onto", function() + assert.is_true(setBackgroundImage(consoleName, imagePath, mudlet.BgImageMode.center)) + assert.equals(2, mudlet.BgImageMode.center) + end) + + it("Should map the cover mode name, which only the full window accepts", function() + assert.equals(5, mudlet.BgImageMode.cover) + assert.is_true(setBackgroundImage("main", imagePath, "cover", true)) + -- the same name on a console reaches the C++ check for mode 5 + local ok, err = setBackgroundImage(consoleName, imagePath, "cover") + assert.is_nil(ok) + assert.is_truthy(err:find("cover", 1, true)) + resetBackgroundImage("main") + end) + + it("Should pass an unknown mode name through so the C++ side rejects it", function() + assert.has_error(function() setBackgroundImage(consoleName, imagePath, "notAMode") end) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserAdjustableContainer_spec.lua b/src/mudlet-lua/tests/GeyserAdjustableContainer_spec.lua index 8ef886ce9..b66bfa08f 100644 --- a/src/mudlet-lua/tests/GeyserAdjustableContainer_spec.lua +++ b/src/mudlet-lua/tests/GeyserAdjustableContainer_spec.lua @@ -16,10 +16,12 @@ describe("Tests functionality of Adjustable.Container", function() end) after_each(function() - -- Clean up the container after each test - if testContainer then - testContainer:hide() + -- deleting rather than hiding keeps the container, its right click menu + -- and the submenu addConnectMenu builds out of every later spec file + if testContainer and Geyser.windowList.testAdjustableContainer == testContainer then + testContainer:delete() end + testContainer = nil end) it("should successfully add connect menu on first call", function() @@ -107,7 +109,649 @@ describe("Tests functionality of Adjustable.Container", function() assert.equals(10, ac.padding) assert.equals("standard", ac.lockStyle) - ac:hide() + ac:delete() + end) + end) + + -- Geometry, visibility and title readback, asserted on the widgets the + -- container builds rather than on its bookkeeping alone. + describe("Adjustable.Container widget state", function() + local container + local topLevelBefore + + local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} + end + + -- Every top level Geyser object registered since this spec's container was + -- built. Snapshotting rather than matching on the container's name catches + -- leaks whatever they are called, such as the menu's "More..." labels. + local function newTopLevelObjects() + local new = {} + for name in pairs(Geyser.windowList) do + if not topLevelBefore[name] then + new[#new + 1] = name + end + end + table.sort(new) + return new + end + + before_each(function() + topLevelBefore = {} + for name in pairs(Geyser.windowList) do + topLevelBefore[name] = true + end + container = Adjustable.Container:new({ + name = "gasContainer", + x = 20, + y = 30, + width = 200, + height = 200, + autoLoad = false, + autoSave = false, + }) + end) + + after_each(function() + -- a delete that throws must not skip the sweep below, or it strands the + -- container and its menu labels for the rest of the suite + local deleted, deleteError = true, nil + if container and Geyser.windowList.gasContainer == container then + deleted, deleteError = pcall(function() container:delete() end) + end + container = nil + -- the whole suite shares one Lua state, so anything left registered here + -- would follow later spec files around: sweep it, but report it rather + -- than quietly repairing a delete that stopped cleaning up after itself + local leftovers = newTopLevelObjects() + for _, name in ipairs(leftovers) do + local object = Geyser.windowList[name] + if object then + object:delete() + end + end + Adjustable.Container.all.gasContainer = nil + local index = table.index_of(Adjustable.Container.all_windows, "gasContainer") + if index then + table.remove(Adjustable.Container.all_windows, index) + end + -- the delete throwing is the root cause, so report it ahead of the leak + -- it would have caused + if not deleted then + error(deleteError) + end + assert.are.same({}, leftovers) + end) + + it("puts its backdrop label over the container's geometry", function() + assert.are.equal("label", windowType("gasContaineradjLabel")) + assert.are.same({x = 20, y = 30, width = 200, height = 200}, geometry("gasContaineradjLabel")) + assert.is_true(windowVisible("gasContaineradjLabel")) + end) + + it("drags its labels along when the container moves and resizes", function() + container:move(60, 70) + container:resize(100, 120) + assert.are.same({x = 60, y = 70, width = 100, height = 120}, geometry("gasContaineradjLabel")) + end) + + it("puts a child inside the padding and below the title bar", function() + Geyser.Label:new({name = "gasChild", x = 0, y = 0, width = "100%", height = "100%"}, container) + -- padding on the left and right, twice that at the top to leave room for + -- the title bar + assert.are.equal(10, container.padding) + assert.are.same({x = 30, y = 50, width = 180, height = 170}, geometry("gasChild")) + end) + + it("setPadding moves and resizes the children", function() + Geyser.Label:new({name = "gasPaddedChild", x = 0, y = 0, width = "100%", height = "100%"}, container) + container:setPadding(30) + assert.are.equal(30, container.padding) + assert.are.same({x = 50, y = 90, width = 140, height = 110}, geometry("gasPaddedChild")) + end) + + it("hides and shows every widget it owns", function() + container:hide() + assert.is_false(windowVisible("gasContaineradjLabel")) + container:show() + assert.is_true(windowVisible("gasContaineradjLabel")) + end) + + it("writes the title onto the backdrop label", function() + container:setTitle("My Title", "red", "c") + local text = getLabelText("gasContaineradjLabel") + assert.is_truthy(text:find("My Title", 1, true)) + assert.is_truthy(text:find("color: #ff0000", 1, true)) + assert.is_truthy(text:find('align="center"', 1, true)) + assert.are.equal("My Title", container.titleText) + end) + + it("titles itself after its name to begin with", function() + assert.are.equal("gasContainer - Adjustable Container", container.titleText) + assert.is_truthy(getLabelText("gasContaineradjLabel"):find("gasContainer - Adjustable Container", 1, true)) + end) + + it("stops drawing the title while the container is locked", function() + container:setTitle("before lock") + assert.is_truthy(getLabelText("gasContaineradjLabel"):find("before lock", 1, true)) + -- the standard lock style clears the title bar so the container reads as + -- locked down + container:lockContainer() + assert.is_true(container.locked) + assert.is_nil(getLabelText("gasContaineradjLabel"):find("before lock", 1, true)) + container:setTitle("after lock") + assert.are.equal("after lock", container.titleText) + assert.is_nil(getLabelText("gasContaineradjLabel"):find("after lock", 1, true)) + -- unlocking redraws the title that was stored while locked + container:unlockContainer() + assert.is_false(container.locked) + assert.is_truthy(getLabelText("gasContaineradjLabel"):find("after lock", 1, true)) + end) + + it("shrinks to the title bar when minimized and grows back when restored", function() + container:minimize() + assert.is_true(container.minimized) + local minimized = geometry("gasContaineradjLabel") + assert.are.equal(200, minimized.width) + assert.is_true(minimized.height < 200) + -- buttonsize is stored as a string, hence the conversion + assert.are.equal(tonumber(container.buttonsize) + 10, minimized.height) + container:restore() + assert.is_false(container.minimized) + assert.are.same({x = 20, y = 30, width = 200, height = 200}, geometry("gasContaineradjLabel")) + end) + + it("titles itself after its name again after resetTitle", function() + container:setTitle("My Title", "red", "c") + container:resetTitle() + assert.are.equal("gasContainer - Adjustable Container", container.titleText) + -- back to what the constructor produced, colour and alignment included + assert.are.equal("grey", container.titleTxtColor) + assert.are.equal("l", container.titleFormat) + local text = getLabelText("gasContaineradjLabel") + assert.is_truthy(text:find("gasContainer - Adjustable Container", 1, true)) + assert.is_truthy(text:find("color: " .. Geyser.Color.hex("grey"), 1, true)) + end) + + it("deletes the container and its backdrop label", function() + container:delete() + assert.is_nil(getWindowGeometry("gasContaineradjLabel")) + assert.is_nil(getWindowGeometry("gasContainerexitLabel")) + assert.is_nil(Geyser.windowList.gasContainer) + end) + + it("takes its right click menu labels and its registration with it", function() + -- menu labels are registered as top level Geyser objects rather than as + -- children of the menu, so only the container's own delete reaches them + local menuLabelName = container.lockLabel.name + local lockStyleLabelName = container.adjLabel:findMenuElement("lockStylesLabel.standard").name + assert.is_not_nil(Geyser.windowList[menuLabelName]) + assert.is_not_nil(Geyser.windowList[lockStyleLabelName]) + container:delete() + -- listed by name: a leaked Geyser object prints as the whole widget tree + assert.are.same({}, newTopLevelObjects()) + assert.is_nil(getWindowGeometry(menuLabelName)) + assert.is_nil(getWindowGeometry(lockStyleLabelName)) + assert.is_nil(Adjustable.Container.all.gasContainer) + assert.is_nil(table.index_of(Adjustable.Container.all_windows, "gasContainer")) + end) + + it("takes the menu labels of a container inside a user window with it", function() + -- menu labels of a container in a user window are registered in that + -- window's list rather than in Geyser.windowList + local userWindow = Geyser.UserWindow:new({name = "gasUserWindow", x = 0, y = 0, width = 300, height = 300}) + finally(function() + -- a user window gets a root container of its own, which is what has to + -- go for the window and everything in it to be cleaned up + local root = Geyser.windowList.gasUserWindowContainer + if root then + root:delete() + end + end) + local inWindow = Adjustable.Container:new({ + name = "gasInUserWindow", + x = 0, y = 0, width = 100, height = 100, + autoLoad = false, + autoSave = false, + }, userWindow) + local menuLabelName = inWindow.lockLabel.name + assert.is_not_nil(getWindowGeometry(menuLabelName)) + inWindow:delete() + assert.is_nil(getWindowGeometry(menuLabelName)) + end) + + it("takes its autosave handler with it", function() + local saving = Adjustable.Container:new({ + name = "gasSavingContainer", + x = 0, y = 0, width = 100, height = 100, + autoLoad = false, + }) + assert.is_not_nil(saving.autoSaveHandler) + saving:delete() + -- left registered, the handler would write a deleted container's geometry + -- back out at exit, over whatever took its name in the meantime + assert.is_nil(saving.autoSaveHandler) + assert.is_false(saving.autoSave) + end) + + it("leaves another adjustable container's registration alone", function() + local other = Adjustable.Container:new({ + name = "gasOtherContainer", + x = 0, y = 0, width = 100, height = 100, + autoLoad = false, + autoSave = false, + }) + finally(function() + if Geyser.windowList.gasOtherContainer == other then + other:delete() + end + end) + local otherMenuLabelName = other.lockLabel.name + container:delete() + assert.are.equal(other, Adjustable.Container.all.gasOtherContainer) + assert.is_not_nil(table.index_of(Adjustable.Container.all_windows, "gasOtherContainer")) + assert.are.equal(other.lockLabel, Geyser.windowList[otherMenuLabelName]) + assert.is_not_nil(getWindowGeometry(otherMenuLabelName)) + other:delete() + end) + end) + + -- Adjustable.Container.Attached is keyed by container name, so two live + -- containers sharing a name land on the same key. resetBorder/adjustBorder + -- walk those entries to work out how much border to reserve, so a container + -- that is still attached but no longer registered loses its reservation and + -- the main console is drawn underneath it. + describe("Tests the functionality of Adjustable.Container:attachToBorder/detach", function() + local containers + local borderBefore + + local function make(name, width) + local container = Adjustable.Container:new({ + name = name, + x = 0, y = 0, width = width, height = 100, + autoLoad = false, + autoSave = false, + }) + containers[#containers + 1] = container + return container + end + + before_each(function() + containers = {} + borderBefore = getBorderLeft() + end) + + -- Deliberately same named containers share their children's names too, so + -- the one that still holds the registration is deleted first and takes the + -- widgets with it; the superseded one is then deleted for its own event + -- handlers and bookkeeping, which nothing else would clear. + after_each(function() + for index = #containers, 1, -1 do + local container = containers[index] + if container.attached then + container:detach() + end + container:delete() + end + containers = {} + setBorderLeft(borderBefore) + assert.is_nil(Adjustable.Container.Attached.left.gasAttachPlain) + assert.is_nil(Adjustable.Container.Attached.left.gasAttachName) + assert.is_nil(Adjustable.Container.Attached.left.gasDetachName) + end) + + it("reserves a border while attached and gives it back on detach", function() + local container = make("gasAttachPlain", 200) + container:attachToBorder("left") + assert.are.equal("left", container.attached) + assert.are.equal(container.borderSize, getBorderLeft()) + assert.are.equal(container, Adjustable.Container.Attached.left.gasAttachPlain) + container:detach() + assert.is_false(container.attached) + assert.is_nil(Adjustable.Container.Attached.left.gasAttachPlain) + assert.are.equal(0, getBorderLeft()) + end) + + it("detaches a same named container it takes the registration from", function() + local first = make("gasAttachName", 200) + local second = make("gasAttachName", 400) + first:attachToBorder("left") + assert.are.equal(first.borderSize, getBorderLeft()) + second:attachToBorder("left") + assert.are.equal(second, Adjustable.Container.Attached.left.gasAttachName) + assert.are.equal(second.borderSize, getBorderLeft()) + -- the superseded container must not be left believing it is attached + -- while nothing reserves a border for it any more + assert.is_false(first.attached) + assert.is_nil(first.borderSize) + end) + + it("leaves a same named container's reservation alone when a superseded one detaches", function() + local first = make("gasDetachName", 200) + local second = make("gasDetachName", 400) + first:attachToBorder("left") + second:attachToBorder("left") + local reserved = getBorderLeft() + first:detach() + assert.are.equal(second, Adjustable.Container.Attached.left.gasDetachName) + assert.are.equal("left", second.attached) + assert.are.equal(reserved, getBorderLeft()) + end) + end) +end) + +-- The handlers Adjustable.Container hangs off its own labels. A real mouse is +-- what normally calls them, with the event table Mudlet builds for a label +-- callback ({button = ..., x = ..., y = ..., globalX = ..., globalY = ...}), so +-- these specs hand them that table directly and read back what they did. +describe("Tests the Adjustable.Container mouse handlers", function() + local container + local containerName = "gahContainer" + + local function mouseEvent(button, x, y) + x, y = x or 5, y or 5 + return {button = button, buttons = {button}, x = x, y = y, globalX = x, globalY = y} + end + + before_each(function() + container = Adjustable.Container:new({ + name = containerName, + x = 20, y = 30, width = 200, height = 200, + autoLoad = false, + autoSave = false, + }) + -- Adjustable.Container keeps which edge is being dragged in one table + -- shared by every container, and only a completed left click empties it, + -- so start each spec from a released mouse rather than from whatever the + -- last spec left mid-drag + container:onClick(container.adjLabel, mouseEvent("LeftButton", 100, 100)) + container:onRelease(container.adjLabel, mouseEvent("LeftButton", 100, 100)) + end) + + after_each(function() + -- onEnterAtt and the right click path both open a nest, which arms a timer + -- that would fire on deleted labels seconds later + if Geyser.Label.closeAllTimer then + killTimer(Geyser.Label.closeAllTimer) + Geyser.Label.closeAllTimer = nil + end + if container then + -- and leave the drag state pointing at nothing rather than at a label + -- about to be deleted: Adjustable.Container:reposition reads it. A locked + -- container refuses the click, so unlock before making it + if container.locked then + container:unlockContainer() + end + container:onClick(container.adjLabel, mouseEvent("LeftButton", 100, 100)) + container:onRelease(container.adjLabel, mouseEvent("LeftButton", 100, 100)) + for _, label in ipairs({container.adjLabel, container.attLabel, container.rCLabel}) do + if label then + -- keyed by the label object, so an entry outlives the label + Geyser.Label.scrollV[label] = nil + Geyser.Label.scrollH[label] = nil + end + end + container:deleteSaveFile() + if Geyser.windowList[containerName] == container then + container:delete() + end + end + container = nil + Adjustable.Container.all[containerName] = nil + local index = table.index_of(Adjustable.Container.all_windows, containerName) + if index then + table.remove(Adjustable.Container.all_windows, index) + end + end) + + describe("Adjustable.Container:onClick and onRelease", function() + it("raises the reposition event once a left click is let go of", function() + local seen + local handler = registerAnonymousEventHandler("AdjustableContainerRepositionFinish", + function(_, name, width, height, x, y) seen = {name, width, height, x, y} end) + finally(function() killAnonymousEventHandler(handler) end) + + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.adjLabel, mouseEvent("LeftButton")) + + assert.is_table(seen) + assert.are.same({containerName, container:get_width(), container:get_height(), container:get_x(), container:get_y()}, seen) + end) + + it("stays quiet when the release was not of a left click", function() + local raised = false + local handler = registerAnonymousEventHandler("AdjustableContainerRepositionFinish", function() raised = true end) + finally(function() killAnonymousEventHandler(handler) end) + + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.adjLabel, mouseEvent("RightButton")) + + assert.is_false(raised) + end) + + it("stays quiet for a label that was not the one clicked", function() + local raised = false + local handler = registerAnonymousEventHandler("AdjustableContainerRepositionFinish", function() raised = true end) + finally(function() killAnonymousEventHandler(handler) end) + + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.exitLabel, mouseEvent("LeftButton")) + + assert.is_false(raised) + end) + + it("only raises the event once per click", function() + local raises = 0 + local handler = registerAnonymousEventHandler("AdjustableContainerRepositionFinish", function() raises = raises + 1 end) + finally(function() killAnonymousEventHandler(handler) end) + + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.adjLabel, mouseEvent("LeftButton")) + + assert.are.equal(1, raises) + end) + + it("takes the grabbing hand back after the drag", function() + container.adjLabel:setCursor("ClosedHand") + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.adjLabel, mouseEvent("LeftButton")) + assert.are.equal("OpenHand", container.adjLabel.cursorShape) + end) + + it("ignores a left click on a locked container that is on its own", function() + local raised = false + local handler = registerAnonymousEventHandler("AdjustableContainerRepositionFinish", function() raised = true end) + finally(function() killAnonymousEventHandler(handler) end) + + container:lockContainer() + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onRelease(container.adjLabel, mouseEvent("LeftButton")) + + -- the click never registered, so there is no drag to finish + assert.is_false(raised) + end) + end) + + describe("Adjustable.Container:onMove", function() + it("turns the container's position into a percentage of the main window", function() + local originalX, originalY = container:get_x(), container:get_y() + + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onMove(container.adjLabel, mouseEvent("LeftButton")) + + -- the mouse has not actually moved between the two, so the container + -- lands where it already was, but now expressed against the window + assert.is_truthy(tostring(container.x):find("%%$")) + assert.is_truthy(tostring(container.y):find("%%$")) + assert.is_true(math.abs(container:get_x() - originalX) <= 1) + assert.is_true(math.abs(container:get_y() - originalY) <= 1) + -- moving does not touch the size, which is what tells this apart from + -- the resize branch below + assert.are.equal("200px", container.width) + assert.are.equal("200px", container.height) + end) + + it("shows the grabbing hand while the container is being dragged", function() + container:onClick(container.adjLabel, mouseEvent("LeftButton")) + container:onMove(container.adjLabel, mouseEvent("LeftButton")) + assert.are.equal("ClosedHand", container.adjLabel.cursorShape) + end) + + it("takes the cursor away again and moves nothing while locked", function() + container.adjLabel:setCursor("OpenHand") + container:lockContainer() + + container:onMove(container.adjLabel, mouseEvent("NoButton")) + + assert.are.equal(0, container.adjLabel.cursorShape) + -- still the pixel position the constructor was given: a drag would have + -- made it a percentage of the main window, whatever pixel that works out + -- to. Geyser rewrites the plain number into "20px" when it constrains it + assert.are.equal("20px", container.x) + end) + + -- a click that lands within ten pixels of an edge grabs that edge, and the + -- next click sees it and switches from moving to resizing + it("resizes rather than moves when the drag started on an edge", function() + container:onClick(container.adjLabel, mouseEvent("LeftButton", 5, 5)) + container:onClick(container.adjLabel, mouseEvent("LeftButton", 5, 5)) + container:onMove(container.adjLabel, mouseEvent("LeftButton", 5, 5)) + + -- resizing rewrites the size as well as the position, where moving left + -- the size alone + assert.is_truthy(tostring(container.width):find("%%$")) + assert.is_truthy(tostring(container.height):find("%%$")) + -- the mouse did not move, so the left edge it grabbed stays where it was + assert.is_true(math.abs(container:get_width() - 200) <= 1) + end) + end) + + describe("Adjustable.Container:onClickL", function() + it("locks an unlocked container and hides its buttons", function() + assert.is_false(container.locked) + container:onClickL() + assert.is_true(container.locked) + assert.is_false(windowVisible(container.exitLabel.name)) + assert.is_false(windowVisible(container.minimizeLabel.name)) + end) + + it("unlocks a locked one and gives the buttons back", function() + container:onClickL() + container:onClickL() + assert.is_false(container.locked) + assert.is_true(windowVisible(container.exitLabel.name)) + assert.is_true(windowVisible(container.minimizeLabel.name)) + end) + end) + + describe("Adjustable.Container:onClickMin", function() + it("minimizes an open container down to its title bar", function() + assert.is_false(container.minimized) + container:onClickMin() + assert.is_true(container.minimized) + -- Inside is a plain Geyser.Container with no widget of its own, so its + -- own flag is the only place its visibility is readable + assert.is_true(container.Inside.hidden) + assert.are.equal(container.buttonsize + 10, container:get_height()) + end) + + it("restores a minimized one to the height it had", function() + local originalHeight = container:get_height() + container:onClickMin() + container:onClickMin() + assert.is_false(container.minimized) + assert.is_false(container.Inside.hidden) + assert.are.equal(originalHeight, container:get_height()) + end) + end) + + describe("Adjustable.Container:onClickSave and onClickLoad", function() + local saveFile + + before_each(function() + saveFile = string.format("%s%s.lua", container.defaultDir, containerName) + container:deleteSaveFile() + end) + + it("writes the container's layout to its save file", function() + assert.is_false(io.exists(saveFile)) + container:onClickSave() + assert.is_true(io.exists(saveFile)) + end) + + it("puts a saved layout back over whatever the container has now", function() + container:onClickSave() + container:move(300, 400) + assert.are.equal(300, container:get_x()) + + container:onClickLoad() + + assert.are.equal(20, container:get_x()) + assert.are.equal(30, container:get_y()) + end) + + it("brings the locked state back with the layout", function() + container:onClickL() + container:onClickSave() + container:onClickL() + assert.is_false(container.locked) + + container:onClickLoad() + + assert.is_true(container.locked) + end) + + it("does nothing to a container that has never been saved", function() + container:move(300, 400) + container:onClickLoad() + assert.are.equal(300, container:get_x()) + end) + end) + + describe("Adjustable.Container:onEnterAtt", function() + it("fills the attach menu with the borders the container can reach", function() + local positions = container:validAttachPositions() + assert.is_true(#positions > 0, "a container at the top left should be able to attach somewhere") + + container:onEnterAtt() + + assert.are.equal(#positions, #container.attLabel.nestedLabels) + for index = 1, #positions do + assert.are.equal(container.att[index], container.attLabel.nestedLabels[index]) + assert.are.equal("Adjustable.Container.attachToBorder", container.att[index].clickCallback) + end + end) + + it("opens the menu it just built", function() + container:onEnterAtt() + assert.is_true(windowVisible(container.att[1].name)) + end) + + it("rebuilds the menu rather than adding to it when hovered again", function() + container:onEnterAtt() + local first = #container.attLabel.nestedLabels + container:onEnterAtt() + assert.are.equal(first, #container.attLabel.nestedLabels) + end) + + it("drops the borders the container has moved away from", function() + -- the container starts at (20, 30), within reach of the top and left + assert.is_truthy(table.contains(container:validAttachPositions(), "top")) + assert.is_truthy(table.contains(container:validAttachPositions(), "left")) + + local winWidth, winHeight = getMainWindowSize() + container:move(winWidth * 0.5, winHeight * 0.5) + local reachable = container:validAttachPositions() + -- half a window away is out of reach of both, whatever the window size + assert.is_false(table.contains(reachable, "top")) + assert.is_false(table.contains(reachable, "left")) + + container:onEnterAtt() + + assert.are.equal(#reachable, #container.attLabel.nestedLabels) end) end) end) diff --git a/src/mudlet-lua/tests/GeyserButton_spec.lua b/src/mudlet-lua/tests/GeyserButton_spec.lua index 876c41482..c6eb993c0 100644 --- a/src/mudlet-lua/tests/GeyserButton_spec.lua +++ b/src/mudlet-lua/tests/GeyserButton_spec.lua @@ -142,4 +142,290 @@ describe("Tests functionality of Geyser.Button", function() assert.spy(toolTipSpy).was.called_with(match.is_ref(gb), gb.downTooltip, gb.toolTipDuration) end) end) -end) \ No newline at end of file + + -- The blocks above watch the calls a button makes; these assert what the + -- widget ends up looking like, through getWindowGeometry and getLabelText. + describe('Geyser.Button widget state', function() + local created + + local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} + end + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + it('creates a label widget at the constrained geometry', function() + track(Geyser.Button:new({name = "gbsGeometry", x = 5, y = 6, width = 70, height = 30})) + assert.are.equal("label", windowType("gbsGeometry")) + assert.are.same({x = 5, y = 6, width = 70, height = 30}, geometry("gbsGeometry")) + assert.is_true(windowVisible("gbsGeometry")) + end) + + it('falls back to the button default size, not the label one', function() + track(Geyser.Button:new({name = "gbsDefaultSize", x = 0, y = 0})) + local actual = geometry("gbsDefaultSize") + assert.are.equal(50, actual.width) + assert.are.equal(50, actual.height) + end) + + it('shows the up message on the label to start with', function() + track(Geyser.Button:new({name = "gbsUpMessage", x = 0, y = 0, width = 60, height = 20, msg = "press me"})) + assert.is_truthy(getLabelText("gbsUpMessage"):find("press me", 1, true)) + end) + + it('swaps the label text with the state of a two state button', function() + local button = track(Geyser.Button:new({ + name = "gbsTwoState", + x = 0, y = 0, width = 60, height = 20, + msg = "up text", + downMsg = "down text", + twoState = true, + })) + assert.is_truthy(getLabelText("gbsTwoState"):find("up text", 1, true)) + button:setState("down") + assert.is_truthy(getLabelText("gbsTwoState"):find("down text", 1, true)) + button:setState("up") + assert.is_truthy(getLabelText("gbsTwoState"):find("up text", 1, true)) + end) + + it('refuses to push a single state button down', function() + local button = track(Geyser.Button:new({ + name = "gbsSingleState", + x = 0, y = 0, width = 60, height = 20, + msg = "up text", + downMsg = "down text", + })) + local result, message = button:setState("down") + assert.is_nil(result) + assert.are.equal("cannot set a single state button's state to 'down', only 'up'", message) + -- the refusal happens before anything is written, so neither the stored + -- state nor the drawn message move + assert.are.equal("up", button.state) + assert.is_truthy(getLabelText("gbsSingleState"):find("up text", 1, true)) + end) + + it('keeps clicking a refused single state button on its up command', function() + local clicks, downs = 0, 0 + local button = track(Geyser.Button:new({ + name = "gbsRefusedPress", + x = 0, y = 0, width = 60, height = 20, + clickFunction = function() clicks = clicks + 1 end, + downFunction = function() downs = downs + 1 end, + })) + button:setState("down") + button:press() + assert.are.equal(1, clicks) + assert.are.equal(0, downs) + assert.are.equal("up", button.state) + end) + + it('reports success for both states of a two state button', function() + local button = track(Geyser.Button:new({ + name = "gbsStateResult", + x = 0, y = 0, width = 60, height = 20, + twoState = true, + })) + -- a legitimate 'down' has to be distinguishable from a refusal + assert.is_true(button:setState("down")) + assert.is_true(button:setState("up")) + end) + + it('will not start a single state button in the down state', function() + local button = track(Geyser.Button:new({ + name = "gbsDownConstraint", + x = 0, y = 0, width = 60, height = 20, + msg = "up text", + state = "down", + })) + assert.are.equal("up", button.state) + assert.is_truthy(getLabelText("gbsDownConstraint"):find("up text", 1, true)) + end) + + it('rejects a state that is not a string or not a known state', function() + local button = track(Geyser.Button:new({name = "gbsBadState", x = 0, y = 0, width = 60, height = 20})) + local result, message = button:setState(7) + assert.is_nil(result) + assert.is_truthy(message:find("state as string expected, got number", 1, true)) + local badResult, badMessage = button:setState("sideways") + assert.is_nil(badResult) + assert.is_truthy(badMessage:find("state must be one of 'up' or 'down'", 1, true)) + assert.are.equal("up", button.state) + end) + + it('applies the stylesheet of the state it is put into', function() + local button = track(Geyser.Button:new({ + name = "gbsStyles", + x = 0, y = 0, width = 60, height = 20, + twoState = true, + style = "background-color: black;", + downStyle = "background-color: blue;", + })) + button:setState("up") + assert.are.equal("background-color: black;", getLabelStyleSheet("gbsStyles")) + button:setState("down") + assert.are.equal("background-color: blue;", getLabelStyleSheet("gbsStyles")) + end) + + it('setMsg and setDownMsg redraw the button in its current state', function() + local button = track(Geyser.Button:new({ + name = "gbsMessages", + x = 0, y = 0, width = 60, height = 20, + twoState = true, + })) + button:setMsg("new up") + assert.is_truthy(getLabelText("gbsMessages"):find("new up", 1, true)) + button:setDownMsg("new down") + button:setState("down") + assert.is_truthy(getLabelText("gbsMessages"):find("new down", 1, true)) + local result, message = button:setMsg(42) + assert.is_nil(result) + assert.is_truthy(message:find("msg as string expected, got number", 1, true)) + end) + + it('press walks a two state button through both messages', function() + local pressed = 0 + local button = track(Geyser.Button:new({ + name = "gbsPress", + x = 0, y = 0, width = 60, height = 20, + msg = "up text", + downMsg = "down text", + twoState = true, + clickFunction = function() pressed = pressed + 1 end, + downFunction = function() pressed = pressed + 1 end, + })) + button:press() + assert.are.equal("down", button.state) + assert.is_truthy(getLabelText("gbsPress"):find("down text", 1, true)) + button:press() + assert.are.equal("up", button.state) + assert.is_truthy(getLabelText("gbsPress"):find("up text", 1, true)) + assert.are.equal(2, pressed) + end) + + it('disableTwoState puts the button back up', function() + local button = track(Geyser.Button:new({ + name = "gbsDisableTwoState", + x = 0, y = 0, width = 60, height = 20, + msg = "up text", + downMsg = "down text", + twoState = true, + })) + button:setState("down") + button:disableTwoState() + assert.is_false(button.twoState) + assert.are.equal("up", button.state) + assert.is_truthy(getLabelText("gbsDisableTwoState"):find("up text", 1, true)) + end) + + it('hides and shows the button widget', function() + local button = track(Geyser.Button:new({name = "gbsVisible", x = 0, y = 0, width = 60, height = 20})) + button:hide() + assert.is_false(windowVisible("gbsVisible")) + button:show() + assert.is_true(windowVisible("gbsVisible")) + end) + + it('deletes its widget', function() + local button = track(Geyser.Button:new({name = "gbsDelete", x = 0, y = 0, width = 60, height = 20})) + button:delete() + assert.is_nil(getWindowGeometry("gbsDelete")) + assert.is_nil(Geyser.windowList.gbsDelete) + end) + + describe('Geyser.Button command, function and style setters', function() + local button + + before_each(function() + button = track(Geyser.Button:new({name = "gbsSetters", x = 0, y = 0, width = 60, height = 20, msg = "up", downMsg = "down"})) + button:enableTwoState() + end) + + it("setClickCommand and setDownCommand store the alias to expand per state", function() + button:setClickCommand("say up") + button:setDownCommand("say down") + assert.are.equal("say up", button.clickCommand) + assert.are.equal("say down", button.downCommand) + + local expand = spy.on(_G, "expandAlias") + finally(function() expand:revert() end) + button:press() + assert.spy(expand).was.called_with("say up") + button:press() + assert.spy(expand).was.called_with("say down") + end) + + it("setClickCommand and setDownCommand reject a non string", function() + assert.has_error(function() button:setClickCommand(5) end) + assert.has_error(function() button:setDownCommand(5) end) + end) + + it("setDownColor and setColor apply the colour used for each state", function() + -- a label's background colour has no getter, so watch what reaches + -- setBackgroundColor; spy.on leaves the real call in place + local backgroundColor = spy.on(_G, "setBackgroundColor") + finally(function() backgroundColor:revert() end) + + local function lastColour() + local calls = backgroundColor.calls + local vals = calls[#calls].vals + return {vals[1], vals[2], vals[3], vals[4]} + end + + button:setColor("green") + assert.are.equal("green", button.color) + assert.are.same({"gbsSetters", 0, 255, 0}, lastColour()) + + button:setDownColor("red") + assert.are.equal("red", button.downColor) + button:setState("down") + assert.are.same({"gbsSetters", 255, 0, 0}, lastColour()) + + button:setState("up") + assert.are.same({"gbsSetters", 0, 255, 0}, lastColour()) + end) + + it("setStyle and setDownStyle put the right sheet on the widget per state", function() + button:setStyle("background-color: green;") + button:setDownStyle("background-color: red;") + assert.are.equal("background-color: green;", button.style) + assert.are.equal("background-color: red;", button.downStyle) + + button:setState("up") + assert.are.equal("background-color: green;", getLabelStyleSheet("gbsSetters")) + button:setState("down") + assert.are.equal("background-color: red;", getLabelStyleSheet("gbsSetters")) + end) + + it("setStyle accepts a Geyser.StyleSheet object", function() + local sheet = Geyser.StyleSheet:new("background-color: blue;") + button:setStyle(sheet) + button:setState("up") + assert.are.equal(sheet:getCSS(), getLabelStyleSheet("gbsSetters")) + end) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserCommandLine_spec.lua b/src/mudlet-lua/tests/GeyserCommandLine_spec.lua new file mode 100644 index 000000000..c815da818 --- /dev/null +++ b/src/mudlet-lua/tests/GeyserCommandLine_spec.lua @@ -0,0 +1,222 @@ +-- Geyser.CommandLine wraps Mudlet's sub-command-line primitive, so everything +-- it does is read back through windowType/getWindowGeometry/windowVisible and +-- getCmdLine rather than through the Geyser object's own bookkeeping. +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.CommandLine", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.CommandLine:new/new2", function() + it("creates a command line widget at the constrained geometry", function() + local commandLine = track(Geyser.CommandLine:new({name = "gclNew", x = 30, y = 40, width = 200, height = 30})) + -- Geyser's own type string is camel cased, Mudlet's windowType is not + assert.are.equal("commandLine", commandLine.type) + assert.are.equal("commandline", windowType("gclNew")) + assert.are.same({x = 30, y = 40, width = 200, height = 30}, geometry("gclNew")) + assert.is_true(windowVisible("gclNew")) + assert.are.equal(commandLine, Geyser.windowList.gclNew) + assert.are.equal("main", commandLine.windowname) + end) + + it("uses Geyser's defaults when no constraints are given", function() + track(Geyser.CommandLine:new({name = "gclDefaults"})) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gclDefaults")) + end) + + it("resolves percentages against its container", function() + local container = track(Geyser.Container:new({name = "gclBox", x = 100, y = 50, width = 400, height = 200})) + track(Geyser.CommandLine:new({name = "gclInBox", x = "25%", y = "50%", width = "50%", height = "25%"}, container)) + assert.are.same({x = 200, y = 150, width = 200, height = 50}, geometry("gclInBox")) + end) + + it("new2 marks the command line as using add2", function() + local commandLine = track(Geyser.CommandLine:new2({name = "gclNew2", x = 0, y = 0, width = 100, height = 30})) + assert.is_true(commandLine.useAdd2) + assert.are.equal("commandline", windowType("gclNew2")) + end) + + it("keeps a new command line of a hidden add2 container hidden", function() + local container = track(Geyser.Container:new2({name = "gclHiddenBox", x = 0, y = 0, width = 200, height = 100})) + container:hide() + local commandLine = track(Geyser.CommandLine:new2({name = "gclHiddenChild", x = 0, y = 0, width = 50, height = 20}, container)) + assert.is_true(commandLine.auto_hidden) + assert.is_false(windowVisible("gclHiddenChild")) + container:show() + assert.is_true(windowVisible("gclHiddenChild")) + end) + end) + + describe("Geyser.CommandLine:print/append/getText/clear", function() + local commandLine + + before_each(function() + commandLine = track(Geyser.CommandLine:new({name = "gclText", x = 0, y = 0, width = 200, height = 30})) + end) + + it("prints text into the command line", function() + commandLine:print("hello") + assert.are.equal("hello", commandLine:getText()) + assert.are.equal("hello", getCmdLine("gclText")) + end) + + it("replaces what was there on the next print", function() + commandLine:print("first") + commandLine:print("second") + assert.are.equal("second", commandLine:getText()) + end) + + it("appends to the text already in the command line", function() + commandLine:print("hello") + commandLine:append(" world") + assert.are.equal("hello world", commandLine:getText()) + end) + + it("appends into an empty command line", function() + commandLine:append("only") + assert.are.equal("only", commandLine:getText()) + end) + + it("clears the command line", function() + commandLine:print("something") + commandLine:clear() + assert.are.equal("", commandLine:getText()) + assert.are.equal("", getCmdLine("gclText")) + end) + end) + + describe("Geyser.CommandLine:selectText", function() + it("reports success", function() + local commandLine = track(Geyser.CommandLine:new({name = "gclSelect", x = 0, y = 0, width = 100, height = 30})) + commandLine:print("select me") + assert.is_true(commandLine:selectText()) + -- selecting must not disturb what is typed + assert.are.equal("select me", commandLine:getText()) + end) + end) + + pending("Geyser.CommandLine:selectText selects every character - the selection itself is not readable from Lua and needs a getCmdLineSelection getter") + + describe("Geyser.CommandLine:setStyleSheet", function() + it("reuses the remembered stylesheet when called without one", function() + local commandLine = track(Geyser.CommandLine:new({name = "gclCss", x = 0, y = 0, width = 100, height = 30})) + commandLine:setStyleSheet("background-color: red;") + assert.are.equal("background-color: red;", commandLine.stylesheet) + commandLine:setStyleSheet() + assert.are.equal("background-color: red;", commandLine.stylesheet) + end) + end) + + pending("Geyser.CommandLine:setStyleSheet applies the stylesheet to the widget - needs a getCmdLineStyleSheet getter") + + describe("Geyser.CommandLine:setAction/resetAction", function() + it("remembers the action and its arguments, and forgets them again", function() + local commandLine = track(Geyser.CommandLine:new({name = "gclAction", x = 0, y = 0, width = 100, height = 30})) + local action = function() end + commandLine:setAction(action, "one", "two") + assert.are.equal(action, commandLine.actionFunc) + assert.are.same({"one", "two"}, commandLine.actionArgs) + commandLine:resetAction() + assert.is_nil(commandLine.actionFunc) + assert.is_nil(commandLine.actionArgs) + end) + end) + + pending("Geyser.CommandLine:setAction runs the action when the command line sends its text - no Lua API submits input to a command line, so this needs a functional test") + + describe("Geyser.CommandLine geometry and visibility", function() + local commandLine + + before_each(function() + commandLine = track(Geyser.CommandLine:new({name = "gclMove", x = 10, y = 20, width = 200, height = 30})) + end) + + it("moves and resizes the widget", function() + commandLine:move(60, 70) + commandLine:resize(120, 40) + assert.are.same({x = 60, y = 70, width = 120, height = 40}, geometry("gclMove")) + end) + + it("hides and shows the widget", function() + commandLine:hide() + assert.is_true(commandLine.hidden) + assert.is_false(windowVisible("gclMove")) + commandLine:show() + assert.is_false(commandLine.hidden) + assert.is_true(windowVisible("gclMove")) + end) + + it("follows its container when the container moves", function() + local container = track(Geyser.Container:new({name = "gclDragBox", x = 0, y = 0, width = 200, height = 100})) + track(Geyser.CommandLine:new({name = "gclDragged", x = 0, y = 0, width = "100%", height = 30}, container)) + container:move(150, 30) + assert.are.same({x = 150, y = 30, width = 200, height = 30}, geometry("gclDragged")) + end) + end) + + describe("Geyser.CommandLine error paths", function() + it("raises on a constraint it cannot parse, leaving no widget behind", function() + -- the object is registered before its constraints are resolved, so the + -- failed attempt has to be swept out of the root window list by hand + finally(function() + local zombie = Geyser.windowList.gclBadConstraint + if zombie then + zombie:delete() + end + end) + local ok, message = pcall(function() + return Geyser.CommandLine:new({name = "gclBadConstraint", x = 0, y = 0, width = true, height = 20}) + end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("GeyserSetConstraints.lua", 1, true)) + assert.is_nil(windowType("gclBadConstraint")) + end) + + it("raises when printing something that is not text, leaving the text alone", function() + local commandLine = track(Geyser.CommandLine:new({name = "gclBadPrint", x = 0, y = 0, width = 100, height = 30})) + commandLine:print("kept") + local ok, message = pcall(function() commandLine:print(nil) end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("printCmdLine", 1, true)) + assert.are.equal("kept", commandLine:getText()) + end) + end) + + describe("Geyser.CommandLine:type_delete", function() + it("deletes the widget with the object", function() + local commandLine = track(Geyser.CommandLine:new({name = "gclDelete", x = 0, y = 0, width = 100, height = 30})) + assert.are.equal("commandline", windowType("gclDelete")) + commandLine:delete() + assert.is_nil(windowType("gclDelete")) + assert.is_nil(getWindowGeometry("gclDelete")) + assert.is_nil(Geyser.windowList.gclDelete) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserContainer_spec.lua b/src/mudlet-lua/tests/GeyserContainer_spec.lua new file mode 100644 index 000000000..eecd4526c --- /dev/null +++ b/src/mudlet-lua/tests/GeyserContainer_spec.lua @@ -0,0 +1,821 @@ +-- Geyser resolves its constraints against the live main window, whose size +-- differs between machines, so expectations are computed from +-- getMainWindowSize() at assert time rather than hardcoded. Mudlet truncates +-- the doubles handed to moveWindow()/resizeWindow() (static_cast<int>), which +-- for the positive geometry used here is math.floor. +-- +-- Containers themselves have no Mudlet widget, so geometry is read back from a +-- child label - the widget Geyser actually moves and resizes. +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.Container", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + -- A cascading parent delete unlinks its children from its windowList, which + -- is how we tell an object has already been deleted and must not be deleted + -- again. + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.Container:new/new2", function() + it("generates a name and defaults the type to container", function() + local container = track(Geyser.Container:new()) + assert.are.equal("container", container.type) + assert.is_truthy(container.name:find("^anon_window_%d+$")) + assert.are.same({}, container.windows) + end) + + it("registers a top level container with the root Geyser window list", function() + local container = track(Geyser.Container:new({name = "gcsRegistered"})) + assert.are.equal(container, Geyser.windowList.gcsRegistered) + assert.is_truthy(table.index_of(Geyser.windows, "gcsRegistered")) + assert.are.equal(Geyser, container.container) + assert.are.equal("main", container.windowname) + end) + + it("has no Mudlet widget of its own", function() + track(Geyser.Container:new({name = "gcsNoWidget", x = 0, y = 0, width = 100, height = 100})) + local result, message = getWindowGeometry("gcsNoWidget") + assert.is_nil(result) + assert.is_truthy(message:find("gcsNoWidget", 1, true)) + assert.is_nil(windowType("gcsNoWidget")) + end) + + it("adds a child to the container given as the second argument", function() + local parent = track(Geyser.Container:new({name = "gcsParent", x = 0, y = 0, width = 100, height = 100})) + local child = track(Geyser.Container:new({name = "gcsChild"}, parent)) + assert.are.equal(parent, child.container) + assert.are.equal(child, parent.windowList.gcsChild) + assert.are.same({"gcsChild"}, parent.windows) + assert.is_nil(Geyser.windowList.gcsChild) + end) + + it("new2 marks the container as using add2", function() + local container = track(Geyser.Container:new2({name = "gcsAdd2", x = 0, y = 0, width = 50, height = 50})) + assert.is_true(container.useAdd2) + assert.is_false(container.hidden) + assert.is_false(container.auto_hidden) + end) + + it("raises an error when the container argument is not a container", function() + local ok, message = pcall(function() + return Geyser.Container:new({name = "gcsBadParent"}, "notacontainer") + end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("add", 1, true)) + end) + end) + + describe("Geyser.calc_constraints/set_constraints", function() + it("places a child at the pixel position it was given", function() + track(Geyser.Label:new({name = "gcsPixels", x = 12, y = 34, width = 120, height = 56})) + assert.are.same({x = 12, y = 34, width = 120, height = 56}, geometry("gcsPixels")) + end) + + it("uses Geyser's defaults when no constraints are given", function() + track(Geyser.Label:new({name = "gcsDefaults"})) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gcsDefaults")) + end) + + it("resolves percentages against the main window", function() + local mainWidth, mainHeight = getMainWindowSize() + track(Geyser.Label:new({name = "gcsPercent", x = "10%", y = "20%", width = "50%", height = "25%"})) + assert.are.same({ + x = math.floor(0.1 * mainWidth), + y = math.floor(0.2 * mainHeight), + width = math.floor(0.5 * mainWidth), + height = math.floor(0.25 * mainHeight), + }, geometry("gcsPercent")) + end) + + it("adds a pixel offset to a percentage constraint", function() + local mainWidth = getMainWindowSize() + track(Geyser.Label:new({name = "gcsOffset", x = "50%+10", y = 0, width = "10%-5", height = 20})) + local actual = geometry("gcsOffset") + assert.are.equal(math.floor(0.5 * mainWidth + 10), actual.x) + assert.are.equal(math.floor(0.1 * mainWidth - 5), actual.width) + end) + + it("measures negative pixel constraints from the far edge", function() + local mainWidth, mainHeight = getMainWindowSize() + track(Geyser.Label:new({name = "gcsNegative", x = "-100px", y = "-50px", width = "100px", height = "50px"})) + assert.are.same({x = mainWidth - 100, y = mainHeight - 50, width = 100, height = 50}, geometry("gcsNegative")) + end) + + it("scales character constraints with the font size", function() + local charWidth, charHeight = calcFontSize(9) + track(Geyser.Label:new({name = "gcsChars", x = 0, y = 0, width = "10c", height = "2c", fontSize = 9})) + local actual = geometry("gcsChars") + assert.are.equal(10 * charWidth, actual.width) + assert.are.equal(2 * charHeight, actual.height) + end) + + it("resolves a child's percentages against its container, not the main window", function() + local container = track(Geyser.Container:new({name = "gcsBox", x = 100, y = 50, width = 400, height = 200})) + track(Geyser.Label:new({name = "gcsBoxChild", x = "50%", y = "50%", width = "50%", height = "50%"}, container)) + assert.are.same({x = 300, y = 150, width = 200, height = 100}, geometry("gcsBoxChild")) + end) + + it("treats a negative percentage as the remainder of the container", function() + local container = track(Geyser.Container:new({name = "gcsNegBox", x = 0, y = 0, width = 200, height = 100})) + track(Geyser.Label:new({name = "gcsNegPercent", x = "-25%", y = 0, width = "-50%", height = "100%"}, container)) + -- -25% means 75% along, -50% means half the container wide + assert.are.same({x = 150, y = 0, width = 100, height = 100}, geometry("gcsNegPercent")) + end) + + it("stretches a negative width to the far edge of the container", function() + local container = track(Geyser.Container:new({name = "gcsNegWidthBox", x = 0, y = 0, width = 200, height = 100})) + track(Geyser.Label:new({name = "gcsNegWidth", x = 10, y = 0, width = "-10px", height = 20}, container)) + -- from x 10 to ten pixels short of the container's right edge + assert.are.same({x = 10, y = 0, width = 180, height = 20}, geometry("gcsNegWidth")) + end) + + it("measures a bare negative number from the far edge too", function() + local container = track(Geyser.Container:new({name = "gcsBareBox", x = 0, y = 0, width = 200, height = 100})) + track(Geyser.Label:new({name = "gcsBareNegative", x = -5, y = 0, width = 20, height = 20}, container)) + assert.are.equal(195, geometry("gcsBareNegative").x) + end) + + it("calls a constraint that is a function", function() + local container = track(Geyser.Container:new({name = "gcsFuncBox", x = 0, y = 0, width = 200, height = 100})) + track(Geyser.Label:new({ + name = "gcsFunctionConstraint", + x = function() return 25 end, + y = 0, + width = function() return 50 end, + height = 20, + }, container)) + assert.are.same({x = 25, y = 0, width = 50, height = 20}, geometry("gcsFunctionConstraint")) + end) + + it("raises an error on a constraint it cannot parse", function() + -- the object is registered before its constraints are resolved, so the + -- failed attempt has to be swept out of the root window list by hand + finally(function() + local zombie = Geyser.windowList.gcsBadConstraint + if zombie then + zombie:delete() + end + end) + local ok, message = pcall(function() + return Geyser.Label:new({name = "gcsBadConstraint", x = 0, y = 0, width = true, height = 20}) + end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("GeyserSetConstraints.lua", 1, true)) + assert.is_nil(getWindowGeometry("gcsBadConstraint")) + end) + + it("leaves the widget where it was when a move is given a bad constraint", function() + local label = track(Geyser.Label:new({name = "gcsBadMove", x = 10, y = 10, width = 50, height = 50})) + local ok = pcall(function() label:move("nonsense", 20) end) + assert.is_false(ok) + assert.are.same({x = 10, y = 10, width = 50, height = 50}, geometry("gcsBadMove")) + end) + + it("resolves percentages through two levels of nesting", function() + local outer = track(Geyser.Container:new({name = "gcsOuter", x = 100, y = 50, width = 400, height = 200})) + local middle = track(Geyser.Container:new({name = "gcsMiddle", x = "50%", y = 0, width = "50%", height = "100%"}, outer)) + track(Geyser.Label:new({name = "gcsLeaf", x = "50%", y = "50%", width = "50%", height = "50%"}, middle)) + -- middle spans x 300..500, y 50..250, so the leaf starts halfway into it + assert.are.same({x = 400, y = 150, width = 100, height = 100}, geometry("gcsLeaf")) + end) + end) + + describe("Geyser.Container:move/resize", function() + local container + + before_each(function() + container = track(Geyser.Container:new({name = "gcsMover", x = 100, y = 50, width = 400, height = 200})) + track(Geyser.Label:new({name = "gcsMoverChild", x = "50%", y = "50%", width = "50%", height = "50%"}, container)) + end) + + it("drags the children of the container along", function() + container:move(200, 60) + assert.are.same({x = 400, y = 160, width = 200, height = 100}, geometry("gcsMoverChild")) + end) + + it("re-resolves the size of percentage children", function() + container:resize(200, 100) + assert.are.same({x = 200, y = 100, width = 100, height = 50}, geometry("gcsMoverChild")) + end) + + it("keeps the constraint that was passed as nil", function() + container:move(nil, 150) + -- numeric constraints are normalised to a pixel string as they are applied + assert.are.equal("100px", container.x) + assert.are.equal("150px", container.y) + container:resize(nil, 100) + assert.are.equal("400px", container.width) + assert.are.equal("100px", container.height) + assert.are.same({x = 300, y = 200, width = 200, height = 50}, geometry("gcsMoverChild")) + end) + + it("moves a label to the pixels it was given", function() + local label = track(Geyser.Label:new({name = "gcsMoveLabel", x = 0, y = 0, width = 40, height = 20})) + label:move(70, 80) + label:resize(90, 30) + assert.are.same({x = 70, y = 80, width = 90, height = 30}, geometry("gcsMoveLabel")) + end) + end) + + describe("Geyser.Container:hide/show/hide_impl/show_impl", function() + local container + + before_each(function() + container = track(Geyser.Container:new({name = "gcsVisible", x = 0, y = 0, width = 100, height = 100})) + track(Geyser.Label:new({name = "gcsVisibleChild"}, container)) + end) + + it("hides and shows the widgets of its children", function() + assert.is_true(windowVisible("gcsVisibleChild")) + container:hide() + assert.is_false(windowVisible("gcsVisibleChild")) + container:show() + assert.is_true(windowVisible("gcsVisibleChild")) + end) + + it("marks children hidden by their container as auto_hidden", function() + local child = container.windowList.gcsVisibleChild + container:hide() + assert.is_true(container.hidden) + assert.is_false(child.hidden) + assert.is_true(child.auto_hidden) + container:show() + assert.is_false(child.auto_hidden) + end) + + it("refuses to show a child while its container is hidden", function() + local child = container.windowList.gcsVisibleChild + container:hide() + assert.is_false(child:show()) + assert.is_false(windowVisible("gcsVisibleChild")) + -- the request is remembered, so the child reappears with its container + assert.is_false(child.hidden) + container:show() + assert.is_true(windowVisible("gcsVisibleChild")) + end) + + it("hides and shows a label directly", function() + local label = track(Geyser.Label:new({name = "gcsSelfHide", x = 0, y = 0, width = 30, height = 30})) + label:hide() + assert.is_true(label.hidden) + assert.is_false(windowVisible("gcsSelfHide")) + label:show() + assert.is_false(label.hidden) + assert.is_true(windowVisible("gcsSelfHide")) + end) + end) + + describe("Geyser.Container:raise/lower/raiseAll/lowerAll", function() + -- Mudlet exposes no z-order readback, so these assert the ordering Geyser + -- keeps in container.windows - the order it replays z-order changes from. + local container + + before_each(function() + container = track(Geyser.Container:new({name = "gcsStack", x = 0, y = 0, width = 100, height = 100})) + track(Geyser.Label:new({name = "gcsStack1"}, container)) + track(Geyser.Label:new({name = "gcsStack2"}, container)) + end) + + it("moves a raised window to the end of its container's ordering", function() + assert.are.same({"gcsStack1", "gcsStack2"}, container.windows) + container.windowList.gcsStack1:raise() + assert.are.same({"gcsStack2", "gcsStack1"}, container.windows) + end) + + it("leaves the ordering alone when the topmost window is raised", function() + container.windowList.gcsStack2:raise() + assert.are.same({"gcsStack1", "gcsStack2"}, container.windows) + end) + + it("moves a lowered window to the front of the ordering", function() + container.windowList.gcsStack2:lower() + assert.are.same({"gcsStack2", "gcsStack1"}, container.windows) + end) + + it("leaves the ordering alone when the bottom window is lowered", function() + container.windowList.gcsStack1:lower() + assert.are.same({"gcsStack1", "gcsStack2"}, container.windows) + end) + + -- raiseAll and lowerAll leave container.windows untouched by design (they + -- raise children with changeWindowIndex false), and Mudlet has no z-order + -- readback, so the only observable is which windows they hand to + -- raiseWindow/lowerWindow and in what order. busted's spy calls the real + -- function through, so this still exercises Mudlet itself. + it("raises itself and then every child, top down", function() + local raised = spy.on(_G, "raiseWindow") + finally(function() _G.raiseWindow:revert() end) + container:raiseAll() + assert.spy(raised).was.called(3) + local order = {} + for index, call in ipairs(raised.calls) do + order[index] = call.vals[1] + end + assert.are.same({"gcsStack", "gcsStack1", "gcsStack2"}, order) + assert.are.same({"gcsStack1", "gcsStack2"}, container.windows) + end) + + it("lowers the deepest child first and itself last", function() + local lowered = spy.on(_G, "lowerWindow") + finally(function() _G.lowerWindow:revert() end) + container:lowerAll() + assert.spy(lowered).was.called(3) + local order = {} + for index, call in ipairs(lowered.calls) do + order[index] = call.vals[1] + end + -- reverse order, so the children keep their stacking relative to each other + assert.are.same({"gcsStack2", "gcsStack1", "gcsStack"}, order) + assert.are.same({"gcsStack1", "gcsStack2"}, container.windows) + -- lowerAll walks the tree through a scratch table it must clean up again + assert.is_nil(Geyser.Container.windowTable) + end) + end) + + describe("Geyser.Container:delete", function() + -- the objects here are tracked as well as deleted by hand, so a failing + -- assertion before the delete cannot strand a widget + it("deletes the widgets of its children", function() + local container = track(Geyser.Container:new({name = "gcsDelete", x = 0, y = 0, width = 100, height = 100})) + track(Geyser.Label:new({name = "gcsDeleteChild"}, container)) + assert.is_not_nil(getWindowGeometry("gcsDeleteChild")) + container:delete() + assert.is_nil(getWindowGeometry("gcsDeleteChild")) + assert.are.same({}, container.windowList) + assert.are.same({}, container.windows) + end) + + it("unregisters a top level container from the root Geyser lists", function() + local container = track(Geyser.Container:new({name = "gcsDeleteRoot", x = 0, y = 0, width = 10, height = 10})) + container:delete() + assert.is_nil(Geyser.windowList.gcsDeleteRoot) + assert.is_nil(table.index_of(Geyser.windows, "gcsDeleteRoot")) + end) + + -- the deferral is per container rather than per cascade, so a box nested + -- inside the container being deleted has to go quiet on its own account + it("holds the layout of a box it is deleting back", function() + local container = track(Geyser.Container:new({name = "gcsDeleteCost", x = 0, y = 0, width = 400, height = 100})) + local box = track(Geyser.HBox:new({name = "gcsDeleteCostBox", width = 400, height = 100}, container)) + for i = 1, 5 do + track(Geyser.Label:new({name = "gcsDeleteCostChild" .. i}, box)) + end + local organizes = 0 + local organize = box.organize + box.organize = function(...) + organizes = organizes + 1 + return organize(...) + end + container:delete() + assert.are.equal(0, organizes) + assert.is_nil(getWindowGeometry("gcsDeleteCostChild1")) + end) + + it("unregisters a child from its parent", function() + local container = track(Geyser.Container:new({name = "gcsDeleteParent", x = 0, y = 0, width = 100, height = 100})) + local child = track(Geyser.Label:new({name = "gcsDeleteMe"}, container)) + child:delete() + assert.is_nil(container.windowList.gcsDeleteMe) + assert.are.same({}, container.windows) + assert.is_nil(getWindowGeometry("gcsDeleteMe")) + end) + end) + + describe("Geyser.Container:setFontSize", function() + it("rejects a font size that is not a number", function() + local container = track(Geyser.Container:new({name = "gcsFont", x = 0, y = 0, width = 100, height = 100})) + local ok, message = pcall(function() container:setFontSize("nope") end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("fontSize must be a number", 1, true)) + assert.are.equal(8, container.fontSize) + end) + + -- the container's own size is what changes here; a child that carries its + -- own character constraint keeps its own fontSize and does not follow + it("re-resolves its own character sized constraints, moving its children with it", function() + local container = track(Geyser.Container:new({name = "gcsFontBox", x = 0, y = 0, width = "20c", height = "4c", fontSize = 8})) + track(Geyser.Label:new({name = "gcsFontChild", x = 0, y = 0, width = "100%", height = "100%"}, container)) + local smallWidth, smallHeight = calcFontSize(8) + assert.are.same({x = 0, y = 0, width = 20 * smallWidth, height = 4 * smallHeight}, geometry("gcsFontChild")) + container:setFontSize(16) + local bigWidth, bigHeight = calcFontSize(16) + assert.are.equal(16, container.fontSize) + assert.are.same({x = 0, y = 0, width = 20 * bigWidth, height = 4 * bigHeight}, geometry("gcsFontChild")) + end) + end) + + describe("Geyser.Container:calculate_dynamic_window_size", function() + it("returns the full size when the container holds at most one window", function() + local container = track(Geyser.Container:new({name = "gcsDyn1", x = 0, y = 0, width = 300, height = 200})) + assert.are.same({width = 300, height = 200}, container:calculate_dynamic_window_size()) + track(Geyser.Label:new({name = "gcsDyn1Child"}, container)) + assert.are.same({width = 300, height = 200}, container:calculate_dynamic_window_size()) + end) + + it("splits the space between dynamic windows", function() + local container = track(Geyser.Container:new({name = "gcsDyn2", x = 0, y = 0, width = 300, height = 200})) + track(Geyser.Label:new({name = "gcsDyn2A"}, container)) + track(Geyser.Label:new({name = "gcsDyn2B"}, container)) + assert.are.same({width = 150, height = 100}, container:calculate_dynamic_window_size()) + end) + + it("leaves fixed windows out of the split", function() + local container = track(Geyser.Container:new({name = "gcsDyn3", x = 0, y = 0, width = 300, height = 200})) + track(Geyser.Label:new({ + name = "gcsDyn3Fixed", + width = 100, + height = 50, + h_policy = Geyser.Fixed, + v_policy = Geyser.Fixed, + }, container)) + track(Geyser.Label:new({name = "gcsDyn3Dynamic"}, container)) + assert.are.same({width = 200, height = 150}, container:calculate_dynamic_window_size()) + end) + + it("reports no share at all when every window is fixed", function() + local container = track(Geyser.Container:new({name = "gcsDyn5", x = 0, y = 0, width = 200, height = 100})) + for index = 1, 2 do + track(Geyser.Label:new({ + name = "gcsDyn5Fixed" .. index, + width = 100, + height = 50, + h_policy = Geyser.Fixed, + v_policy = Geyser.Fixed, + }, container)) + end + assert.are.same({width = 0, height = 0}, container:calculate_dynamic_window_size()) + end) + + it("accounts for a stretch factor", function() + local container = track(Geyser.Container:new({name = "gcsDyn4", x = 0, y = 0, width = 400, height = 400})) + track(Geyser.Label:new({name = "gcsDyn4A", v_stretch_factor = 3}, container)) + track(Geyser.Label:new({name = "gcsDyn4B"}, container)) + -- the stretch factor counts as three shares against one, so a share is a quarter + assert.are.equal(100, container:calculate_dynamic_window_size().height) + end) + end) + + describe("Geyser.Container:flash", function() + it("puts a flash label over the container's geometry", function() + local container = track(Geyser.Container:new({name = "gcsFlash", x = 20, y = 30, width = 80, height = 40})) + -- the flash label belongs to no Geyser container, so remove it by hand + finally(function() deleteLabel("gcsFlash_dimensions_flash") end) + container:flash(0.1) + assert.are.same({x = 20, y = 30, width = 80, height = 40}, geometry("gcsFlash_dimensions_flash")) + assert.is_true(windowVisible("gcsFlash_dimensions_flash")) + end) + + it("creates nothing when told not to flash", function() + local container = track(Geyser.Container:new({name = "gcsNoFlash", x = 20, y = 30, width = 80, height = 40})) + container:flash(0.1, false) + assert.is_nil(getWindowGeometry("gcsNoFlash_dimensions_flash")) + end) + end) + + describe("Geyser:base_add/add/add2", function() + it("tracks an added window once, even when it is added twice", function() + local container = track(Geyser.Container:new({name = "gcsAdd", x = 0, y = 0, width = 100, height = 100})) + local label = track(Geyser.Label:new({name = "gcsAdded"}, container)) + container:add(label) + assert.are.same({"gcsAdded"}, container.windows) + assert.are.equal(label, container.windowList.gcsAdded) + end) + + it("takes a window away from its previous container", function() + local first = track(Geyser.Container:new({name = "gcsAddFrom", x = 0, y = 0, width = 100, height = 100})) + local second = track(Geyser.Container:new({name = "gcsAddTo", x = 200, y = 0, width = 100, height = 100})) + local label = track(Geyser.Label:new({name = "gcsAddMoved", x = 0, y = 0, width = "100%", height = "100%"}, first)) + second:add(label) + assert.is_nil(first.windowList.gcsAddMoved) + assert.are.same({}, first.windows) + assert.are.equal(label, second.windowList.gcsAddMoved) + assert.are.equal(200, geometry("gcsAddMoved").x) + end) + + it("keeps a new child of a hidden add2 container hidden", function() + local container = track(Geyser.Container:new2({name = "gcsAdd2Box", x = 0, y = 0, width = 100, height = 100})) + container:hide() + local label = track(Geyser.Label:new2({name = "gcsAdd2Child"}, container)) + assert.is_true(label.auto_hidden) + assert.is_false(windowVisible("gcsAdd2Child")) + container:show() + assert.is_true(windowVisible("gcsAdd2Child")) + end) + end) + + describe("Geyser:remove", function() + it("drops the window from both of the container's lists", function() + local container = track(Geyser.Container:new({name = "gcsRemove", x = 0, y = 0, width = 100, height = 100})) + local label = track(Geyser.Label:new({name = "gcsRemoved"}, container)) + -- a removed window is no longer anyone's child, so nothing else will + -- clean its widget up + finally(function() deleteLabel("gcsRemoved") end) + container:remove(label) + assert.is_nil(container.windowList.gcsRemoved) + assert.are.same({}, container.windows) + -- removing only unhooks the bookkeeping, the widget stays alive + assert.is_not_nil(getWindowGeometry("gcsRemoved")) + end) + end) + + describe("Geyser:changeContainer", function() + local from, to + + before_each(function() + from = track(Geyser.Container:new({name = "gcsFrom", x = 0, y = 0, width = 200, height = 200})) + to = track(Geyser.Container:new({name = "gcsTo", x = 300, y = 100, width = 200, height = 200})) + end) + + it("re-resolves the window's constraints against its new container", function() + local label = track(Geyser.Label:new({name = "gcsChanging", x = "50%", y = "50%", width = "50%", height = "50%"}, from)) + assert.are.same({x = 100, y = 100, width = 100, height = 100}, geometry("gcsChanging")) + label:changeContainer(to) + assert.are.equal(to, label.container) + assert.is_nil(from.windowList.gcsChanging) + assert.are.same({x = 400, y = 200, width = 100, height = 100}, geometry("gcsChanging")) + end) + + it("returns nil and a message when the window is already in that container", function() + local label = track(Geyser.Label:new({name = "gcsSameContainer"}, from)) + local result, message = label:changeContainer(from) + assert.is_nil(result) + assert.is_truthy(message:find("already in this container", 1, true)) + end) + + it("returns nil and a message for something that is not a container", function() + local label = track(Geyser.Label:new({name = "gcsBadContainer"}, from)) + local result, message = label:changeContainer("notacontainer") + assert.is_nil(result) + assert.are.equal("didn't get a valid container", message) + assert.are.equal(from, label.container) + local nilResult, nilMessage = label:changeContainer(nil) + assert.is_nil(nilResult) + assert.are.equal("didn't get a valid container", nilMessage) + end) + + it("refuses to put a container inside itself", function() + local result, message = from:changeContainer(from) + assert.is_nil(result) + assert.are.equal("didn't get a valid container", message) + end) + + it("moves a window back to the root window when passed \"main\"", function() + local label = track(Geyser.Label:new({name = "gcsBackToMain", x = "50%", y = 0, width = 10, height = 10}, from)) + label:changeContainer("main") + assert.are.equal(Geyser, label.container) + assert.are.equal(math.floor(0.5 * getMainWindowSize()), geometry("gcsBackToMain").x) + end) + end) + + describe("Geyser:begin_update/end_update/reposition", function() + it("toggles the deferred update flag", function() + -- leaving the flag set would stop every later spec repositioning + finally(function() Geyser.defer_updates = false end) + Geyser:begin_update() + assert.is_true(Geyser.defer_updates) + Geyser:end_update() + assert.is_false(Geyser.defer_updates) + end) + + it("holds back the layout of a box while its updates are deferred", function() + local box = track(Geyser.VBox:new({name = "gcsDeferred", x = 0, y = 0, width = 200, height = 200})) + box:begin_update() + finally(function() box.defer_updates = false end) + track(Geyser.Label:new({name = "gcsDeferredA"}, box)) + track(Geyser.Label:new({name = "gcsDeferredB"}, box)) + -- the children keep their own constraints instead of being stacked + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gcsDeferredA")) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gcsDeferredB")) + -- end_update applies the layout that was held back, without the caller + -- having to organize the box itself + box:end_update() + assert.is_false(box.defer_updates) + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gcsDeferredA")) + assert.are.same({x = 0, y = 100, width = 200, height = 100}, geometry("gcsDeferredB")) + end) + + it("holds back the layout of an hbox while its updates are deferred", function() + local box = track(Geyser.HBox:new({name = "gcsDeferredH", x = 0, y = 0, width = 200, height = 200})) + box:begin_update() + finally(function() box.defer_updates = false end) + track(Geyser.Label:new({name = "gcsDeferredHA"}, box)) + track(Geyser.Label:new({name = "gcsDeferredHB"}, box)) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gcsDeferredHA")) + box:end_update() + assert.are.same({x = 0, y = 0, width = 100, height = 200}, geometry("gcsDeferredHA")) + assert.are.same({x = 100, y = 0, width = 100, height = 200}, geometry("gcsDeferredHB")) + end) + + it("holds back the layout of a new2 box, which fills through add2", function() + local box = track(Geyser.VBox:new2({name = "gcsDeferred2", x = 0, y = 0, width = 200, height = 200})) + box:begin_update() + finally(function() box.defer_updates = false end) + track(Geyser.Label:new2({name = "gcsDeferred2A"}, box)) + track(Geyser.Label:new2({name = "gcsDeferred2B"}, box)) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gcsDeferred2A")) + box:end_update() + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gcsDeferred2A")) + assert.are.same({x = 0, y = 100, width = 200, height = 100}, geometry("gcsDeferred2B")) + end) + + it("keeps holding the layout back when the box itself is moved", function() + -- move() repositions, and reposition is what flushes the deferred layout, + -- so it must not undo the deferral it was asked for + local box = track(Geyser.VBox:new({name = "gcsDeferredMove", x = 0, y = 0, width = 200, height = 200})) + box:begin_update() + finally(function() box.defer_updates = false end) + track(Geyser.Label:new({name = "gcsDeferredMoveA"}, box)) + track(Geyser.Label:new({name = "gcsDeferredMoveB"}, box)) + box:move(0, 0) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gcsDeferredMoveA")) + box:end_update() + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gcsDeferredMoveA")) + end) + + it("lays a box out that was filled during a deferral of the root window", function() + -- leaving the flag set would stop every later spec repositioning + finally(function() Geyser.defer_updates = false end) + local box = track(Geyser.VBox:new({name = "gcsRootDeferred", x = 0, y = 0, width = 200, height = 200})) + Geyser:begin_update() + track(Geyser.Label:new({name = "gcsRootDeferredA"}, box)) + track(Geyser.Label:new({name = "gcsRootDeferredB"}, box)) + Geyser:end_update() + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gcsRootDeferredA")) + assert.are.same({x = 0, y = 100, width = 200, height = 100}, geometry("gcsRootDeferredB")) + end) + + -- delete() defers the box it is emptying, and that borrowed deferral must + -- not end a deferral the caller asked for: the box has to stay held back + -- until end_update, and then still catch up on the layout it skipped + it("leaves a root deferral running when a box loses a child to delete", function() + -- leaving the flag set would stop every later spec repositioning + finally(function() Geyser.defer_updates = false end) + local box = track(Geyser.HBox:new({name = "gcsRootDelete", x = 0, y = 0, width = 400, height = 100})) + track(Geyser.Label:new({name = "gcsRootDeleteKeep"}, box)) + local doomed = track(Geyser.Container:new({name = "gcsRootDeleteGone"}, box)) + Geyser:begin_update() + doomed:delete() + assert.is_true(Geyser.defer_updates) + assert.are.equal(200, geometry("gcsRootDeleteKeep").width) + Geyser:end_update() + assert.are.equal(400, geometry("gcsRootDeleteKeep").width) + end) + + it("repositions every window when Geyser:reposition is called directly", function() + -- Geyser:reposition hands GeyserReposition no event, which is how + -- end_update flushes what was deferred, so it applies to everything + -- a leaked deferral would make this a no-op for a reason of its own + assert.is_false(Geyser.defer_updates) + track(Geyser.Label:new({name = "gcsRepositionDirect", x = 10, y = 10, width = 100, height = 50})) + moveWindow("gcsRepositionDirect", 300, 300) + Geyser:reposition() + assert.are.same({x = 10, y = 10, width = 100, height = 50}, geometry("gcsRepositionDirect")) + end) + + it("lays a box out as its children arrive when updates are not deferred", function() + local box = track(Geyser.VBox:new({name = "gcsUndeferred", x = 0, y = 0, width = 200, height = 200})) + track(Geyser.Label:new({name = "gcsUndeferredA"}, box)) + track(Geyser.Label:new({name = "gcsUndeferredB"}, box)) + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gcsUndeferredA")) + assert.are.same({x = 0, y = 100, width = 200, height = 100}, geometry("gcsUndeferredB")) + end) + end) + + describe("GeyserReposition", function() + -- GeyserReposition works on every top level Geyser object, including any + -- another spec file left behind; that is harmless, as it only restores + -- each object to the geometry its own constraints ask for. + it("restores geometry that was changed behind Geyser's back", function() + local mainWidth, mainHeight = getMainWindowSize() + track(Geyser.Label:new({name = "gcsReposition", x = 10, y = 10, width = 100, height = 50})) + moveWindow("gcsReposition", 400, 400) + resizeWindow("gcsReposition", 20, 20) + assert.are.same({x = 400, y = 400, width = 20, height = 20}, geometry("gcsReposition")) + GeyserReposition("sysWindowResizeEvent", mainWidth, mainHeight) + assert.are.same({x = 10, y = 10, width = 100, height = 50}, geometry("gcsReposition")) + end) + + it("ignores events that are not window resizes", function() + track(Geyser.Label:new({name = "gcsNoReposition", x = 10, y = 10, width = 100, height = 50})) + moveWindow("gcsNoReposition", 300, 300) + GeyserReposition("sysSomeOtherEvent", 100, 100) + assert.are.equal(300, geometry("gcsNoReposition").x) + end) + end) + + describe("Geyser.nameGen", function() + it("hands out a new name every time", function() + local first = Geyser.nameGen() + local second = Geyser.nameGen() + assert.are_not.equal(first, second) + assert.is_truthy(first:find("^anon_window_%d+$")) + end) + + it("uses the type it is given in the name", function() + assert.is_truthy(Geyser.nameGen("gauge"):find("^anon_gauge_%d+$")) + end) + end) + + describe("Geyser.copyTable", function() + it("copies the entries of a table", function() + local source = {name = "x", width = "10px"} + local copy = Geyser.copyTable(source) + assert.are.same(source, copy) + copy.name = "y" + assert.are.equal("x", source.name) + end) + + it("shares nested tables that do not ask to be cloned", function() + local nested = {1, 2} + local copy = Geyser.copyTable({nested = nested}) + assert.are.equal(nested, copy.nested) + end) + + it("clones a nested table that provides __clone", function() + local nested = {__clone = function() return {cloned = true} end} + local copy = Geyser.copyTable({nested = nested}) + assert.are_not.equal(nested, copy.nested) + assert.is_true(copy.nested.cloned) + end) + + it("returns an empty table for nil", function() + assert.are.same({}, Geyser.copyTable(nil)) + end) + end) + + describe("Geyser.hideAll/showAll", function() + -- calling either without a type would sweep every Geyser widget in the + -- profile, including the ones other spec files own, so only the filtered + -- form is exercised here + it("only touches windows of the type it is given", function() + -- a private type keeps the sweep away from widgets other specs own + local mine = track(Geyser.Container:new({name = "gcsSweep", type = "gcsprobe", x = 0, y = 0, width = 50, height = 50})) + track(Geyser.Label:new({name = "gcsSweepChild"}, mine)) + track(Geyser.Label:new({name = "gcsUnswept", x = 0, y = 0, width = 20, height = 20})) + Geyser.hideAll("gcsprobe") + assert.is_false(windowVisible("gcsSweepChild")) + assert.is_true(windowVisible("gcsUnswept")) + Geyser.showAll("gcsprobe") + assert.is_true(windowVisible("gcsSweepChild")) + assert.is_true(windowVisible("gcsUnswept")) + assert.is_false(mine.hidden) + end) + end) + + describe("Geyser reuses a name that is already taken", function() + it("replaces the tracked window without duplicating the ordering entry", function() + local first = track(Geyser.Label:new({name = "gcsDuplicate", x = 0, y = 0, width = 30, height = 30})) + local windowCount = #Geyser.windows + local second = track(Geyser.Label:new({name = "gcsDuplicate", x = 5, y = 5, width = 60, height = 60})) + assert.are.equal(windowCount, #Geyser.windows) + assert.are.equal(second, Geyser.windowList.gcsDuplicate) + assert.are.same({x = 5, y = 5, width = 60, height = 60}, geometry("gcsDuplicate")) + -- both objects drive the same widget, which is why reusing a name is a trap + first:move(11, 12) + assert.are.same({x = 11, y = 12, width = 30, height = 30}, geometry("gcsDuplicate")) + end) + end) +end) + +-- GeyserTests.lua is Geyser's own hand-driven demo set, not a test suite. Every +-- one of these builds a screenful of widgets under a fixed global name and +-- leaves them there for a person to look at and click, so running them here +-- would leak a hundred labels and two globals into every spec file that follows +-- and still assert nothing. They are recorded rather than covered. +describe("Tests Geyser's built-in demos", function() + pending("Geyser.testLabels builds 101 labels for a person to look at - leaves labelTestContainer behind") + + pending("Geyser.testGauges builds 100 gauges for a person to look at - leaves gaugeTestContainer behind") + + pending("Geyser.demo1 builds a demo UI for a person to resize and click - leaves geyserDemoContainer behind") + + pending("demoCallback1 only runs from Geyser.demo1's label, off that demo's own gauges and consoles") + + pending("demoCallback2 only runs from Geyser.demo1's label, and moves that demo's own container") +end) diff --git a/src/mudlet-lua/tests/GeyserGauge_spec.lua b/src/mudlet-lua/tests/GeyserGauge_spec.lua new file mode 100644 index 000000000..d917c0fc3 --- /dev/null +++ b/src/mudlet-lua/tests/GeyserGauge_spec.lua @@ -0,0 +1,559 @@ +-- A gauge is a container holding three labels: back (the full size backdrop), +-- front (the part that shrinks with the value) and text (the caption). Only +-- the front label changes geometry, so that is where setValue is measured. +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.Gauge", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.Gauge:new/new2", function() + it("builds a back, front and text label over the gauge's geometry", function() + local gauge = track(Geyser.Gauge:new({name = "ggsNew", x = 10, y = 20, width = 200, height = 40})) + assert.are.equal("gauge", gauge.type) + assert.are.equal(100, gauge.value) + assert.are.same({"ggsNew_back", "ggsNew_front", "ggsNew_text"}, gauge.windows) + for _, name in ipairs({"ggsNew_back", "ggsNew_front", "ggsNew_text"}) do + assert.are.equal("label", windowType(name)) + assert.are.same({x = 10, y = 20, width = 200, height = 40}, geometry(name)) + end + -- the gauge itself is a container, so it has no widget + assert.is_nil(getWindowGeometry("ggsNew")) + end) + + it("defaults to a horizontal, non strict gauge", function() + local gauge = track(Geyser.Gauge:new({name = "ggsDefaults", x = 0, y = 0, width = 100, height = 20})) + assert.are.equal("horizontal", gauge.orientation) + assert.is_false(gauge.strict) + end) + + it("echoes the message constraint onto the text label", function() + track(Geyser.Gauge:new({name = "ggsMessage", x = 0, y = 0, width = 100, height = 20, message = "50%"})) + assert.is_truthy(getLabelText("ggsMessage_text"):find("50%%")) + end) + + it("new2 marks the gauge as using add2", function() + local gauge = track(Geyser.Gauge:new2({name = "ggsNew2", x = 0, y = 0, width = 100, height = 20})) + assert.is_true(gauge.useAdd2) + assert.are.equal("gauge", gauge.type) + end) + end) + + describe("Geyser.Gauge:setValue", function() + local gauge + + before_each(function() + gauge = track(Geyser.Gauge:new({name = "ggsValue", x = 0, y = 0, width = 200, height = 40})) + end) + + it("sizes the front label to the percentage given", function() + gauge:setValue(25) + assert.are.equal(25, gauge.value) + assert.are.same({x = 0, y = 0, width = 50, height = 40}, geometry("ggsValue_front")) + gauge:setValue(75) + assert.are.equal(150, geometry("ggsValue_front").width) + end) + + it("treats a second argument as the maximum", function() + gauge:setValue(50, 200) + assert.are.equal(25, gauge.value) + assert.are.equal(50, geometry("ggsValue_front").width) + end) + + it("leaves the back label at full size", function() + gauge:setValue(10) + assert.are.same({x = 0, y = 0, width = 200, height = 40}, geometry("ggsValue_back")) + assert.are.same({x = 0, y = 0, width = 200, height = 40}, geometry("ggsValue_text")) + end) + + it("clamps a negative value to empty", function() + gauge:setValue(-20) + assert.are.equal(0, gauge.value) + assert.are.equal(0, geometry("ggsValue_front").width) + end) + + it("lets the front overflow past the back unless the gauge is strict", function() + gauge:setValue(150) + assert.are.equal(150, gauge.value) + assert.are.equal(300, geometry("ggsValue_front").width) + end) + + it("caps a strict gauge at its own width", function() + local strict = track(Geyser.Gauge:new({name = "ggsStrict", x = 0, y = 0, width = 200, height = 40, strict = true})) + strict:setValue(150) + assert.are.equal(100, strict.value) + assert.are.equal(200, geometry("ggsStrict_front").width) + end) + + it("writes the optional third argument onto the text label", function() + gauge:setValue(40, 100, "40 of 100") + assert.is_truthy(getLabelText("ggsValue_text"):find("40 of 100", 1, true)) + end) + + it("rejects a value that is not a number", function() + local ok, message = pcall(function() gauge:setValue("x") end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("currentValue as number expected, got string", 1, true)) + end) + + it("rejects a maximum that is not a number", function() + local ok, message = pcall(function() gauge:setValue(5, "y") end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("maxValue as number expected, got string", 1, true)) + end) + + it("refuses a maximum that is not positive instead of going infinite", function() + gauge:setValue(25) + -- a zero maximum used to leave value at inf, a negative one at a negative + -- value, and both stuck until the next good call + for _, bad in ipairs({0, -10, 0 / 0}) do + local result, message = gauge:setValue(5, bad) + assert.is_nil(result) + assert.is_not_nil(message) + assert.is_truthy(message:find("maxValue must be a positive number", 1, true)) + end + -- refusing is not fatal, and leaves the gauge on the value it already had + assert.are.equal(25, gauge.value) + assert.are.equal(50, geometry("ggsValue_front").width) + -- a good reading has to be distinguishable from those refusals + assert.is_true(gauge:setValue(50, 100)) + end) + end) + + describe("Geyser.Gauge orientations", function() + it("fills a vertical gauge from the bottom", function() + local gauge = track(Geyser.Gauge:new({name = "ggsVertical", x = 0, y = 0, width = 100, height = 200, orientation = "vertical"})) + gauge:setValue(25) + assert.are.same({x = 0, y = 150, width = 100, height = 50}, geometry("ggsVertical_front")) + assert.are.same({x = 0, y = 0, width = 100, height = 200}, geometry("ggsVertical_back")) + end) + + it("fills a goofy gauge from the right", function() + local gauge = track(Geyser.Gauge:new({name = "ggsGoofy", x = 0, y = 0, width = 200, height = 40, orientation = "goofy"})) + gauge:setValue(25) + assert.are.same({x = 150, y = 0, width = 50, height = 40}, geometry("ggsGoofy_front")) + end) + + it("fills a batty gauge from the top", function() + local gauge = track(Geyser.Gauge:new({name = "ggsBatty", x = 0, y = 0, width = 100, height = 200, orientation = "batty"})) + gauge:setValue(25) + assert.are.same({x = 0, y = 0, width = 100, height = 50}, geometry("ggsBatty_front")) + end) + end) + + describe("Geyser.Gauge:setStyleSheet", function() + it("keeps the front label inside the back label's margins", function() + local gauge = track(Geyser.Gauge:new({name = "ggsMargin", x = 0, y = 0, width = 200, height = 40})) + gauge:setStyleSheet("margin: 5px; background-color: red;", "margin: 5px; background-color: blue;") + assert.are.same({x = 0, y = 0, width = 200, height = 40}, geometry("ggsMargin_back")) + assert.are.same({x = 5, y = 5, width = 190, height = 30}, geometry("ggsMargin_front")) + end) + + it("reads a two value margin as vertical then horizontal", function() + local gauge = track(Geyser.Gauge:new({name = "ggsTwoValue", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 10px 30px;", "margin: 10px 30px;") + assert.are.same({x = 30, y = 10, width = 140, height = 80}, geometry("ggsTwoValue_front")) + end) + + it("reads a three value margin as top, horizontal, bottom", function() + local gauge = track(Geyser.Gauge:new({name = "ggsThreeValue", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 10px 20px 30px;", "margin: 10px 20px 30px;") + assert.are.same({x = 20, y = 10, width = 160, height = 60}, geometry("ggsThreeValue_front")) + end) + + it("reads a three value padding the same way as a margin", function() + local gauge = track(Geyser.Gauge:new({name = "ggsThreePadding", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("padding: 4px 6px 8px;", "padding: 4px 6px 8px;") + assert.are.same({x = 6, y = 4, width = 188, height = 88}, geometry("ggsThreePadding_front")) + end) + + -- A unitless zero is the ordinary way to write "no margin on this axis". + -- Counting only the px tokens dropped it, so what is left reads as a + -- shorter shorthand and every component shifts. + it("counts a unitless zero as a margin value", function() + local gauge = track(Geyser.Gauge:new({name = "ggsZero", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 0 10px;", "margin: 0 10px;") + assert.are.same({x = 10, y = 0, width = 180, height = 100}, geometry("ggsZero_front")) + end) + + it("counts a unitless zero in a four value margin", function() + local gauge = track(Geyser.Gauge:new({name = "ggsZeroFour", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 10px 0 10px 0;", "margin: 10px 0 10px 0;") + assert.are.same({x = 0, y = 10, width = 200, height = 80}, geometry("ggsZeroFour_front")) + end) + + it("keeps the sign of a negative margin", function() + local gauge = track(Geyser.Gauge:new({name = "ggsNegative", x = 20, y = 20, width = 200, height = 100})) + gauge:setStyleSheet("margin: -5px;", "margin: -5px;") + assert.are.same({x = 15, y = 15, width = 210, height = 110}, geometry("ggsNegative_front")) + end) + + it("reads a margin longhand", function() + local gauge = track(Geyser.Gauge:new({name = "ggsLonghand", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin-left: 10px;", "margin-left: 10px;") + assert.are.same({x = 10, y = 0, width = 190, height = 100}, geometry("ggsLonghand_front")) + end) + + it("lets a margin longhand override the shorthand it follows", function() + local gauge = track(Geyser.Gauge:new({name = "ggsOverride", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 5px; margin-left: 20px;", "margin: 5px; margin-left: 20px;") + assert.are.same({x = 20, y = 5, width = 175, height = 90}, geometry("ggsOverride_front")) + end) + + it("reads a border-width longhand", function() + local gauge = track(Geyser.Gauge:new({name = "ggsBorderWidth", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("border-width: 2px;", "border-width: 2px;") + assert.are.same({x = 2, y = 2, width = 196, height = 96}, geometry("ggsBorderWidth_front")) + end) + + it("reads an upper case px unit", function() + local gauge = track(Geyser.Gauge:new({name = "ggsUpperCase", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 10PX;", "margin: 10PX;") + assert.are.same({x = 10, y = 10, width = 180, height = 80}, geometry("ggsUpperCase_front")) + end) + + -- em and % cannot be turned into pixels here, so the whole declaration is + -- left alone rather than half of it being read as zero + it("leaves a margin it cannot measure in pixels alone", function() + local gauge = track(Geyser.Gauge:new({name = "ggsEm", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 1em;", "margin: 1em;") + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("ggsEm_front")) + gauge:setStyleSheet("margin: 5% 10px;", "margin: 5% 10px;") + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("ggsEm_front")) + end) + + -- qproperty-margin is a Qt property, not a margin, and used to be picked up + -- by the unanchored property pattern - both when working out the offset and + -- when stripping margins off the front label, where it left "qproperty-" + -- behind and Qt threw the whole sheet out + it("does not read qproperty-margin as a margin", function() + local gauge = track(Geyser.Gauge:new({name = "ggsQProperty", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("qproperty-margin: 5px; color: red;", "qproperty-margin: 5px;") + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("ggsQProperty_front")) + assert.is_truthy(getLabelStyleSheet("ggsQProperty_front"):find("qproperty-margin: 5px;", 1, true)) + end) + + -- Qt takes !important on a declaration; it marks priority and is not one of + -- the box's sides + it("reads a margin that carries !important", function() + local gauge = track(Geyser.Gauge:new({name = "ggsImportant", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 10px !important;", "margin: 10px !important;") + assert.are.same({x = 10, y = 10, width = 180, height = 80}, geometry("ggsImportant_front")) + gauge:setStyleSheet("margin-left: 10px !important;", "margin-left: 10px !important;") + assert.are.same({x = 10, y = 0, width = 190, height = 100}, geometry("ggsImportant_front")) + end) + + -- a declaration written inside a selector block usually carries no + -- semicolon, and the closing brace is neither part of the value nor + -- something the front label's margin strip may swallow + it("reads a margin inside a selector block without wrecking the sheet", function() + local gauge = track(Geyser.Gauge:new({name = "ggsBlock", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("QLabel { background-color: red; margin: 4px }", "QLabel { background-color: blue; margin: 4px }") + assert.are.same({x = 4, y = 4, width = 192, height = 92}, geometry("ggsBlock_front")) + local front = getLabelStyleSheet("ggsBlock_front") + assert.is_nil(front:find("margin", 1, true)) + assert.is_truthy(front:find("background-color: red;", 1, true)) + assert.is_truthy(front:find("}", 1, true)) + end) + + it("does not read a commented out margin, and leaves the comment whole", function() + local gauge = track(Geyser.Gauge:new({name = "ggsComment", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("background-color: red; /* margin: 20px */ padding: 3px;", "background-color: blue; /* margin: 20px */ padding: 3px;") + assert.are.same({x = 3, y = 3, width = 194, height = 94}, geometry("ggsComment_front")) + local front = getLabelStyleSheet("ggsComment_front") + assert.is_truthy(front:find("background-color: red;", 1, true)) + assert.is_nil(front:find("20px", 1, true)) + -- whatever is left of the comment, it must still be closed + assert.are.equal(select(2, front:gsub("/%*", "")), select(2, front:gsub("%*/", ""))) + end) + + it("reads a length written without a leading digit", function() + local gauge = track(Geyser.Gauge:new({name = "ggsLeadingDot", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: .5px;", "margin: .5px;") + -- .5px used to match the "5px" inside it and inset the gauge tenfold; half + -- a pixel each side comes off the size and rounds away on the position + assert.are.same({x = 0, y = 0, width = 199, height = 99}, geometry("ggsLeadingDot_front")) + end) + + it("reads border longhands", function() + local gauge = track(Geyser.Gauge:new({name = "ggsBorderLong", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("border-top: 3px solid red;", "border-top: 3px solid red;") + assert.are.same({x = 0, y = 3, width = 200, height = 97}, geometry("ggsBorderLong_front")) + gauge:setStyleSheet("border-left-width: 4px;", "border-left-width: 4px;") + assert.are.same({x = 4, y = 0, width = 196, height = 100}, geometry("ggsBorderLong_front")) + gauge:setStyleSheet("border-width: 1px 2px 3px 4px;", "border-width: 1px 2px 3px 4px;") + assert.are.same({x = 4, y = 1, width = 194, height = 96}, geometry("ggsBorderLong_front")) + end) + + it("reads a padding longhand", function() + local gauge = track(Geyser.Gauge:new({name = "ggsPaddingLong", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("padding-bottom: 7px;", "padding-bottom: 7px;") + assert.are.same({x = 0, y = 0, width = 200, height = 93}, geometry("ggsPaddingLong_front")) + end) + + -- the offsets reach the front label through a different branch per + -- orientation, and a negative one has to stay negative in each + it("keeps a negative margin in every orientation", function() + local expected = { + vertical = {x = 15, y = 15, width = 210, height = 110}, + goofy = {x = 15, y = 15, width = 210, height = 110}, + batty = {x = 15, y = 15, width = 210, height = 110}, + } + for orientation, geometryWanted in pairs(expected) do + local name = "ggsNegative" .. orientation + local gauge = track(Geyser.Gauge:new({name = name, x = 20, y = 20, width = 200, height = 100, orientation = orientation})) + gauge:setStyleSheet("margin: -5px;", "margin: -5px;") + gauge:setValue(100) + assert.are.same(geometryWanted, geometry(name .. "_front"), orientation .. " gauge") + end + end) + + -- Qt applies a spacing Geyser cannot measure, so the fill bar is laid out + -- against the wrong box: that has to be said rather than left looking like + -- a Geyser bug + it("says so when it cannot measure a spacing in pixels", function() + local debugMessage = spy.on(_G, "debugc") + finally(function() debugMessage:revert() end) + local gauge = track(Geyser.Gauge:new({name = "ggsUnreadable", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 1em;", "margin: 1em;") + assert.spy(debugMessage).was.called() + local said = debugMessage.calls[#debugMessage.calls].vals[1] + assert.is_truthy(said:find("ggsUnreadable", 1, true)) + assert.is_truthy(said:find("margin: 1em", 1, true)) + -- and it is latched, so a gauge updated every prompt does not flood + local saidOnce = #debugMessage.calls + gauge:setValue(50) + gauge:setValue(75) + assert.are.equal(saidOnce, #debugMessage.calls) + end) + + it("says nothing about an ordinary borderless stylesheet", function() + local debugMessage = spy.on(_G, "debugc") + finally(function() debugMessage:revert() end) + local gauge = track(Geyser.Gauge:new({name = "ggsQuiet", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("border: none; background-color: red; margin: 0;", "border: none; background-color: blue; margin: 0;") + assert.spy(debugMessage).was_not.called() + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("ggsQuiet_front")) + end) + + it("reads a four value margin as top, right, bottom, left", function() + local gauge = track(Geyser.Gauge:new({name = "ggsFourValue", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("margin: 1px 2px 3px 4px;", "margin: 1px 2px 3px 4px;") + assert.are.same({x = 4, y = 1, width = 194, height = 96}, geometry("ggsFourValue_front")) + end) + + it("makes room for a border on the back label", function() + local gauge = track(Geyser.Gauge:new({name = "ggsBorder", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("border: 2px solid red;", "border: 2px solid red;") + assert.are.same({x = 2, y = 2, width = 196, height = 96}, geometry("ggsBorder_front")) + end) + + it("makes room for padding on the back label", function() + local gauge = track(Geyser.Gauge:new({name = "ggsPadding", x = 0, y = 0, width = 200, height = 100})) + gauge:setStyleSheet("padding: 3px;", "padding: 3px;") + assert.are.same({x = 3, y = 3, width = 194, height = 94}, geometry("ggsPadding_front")) + end) + + it("adds margin, border and padding together", function() + local gauge = track(Geyser.Gauge:new({name = "ggsCombined", x = 0, y = 0, width = 200, height = 100})) + local css = "margin: 2px; border: 1px solid red; padding: 3px;" + gauge:setStyleSheet(css, css) + assert.are.same({x = 6, y = 6, width = 188, height = 88}, geometry("ggsCombined_front")) + end) + + it("strips the margin from the front stylesheet but not the back", function() + local gauge = track(Geyser.Gauge:new({name = "ggsCss", x = 0, y = 0, width = 200, height = 40})) + gauge:setStyleSheet("margin: 5px; background-color: red;", "margin: 5px; background-color: blue;") + assert.is_nil(getLabelStyleSheet("ggsCss_front"):find("margin", 1, true)) + assert.is_truthy(getLabelStyleSheet("ggsCss_front"):find("background-color: red;", 1, true)) + assert.is_truthy(getLabelStyleSheet("ggsCss_back"):find("margin: 5px;", 1, true)) + assert.are.equal("margin: 5px; background-color: blue;", gauge.backCSS) + end) + + -- the last declaration in a stylesheet carries no semicolon, and a margin + -- left on the front label is applied on top of the offset computed from the + -- back label, doubling it + it("strips a margin that carries no trailing semicolon", function() + local gauge = track(Geyser.Gauge:new({name = "ggsNoSemicolon", x = 0, y = 0, width = 200, height = 40})) + gauge:setStyleSheet("background-color: red; margin: 5px", "margin: 5px;") + assert.is_nil(getLabelStyleSheet("ggsNoSemicolon_front"):find("margin", 1, true)) + assert.are.same({x = 5, y = 5, width = 190, height = 30}, geometry("ggsNoSemicolon_front")) + end) + + it("uses the front stylesheet for the back when only one is given", function() + local gauge = track(Geyser.Gauge:new({name = "ggsOneCss", x = 0, y = 0, width = 100, height = 20})) + gauge:setStyleSheet("background-color: green;") + assert.are.equal("background-color: green;", gauge.backCSS) + assert.are.equal("background-color: green;", getLabelStyleSheet("ggsOneCss_back")) + end) + + it("applies a text stylesheet when one is given", function() + local gauge = track(Geyser.Gauge:new({name = "ggsTextCss", x = 0, y = 0, width = 100, height = 20})) + gauge:setStyleSheet("background-color: green;", nil, "color: white;") + assert.are.equal("color: white;", getLabelStyleSheet("ggsTextCss_text")) + end) + + it("keeps the current value when the stylesheet changes", function() + local gauge = track(Geyser.Gauge:new({name = "ggsCssValue", x = 0, y = 0, width = 200, height = 40})) + gauge:setValue(50) + gauge:setStyleSheet("background-color: red;") + assert.are.equal(50, gauge.value) + assert.are.equal(100, geometry("ggsCssValue_front").width) + end) + end) + + describe("Geyser.Gauge text", function() + local gauge + + before_each(function() + gauge = track(Geyser.Gauge:new({name = "ggsText", x = 0, y = 0, width = 200, height = 40})) + end) + + it("writes setText onto the text label", function() + gauge:setText("hello gauge") + assert.is_truthy(getLabelText("ggsText_text"):find("hello gauge", 1, true)) + end) + + it("echoes with a colour", function() + gauge:echo("colored", "red") + local text = getLabelText("ggsText_text") + assert.is_truthy(text:find("colored", 1, true)) + assert.is_truthy(text:find("color: #ff0000", 1, true)) + end) + + it("mirrors the text label's format state back onto the gauge", function() + gauge:setBold(true) + gauge:setItalics(true) + gauge:setUnderline(true) + gauge:setStrikethrough(true) + gauge:setText("styled") + local text = getLabelText("ggsText_text") + assert.is_truthy(text:find("<b>", 1, true)) + assert.is_truthy(text:find("<i>", 1, true)) + assert.is_truthy(text:find("<u>", 1, true)) + assert.is_truthy(text:find("<s>", 1, true)) + assert.are.equal("8bius", gauge.format) + assert.is_true(gauge.formatTable.bold) + assert.is_true(gauge.formatTable.strikethrough) + end) + + it("sets the font size of the text label", function() + gauge:setFontSize(18) + gauge:setText("bigger") + assert.is_truthy(getLabelText("ggsText_text"):find("font%-size: 18pt")) + end) + + it("aligns the text label", function() + gauge:setAlignment("center") + gauge:setText("middle") + assert.is_truthy(getLabelText("ggsText_text"):find('align="center"', 1, true)) + end) + + it("sets the text colour", function() + gauge:setFgColor("#00ff00") + gauge:setText("green") + assert.is_truthy(getLabelText("ggsText_text"):find("color: #00ff00", 1, true)) + end) + end) + + describe("Geyser.Gauge geometry and visibility", function() + it("moves and resizes all three labels", function() + local gauge = track(Geyser.Gauge:new({name = "ggsMove", x = 0, y = 0, width = 200, height = 40})) + gauge:setValue(50) + gauge:move(60, 70) + gauge:resize(100, 20) + assert.are.same({x = 60, y = 70, width = 100, height = 20}, geometry("ggsMove_back")) + assert.are.same({x = 60, y = 70, width = 100, height = 20}, geometry("ggsMove_text")) + -- the front label keeps its share of the new size + assert.are.same({x = 60, y = 70, width = 50, height = 20}, geometry("ggsMove_front")) + end) + + it("hides and shows all three labels", function() + local gauge = track(Geyser.Gauge:new({name = "ggsHide", x = 0, y = 0, width = 100, height = 20})) + gauge:hide() + for _, name in ipairs({"ggsHide_back", "ggsHide_front", "ggsHide_text"}) do + assert.is_false(windowVisible(name)) + end + gauge:show() + for _, name in ipairs({"ggsHide_back", "ggsHide_front", "ggsHide_text"}) do + assert.is_true(windowVisible(name)) + end + end) + + it("sizes a percentage gauge against its container", function() + local container = track(Geyser.Container:new({name = "ggsBox", x = 100, y = 100, width = 400, height = 100})) + local gauge = track(Geyser.Gauge:new({name = "ggsInBox", x = 0, y = 0, width = "50%", height = "100%"}, container)) + gauge:setValue(50) + assert.are.same({x = 100, y = 100, width = 200, height = 100}, geometry("ggsInBox_back")) + assert.are.same({x = 100, y = 100, width = 100, height = 100}, geometry("ggsInBox_front")) + end) + end) + + describe("Geyser.Gauge:type_delete", function() + it("deletes the back, front and text labels with the gauge", function() + local gauge = track(Geyser.Gauge:new({name = "ggsDelete", x = 0, y = 0, width = 100, height = 20})) + gauge:delete() + for _, name in ipairs({"ggsDelete_back", "ggsDelete_front", "ggsDelete_text"}) do + assert.is_nil(getWindowGeometry(name)) + end + assert.is_nil(Geyser.windowList.ggsDelete) + end) + end) + + describe("Geyser.Gauge clickthrough and tooltip", function() + it("enableClickthrough and disableClickthrough reach all three labels", function() + local gauge = track(Geyser.Gauge:new({name = "ggsClick", x = 0, y = 0, width = 100, height = 20})) + -- there is no getter for the clickthrough flag, so the delegation to the + -- three labels is what can be checked + local enable = spy.on(_G, "enableClickthrough") + finally(function() enable:revert() end) + gauge:enableClickthrough() + assert.spy(enable).was.called(3) + assert.spy(enable).was.called_with("ggsClick_front") + assert.spy(enable).was.called_with("ggsClick_back") + assert.spy(enable).was.called_with("ggsClick_text") + + local disable = spy.on(_G, "disableClickthrough") + finally(function() disable:revert() end) + gauge:disableClickthrough() + assert.spy(disable).was.called(3) + assert.spy(disable).was.called_with("ggsClick_text") + end) + + it("setToolTip and resetToolTip go to the text label and are remembered", function() + local gauge = track(Geyser.Gauge:new({name = "ggsToolTip", x = 0, y = 0, width = 100, height = 20})) + gauge:setToolTip("how much is left", 5) + assert.are.equal("how much is left", gauge.text.toolTip) + assert.are.equal(5, gauge.text.toolTipDuration) + gauge:resetToolTip() + assert.is_nil(gauge.text.toolTip) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserHBox_spec.lua b/src/mudlet-lua/tests/GeyserHBox_spec.lua new file mode 100644 index 000000000..d0d2265f6 --- /dev/null +++ b/src/mudlet-lua/tests/GeyserHBox_spec.lua @@ -0,0 +1,300 @@ +-- An HBox lays its children out left to right by rewriting their constraints +-- as percentages of the box, so the pixel expectations below are the box +-- geometry divided by the shares each child is entitled to. +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.HBox", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.HBox:new/new2", function() + it("defaults the type to hbox and starts empty", function() + local box = track(Geyser.HBox:new({name = "ghbNew", x = 0, y = 0, width = 100, height = 100})) + assert.are.equal("hbox", box.type) + assert.are.same({}, box.windows) + assert.is_nil(getWindowGeometry("ghbNew")) + end) + + it("new2 marks the box as using add2", function() + local box = track(Geyser.HBox:new2({name = "ghbNew2", x = 0, y = 0, width = 100, height = 100})) + assert.is_true(box.useAdd2) + assert.are.equal("hbox", box.type) + end) + + it("lines the children of a new2 box up the same way new does", function() + local box = track(Geyser.HBox:new2({name = "ghbNew2Layout", x = 0, y = 0, width = 200, height = 100})) + -- the children arrive through add2 rather than add + track(Geyser.Label:new2({name = "ghbNew2A", x = 10, y = 10, width = 300, height = 200}, box)) + track(Geyser.Label:new2({name = "ghbNew2B", x = 10, y = 10, width = 300, height = 200}, box)) + assert.are.same({x = 0, y = 0, width = 100, height = 100}, geometry("ghbNew2A")) + assert.are.same({x = 100, y = 0, width = 100, height = 100}, geometry("ghbNew2B")) + end) + end) + + describe("Geyser.HBox:add/organize", function() + it("gives a single child the whole box", function() + local box = track(Geyser.HBox:new({name = "ghbOne", x = 10, y = 20, width = 200, height = 100})) + track(Geyser.Label:new({name = "ghbOneChild"}, box)) + assert.are.same({x = 10, y = 20, width = 200, height = 100}, geometry("ghbOneChild")) + end) + + it("splits the box evenly between two children", function() + local box = track(Geyser.HBox:new({name = "ghbTwo", x = 0, y = 300, width = 200, height = 100})) + track(Geyser.Label:new({name = "ghbTwoA"}, box)) + track(Geyser.Label:new({name = "ghbTwoB"}, box)) + assert.are.same({x = 0, y = 300, width = 100, height = 100}, geometry("ghbTwoA")) + assert.are.same({x = 100, y = 300, width = 100, height = 100}, geometry("ghbTwoB")) + end) + + it("re-splits the box when another child is added", function() + local box = track(Geyser.HBox:new({name = "ghbFour", x = 0, y = 0, width = 400, height = 40})) + track(Geyser.Label:new({name = "ghbFourA"}, box)) + track(Geyser.Label:new({name = "ghbFourB"}, box)) + assert.are.equal(200, geometry("ghbFourA").width) + track(Geyser.Label:new({name = "ghbFourC"}, box)) + track(Geyser.Label:new({name = "ghbFourD"}, box)) + for index, name in ipairs({"ghbFourA", "ghbFourB", "ghbFourC", "ghbFourD"}) do + assert.are.same({x = (index - 1) * 100, y = 0, width = 100, height = 40}, geometry(name)) + end + end) + + it("stretches children over the full height of the box", function() + local box = track(Geyser.HBox:new({name = "ghbTall", x = 0, y = 0, width = 200, height = 120})) + track(Geyser.Label:new({name = "ghbTallChild", height = 20}, box)) + assert.are.equal("100%", box.windowList.ghbTallChild.height) + assert.are.equal(120, geometry("ghbTallChild").height) + end) + + it("keeps a fixed width child at its size and gives the rest away", function() + local box = track(Geyser.HBox:new({name = "ghbFixed", x = 0, y = 0, width = 300, height = 60})) + track(Geyser.Label:new({name = "ghbFixedChild", width = 100, h_policy = Geyser.Fixed}, box)) + track(Geyser.Label:new({name = "ghbDynamic"}, box)) + assert.is_true(box.contains_fixed) + assert.are.same({x = 0, y = 0, width = 100, height = 60}, geometry("ghbFixedChild")) + local dynamic = geometry("ghbDynamic") + assert.are.equal(200, dynamic.width) + -- the dynamic child should start at 100, where the fixed one ends, but + -- organize() hands out positions as percentages: a third of 300px comes + -- back as 99.999999999999 and Mudlet truncates it, leaving a one pixel + -- gap. Pinned so the day the layout is fixed this spec says so. + assert.are.equal(99, dynamic.x) + end) + + it("gives a stretch factor its extra share of the width", function() + local box = track(Geyser.HBox:new({name = "ghbStretch", x = 0, y = 0, width = 400, height = 100})) + track(Geyser.Label:new({name = "ghbStretchA", h_stretch_factor = 3}, box)) + track(Geyser.Label:new({name = "ghbStretchB"}, box)) + assert.are.same({x = 0, y = 0, width = 300, height = 100}, geometry("ghbStretchA")) + assert.are.same({x = 300, y = 0, width = 100, height = 100}, geometry("ghbStretchB")) + end) + end) + + -- The box lays itself out when a child arrives, and has to do the same when + -- one leaves: without it the survivors keep the geometry computed for the old + -- child count and the box is left with a permanent hole. contains_fixed is + -- false for a box of plain labels, so reposition() does not heal it either. + describe("Geyser.HBox:remove", function() + local box + + before_each(function() + box = track(Geyser.HBox:new({name = "ghbShrink", x = 0, y = 0, width = 600, height = 50})) + track(Geyser.Label:new({name = "ghbShrinkA"}, box)) + track(Geyser.Label:new({name = "ghbShrinkB"}, box)) + end) + + it("re-splits the row when a child is deleted", function() + local third = track(Geyser.Label:new({name = "ghbShrinkC"}, box)) + third:delete() + assert.are.same({"ghbShrinkA", "ghbShrinkB"}, box.windows) + assert.are.same({x = 0, y = 0, width = 300, height = 50}, geometry("ghbShrinkA")) + assert.are.same({x = 300, y = 0, width = 300, height = 50}, geometry("ghbShrinkB")) + end) + + it("re-splits the row when a child is removed by hand", function() + box:remove(box.windowList.ghbShrinkB) + assert.are.same({"ghbShrinkA"}, box.windows) + assert.are.same({x = 0, y = 0, width = 600, height = 50}, geometry("ghbShrinkA")) + end) + + it("re-splits the row a child left for another container", function() + local elsewhere = track(Geyser.Container:new({name = "ghbElsewhere", x = 0, y = 100, width = 100, height = 100})) + box.windowList.ghbShrinkB:changeContainer(elsewhere) + assert.are.same({"ghbShrinkA"}, box.windows) + assert.are.same({x = 0, y = 0, width = 600, height = 50}, geometry("ghbShrinkA")) + end) + + -- an emptied box has no children to divide its width between, and organize() + -- still has to come through that without raising + it("survives losing its last child", function() + assert.has_no.errors(function() + box:remove(box.windowList.ghbShrinkA) + box:remove(box.windowList.ghbShrinkB) + end) + assert.are.same({}, box.windows) + end) + + it("holds the layout back while updates are deferred", function() + local third = track(Geyser.Label:new({name = "ghbShrinkDeferred"}, box)) + local widthOfThree = geometry("ghbShrinkA").width + box.defer_updates = true + third:delete() + assert.are.equal(widthOfThree, geometry("ghbShrinkA").width) + box.defer_updates = false + box:reposition() + assert.are.equal(300, geometry("ghbShrinkA").width) + end) + + it("deletes a box that still holds children", function() + assert.has_no.errors(function() box:delete() end) + assert.is_nil(getWindowGeometry("ghbShrinkA")) + assert.is_nil(getWindowGeometry("ghbShrinkB")) + assert.is_nil(Geyser.windowList.ghbShrink) + end) + + -- one layout pass per child is what makes tearing a box down quadratic. The + -- fixed child is here because contains_fixed short circuits reposition()'s + -- check of the deferral, which could let the cost back in for boxes like it + it("does not lay the row out again for each child it deletes", function() + track(Geyser.Label:new({name = "ghbShrinkCostFixed", width = 100, h_policy = Geyser.Fixed}, box)) + for i = 1, 3 do + track(Geyser.Label:new({name = "ghbShrinkCost" .. i}, box)) + end + local organizes = 0 + local organize = box.organize + box.organize = function(...) + organizes = organizes + 1 + return organize(...) + end + box:delete() + assert.are.equal(0, organizes) + assert.is_nil(getWindowGeometry("ghbShrinkA")) + assert.is_nil(getWindowGeometry("ghbShrinkCost1")) + end) + + -- the deferral belongs to the container being deleted, so a box losing a + -- whole subtree - one that defers itself on the way out - still re-splits + it("re-splits the row when a nested box of its own is deleted", function() + local nested = track(Geyser.VBox:new({name = "ghbShrinkNested"}, box)) + track(Geyser.Label:new({name = "ghbShrinkNestedA"}, nested)) + track(Geyser.Label:new({name = "ghbShrinkNestedB"}, nested)) + nested:delete() + assert.are.same({"ghbShrinkA", "ghbShrinkB"}, box.windows) + assert.are.same({x = 0, y = 0, width = 300, height = 50}, geometry("ghbShrinkA")) + assert.are.same({x = 300, y = 0, width = 300, height = 50}, geometry("ghbShrinkB")) + end) + + -- a cascade that raises leaves the box in the tree, so a box left holding + -- the cascade's deferral would silently never lay itself out again + it("stops deferring the row when a child's delete raises", function() + local doomed = box.windowList.ghbShrinkB + local ownDelete = rawget(doomed, "delete") + -- put the real delete back before after_each tries to clean the box up + finally(function() doomed.delete = ownDelete end) + doomed.delete = function() error("delete blew up") end + assert.has_error(function() box:delete() end) + assert.is_nil(rawget(box, "defer_updates")) + local organizes = 0 + local organize = box.organize + box.organize = function(...) + organizes = organizes + 1 + return organize(...) + end + track(Geyser.Label:new({name = "ghbShrinkAfterRaise"}, box)) + assert.is_true(organizes > 0) + end) + + -- the children the cascade did get through are gone, so the survivors are + -- left holding the widths worked out for the child count it started with + it("re-splits the row a half finished delete left behind", function() + local ownRemove = rawget(box, "remove") + -- restored so that after_each can still tear the box down + finally(function() box.remove = ownRemove end) + -- raising on the second unlink is what puts one child through and strands + -- the other, whichever order the cascade happens to walk them in + local removals = 0 + local remove = box.remove + box.remove = function(...) + removals = removals + 1 + if removals == 2 then + error("remove blew up") + end + return remove(...) + end + assert.has_error(function() box:delete() end) + assert.are.equal(1, #box.windows) + assert.are.same({x = 0, y = 0, width = 600, height = 50}, geometry(box.windows[1])) + end) + end) + + describe("Geyser.HBox:reposition", function() + local box + + before_each(function() + box = track(Geyser.HBox:new({name = "ghbMove", x = 10, y = 20, width = 200, height = 100})) + track(Geyser.Label:new({name = "ghbMoveA"}, box)) + track(Geyser.Label:new({name = "ghbMoveB"}, box)) + end) + + it("drags the row along when the box moves", function() + box:move(50, 60) + assert.are.same({x = 50, y = 60, width = 100, height = 100}, geometry("ghbMoveA")) + assert.are.same({x = 150, y = 60, width = 100, height = 100}, geometry("ghbMoveB")) + end) + + it("re-splits the row when the box is resized", function() + box:resize(100, 50) + assert.are.same({x = 10, y = 20, width = 50, height = 50}, geometry("ghbMoveA")) + assert.are.same({x = 60, y = 20, width = 50, height = 50}, geometry("ghbMoveB")) + end) + + it("keeps a fixed child flush against its neighbour after a resize", function() + local fixedBox = track(Geyser.HBox:new({name = "ghbFixedMove", x = 0, y = 0, width = 200, height = 60})) + track(Geyser.Label:new({name = "ghbFixedMoveA", width = 50, h_policy = Geyser.Fixed}, fixedBox)) + track(Geyser.Label:new({name = "ghbFixedMoveB"}, fixedBox)) + fixedBox:resize(250, 60) + assert.are.same({x = 0, y = 0, width = 50, height = 60}, geometry("ghbFixedMoveA")) + assert.are.same({x = 50, y = 0, width = 200, height = 60}, geometry("ghbFixedMoveB")) + end) + end) + + describe("Geyser.HBox visibility", function() + it("hides and shows the whole row", function() + local box = track(Geyser.HBox:new({name = "ghbHide", x = 0, y = 0, width = 100, height = 100})) + track(Geyser.Label:new({name = "ghbHideA"}, box)) + track(Geyser.Label:new({name = "ghbHideB"}, box)) + box:hide() + assert.is_false(windowVisible("ghbHideA")) + assert.is_false(windowVisible("ghbHideB")) + box:show() + assert.is_true(windowVisible("ghbHideA")) + assert.is_true(windowVisible("ghbHideB")) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserLabel_spec.lua b/src/mudlet-lua/tests/GeyserLabel_spec.lua index d2e10ee3c..0efa628ec 100644 --- a/src/mudlet-lua/tests/GeyserLabel_spec.lua +++ b/src/mudlet-lua/tests/GeyserLabel_spec.lua @@ -106,3 +106,991 @@ describe("Tests functionality of Geyser.Label", function() end) end) end) + +-- Geometry, visibility and text readback for Geyser.Label, asserted against +-- the widget itself through getWindowGeometry/windowVisible/getLabelText +-- rather than by spying on the echo call. +describe("Tests functionality of Geyser.Label widget state", function() + local created + + local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} + end + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.Label:new/new2", function() + it("creates a visible label widget at the constrained geometry", function() + local label = track(Geyser.Label:new({name = "glsNew", x = 15, y = 25, width = 120, height = 40})) + assert.are.equal("label", label.type) + assert.are.equal("label", windowType("glsNew")) + assert.are.same({x = 15, y = 25, width = 120, height = 40}, geometry("glsNew")) + assert.is_true(windowVisible("glsNew")) + assert.are.equal("main", label.windowname) + end) + + it("resolves percentages against its container", function() + local container = track(Geyser.Container:new({name = "glsBox", x = 100, y = 50, width = 400, height = 200})) + track(Geyser.Label:new({name = "glsInBox", x = "25%", y = "50%", width = "50%", height = "25%"}, container)) + assert.are.same({x = 200, y = 150, width = 200, height = 50}, geometry("glsInBox")) + end) + + it("new2 marks the label as using add2", function() + local label = track(Geyser.Label:new2({name = "glsNew2", x = 0, y = 0, width = 40, height = 20})) + assert.is_true(label.useAdd2) + assert.are.equal("label", windowType("glsNew2")) + end) + end) + + describe("Geyser.Label geometry and visibility", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glsMove", x = 10, y = 20, width = 100, height = 50})) + end) + + it("moves and resizes the widget", function() + label:move(70, 80) + label:resize(90, 30) + assert.are.same({x = 70, y = 80, width = 90, height = 30}, geometry("glsMove")) + end) + + it("hides and shows the widget", function() + label:hide() + assert.is_false(windowVisible("glsMove")) + assert.is_true(label.hidden) + label:show() + assert.is_true(windowVisible("glsMove")) + assert.is_false(label.hidden) + end) + end) + + describe("Geyser.Label:echo/rawEcho/decho/hecho/cecho and Geyser.Label:clear", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glsText", x = 0, y = 0, width = 200, height = 50})) + end) + + it("wraps the message in a styled div", function() + label:echo("hello label") + local text = getLabelText("glsText") + assert.is_truthy(text:find("hello label", 1, true)) + assert.is_truthy(text:find("font%-size: 8pt")) + assert.are.equal("hello label", label.message) + end) + + it("reuses the last message when echoed with no arguments", function() + label:echo("sticky") + label:echo() + assert.is_truthy(getLabelText("glsText"):find("sticky", 1, true)) + end) + + it("colours the text with the colour it is given", function() + label:echo("red text", "red") + assert.is_truthy(getLabelText("glsText"):find("color: #ff0000", 1, true)) + assert.are.equal("red", label.fgColor) + end) + + it("leaves the colour to the stylesheet when told nocolor", function() + label:echo("plain", "nocolor") + assert.is_nil(getLabelText("glsText"):find("color: #", 1, true)) + end) + + it("applies a format string given to echo", function() + label:echo("formatted", nil, "cb14") + local text = getLabelText("glsText") + assert.is_truthy(text:find('align="center"', 1, true)) + assert.is_truthy(text:find("<b>formatted</b>", 1, true)) + assert.is_truthy(text:find("font%-size: 14pt")) + end) + + it("rawEcho writes the markup through untouched", function() + label:rawEcho("<b>raw</b>") + assert.are.equal("<b>raw</b>", getLabelText("glsText")) + end) + + it("clear empties the label", function() + label:echo("something") + label:clear() + assert.are.equal("", getLabelText("glsText")) + assert.are.equal("", label.message) + end) + + it("decho, hecho and cecho put their colours into the markup", function() + label:decho("<0,0,255>blue") + local blue = getLabelText("glsText") + assert.is_truthy(blue:find("blue", 1, true)) + assert.is_truthy(blue:find("color: rgb(0, 0, 255)", 1, true)) + label:hecho("|cff0000red") + local red = getLabelText("glsText") + assert.is_truthy(red:find("red", 1, true)) + assert.is_truthy(red:find("color: rgb(255, 0, 0)", 1, true)) + label:cecho("<green>green") + local green = getLabelText("glsText") + assert.is_truthy(green:find("green", 1, true)) + assert.is_truthy(green:find("color: rgb(0, 255, 0)", 1, true)) + end) + end) + + describe("Geyser.Label format setters", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glsFormat", x = 0, y = 0, width = 200, height = 50})) + label:echo("styled") + end) + + it("turns bold, italics, underline and strikethrough into markup", function() + label:setBold(true) + assert.is_truthy(getLabelText("glsFormat"):find("<b>styled</b>", 1, true)) + label:setItalics(true) + label:setUnderline(true) + label:setStrikethrough(true) + local text = getLabelText("glsFormat") + assert.is_truthy(text:find("<i>", 1, true)) + assert.is_truthy(text:find("<u>", 1, true)) + assert.is_truthy(text:find("<s>", 1, true)) + -- the font size was put into the format string first, the flags append + assert.are.equal("8bius", label.format) + end) + + it("takes the markup away again", function() + label:setBold(true) + label:setBold(false) + assert.is_nil(getLabelText("glsFormat"):find("<b>", 1, true)) + assert.is_nil(label.format:find("b")) + end) + + it("sets the font size in the markup", function() + label:setFontSize(20) + assert.is_truthy(getLabelText("glsFormat"):find("font%-size: 20pt")) + assert.are.equal(20, label.fontSize) + end) + + it("sets the alignment in the markup", function() + label:setAlignment("center") + assert.is_truthy(getLabelText("glsFormat"):find('align="center"', 1, true)) + label:setAlignment("right") + assert.is_truthy(getLabelText("glsFormat"):find('align="right"', 1, true)) + label:setAlignment("") + assert.is_nil(getLabelText("glsFormat"):find("align=", 1, true)) + end) + + it("sets the text colour", function() + label:setFgColor("#00ff00") + assert.is_truthy(getLabelText("glsFormat"):find("color: #00ff00", 1, true)) + end) + + it("setFormat replaces the whole format at once", function() + label:setFormat("ci18") + local text = getLabelText("glsFormat") + assert.is_truthy(text:find('align="center"', 1, true)) + assert.is_truthy(text:find("<i>styled</i>", 1, true)) + assert.is_truthy(text:find("font%-size: 18pt")) + assert.is_nil(text:find("<b>", 1, true)) + end) + + it("processFormatString fills in the format table", function() + label:processFormatString("bu12") + assert.are.equal(true, label.formatTable.bold) + assert.are.equal(true, label.formatTable.underline) + assert.are.equal(false, label.formatTable.italics) + assert.are.equal("12", label.formatTable.fontSize) + assert.are.equal("", label.formatTable.alignment) + end) + + it("keeps the label's own font size when the format string has no number", function() + label:setFontSize(11) + label:processFormatString("b") + assert.are.equal(11, label.formatTable.fontSize) + assert.are.equal("b11", label.format) + end) + + it("rejects an alignment it does not know", function() + local ok, message = pcall(function() label:setAlignment("nonsense") end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("invalid alignment sent", 1, true)) + end) + + it("rejects a font size that is not a number", function() + local ok, message = pcall(function() label:setFontSize("big") end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("fontSize as number expected, got string", 1, true)) + end) + + it("rejects a format that is not a string", function() + local ok, message = pcall(function() label:processFormatString(42) end) + assert.is_false(ok) + assert.is_truthy(tostring(message):find("format as string expected, got number", 1, true)) + end) + end) + + describe("Geyser.Label:getSizeHint and Geyser.Label auto-size adjustSize/adjustHeight/adjustWidth/autoAdjustSize/enableAutoAdjustSize/disableAutoAdjustSize", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glsHint", x = 0, y = 0, width = 400, height = 200})) + label:echo("some text in a label") + end) + + it("reports a size hint big enough for the content", function() + local width, height = label:getSizeHint() + assert.is_true(width > 0) + assert.is_true(height > 0) + -- the hint comes from the font metrics, so it is only bounded loosely + assert.is_true(width < 400, "size hint width was " .. tostring(width)) + end) + + it("adjustSize resizes the widget to the hint", function() + local width, height = label:getSizeHint() + label:adjustSize() + assert.are.same({x = 0, y = 0, width = width, height = height}, geometry("glsHint")) + end) + + it("adjustWidth only touches the width", function() + local width = label:getSizeHint() + label:adjustWidth() + assert.are.same({x = 0, y = 0, width = width, height = 200}, geometry("glsHint")) + end) + + it("adjustHeight only touches the height", function() + local _, height = label:getSizeHint() + label:adjustHeight() + assert.are.same({x = 0, y = 0, width = 400, height = height}, geometry("glsHint")) + end) + + it("enableAutoAdjustSize makes every echo fit the content", function() + assert.is_true(label:enableAutoAdjustSize()) + label:echo("tiny") + local width, height = label:getSizeHint() + assert.are.same({x = 0, y = 0, width = width, height = height}, geometry("glsHint")) + end) + + it("enableAutoAdjustSize can be limited to one dimension", function() + label:enableAutoAdjustSize(false) + assert.is_false(label.autoWidth) + assert.is_true(label.autoHeight) + label:echo("tiny") + local _, height = label:getSizeHint() + assert.are.same({x = 0, y = 0, width = 400, height = height}, geometry("glsHint")) + end) + + it("disableAutoAdjustSize leaves the size alone again", function() + label:enableAutoAdjustSize() + label:echo("tiny") + assert.is_true(label:disableAutoAdjustSize()) + label:resize(400, 200) + label:echo("a much longer piece of text than before") + assert.are.same({x = 0, y = 0, width = 400, height = 200}, geometry("glsHint")) + end) + end) + + describe("Geyser.Label:setStyleSheet and Geyser.Label:getFormat", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glsStyle", x = 0, y = 0, width = 100, height = 50})) + end) + + it("round-trips a stylesheet through Mudlet", function() + label:setStyleSheet("background-color: red; border: 1px solid white;") + assert.are.equal("background-color: red; border: 1px solid white;", getLabelStyleSheet("glsStyle")) + assert.are.equal("background-color: red; border: 1px solid white;", label.stylesheet) + end) + + it("reuses the stored stylesheet when called with no argument", function() + label.stylesheet = "background-color: blue;" + label:setStyleSheet() + assert.are.equal("background-color: blue;", getLabelStyleSheet("glsStyle")) + end) + + it("setTiledBackgroundImage puts the image into the stylesheet", function() + label:setTiledBackgroundImage("/tmp/nosuchimage.png") + assert.are.equal("background-image: url(/tmp/nosuchimage.png);", getLabelStyleSheet("glsStyle")) + end) + + it("getFormat reports the label's format defaults", function() + local format = label:getFormat() + assert.are.equal("table", type(format)) + assert.is_false(format.bold) + assert.is_false(format.italic) + assert.are.equal("table", type(format.foreground)) + end) + end) + + describe("Geyser.Label:type_delete", function() + it("deletes the widget with the object", function() + local label = track(Geyser.Label:new({name = "glsDelete", x = 0, y = 0, width = 40, height = 20})) + assert.is_not_nil(getWindowGeometry("glsDelete")) + label:delete() + assert.is_nil(getWindowGeometry("glsDelete")) + assert.is_nil(Geyser.windowList.glsDelete) + end) + + it("clears the nested label bookkeeping", function() + local label = track(Geyser.Label:new({name = "glsNested", x = 0, y = 0, width = 40, height = 20})) + label.nestedLabels = {"something"} + label:delete() + assert.are.same({}, label.nestedLabels) + end) + end) + + describe("Geyser.Label clickthrough and cursor", function() + it("enableClickthrough and disableClickthrough track the flag on the object", function() + local label = track(Geyser.Label:new({name = "glsClick", x = 0, y = 0, width = 40, height = 20})) + label:enableClickthrough() + assert.is_true(label.clickthrough) + label:disableClickthrough() + assert.is_false(label.clickthrough) + end) + + it("setCursor stores the shape as a name whichever form it was given", function() + local label = track(Geyser.Label:new({name = "glsCursor", x = 0, y = 0, width = 40, height = 20})) + label:setCursor("OpenHand") + assert.are.equal("OpenHand", label.cursorShape) + label:setCursor(mudlet.cursor.ClosedHand) + assert.are.equal("ClosedHand", label.cursorShape) + end) + + it("resetCursor puts the shape back to the default", function() + local label = track(Geyser.Label:new({name = "glsResetCursor", x = 0, y = 0, width = 40, height = 20})) + label:setCursor("OpenHand") + label:setCustomCursor(":/icons/mudlet.png") + label:resetCursor() + assert.are.equal(0, label.cursorShape) + assert.are.equal("", label.customCursor) + end) + + it("setCustomCursor passes the image and hotspot on and remembers it", function() + -- there is no getter for a label cursor, so spy on the global to see + -- the hotspot defaults the wrapper fills in; spy.on keeps the real call + local customCursor = spy.on(_G, "setLabelCustomCursor") + finally(function() customCursor:revert() end) + local label = track(Geyser.Label:new({name = "glsCustomCursor", x = 0, y = 0, width = 40, height = 20})) + label:setCustomCursor(":/icons/mudlet.png", 1, 2) + assert.spy(customCursor).was.called_with("glsCustomCursor", ":/icons/mudlet.png", 1, 2) + assert.are.equal(":/icons/mudlet.png", label.customCursor) + label:setCustomCursor(":/icons/mudlet.png") + assert.spy(customCursor).was.called_with("glsCustomCursor", ":/icons/mudlet.png", -1, -1) + end) + end) + + describe("Geyser.Label:setBackgroundImage", function() + it("puts the image on the label without touching its stylesheet", function() + -- there is no getter for a label's background image, so spy on the + -- global; the stylesheet assertion is what separates this from the + -- tiled variant below, which works through the stylesheet instead + local background = spy.on(_G, "setBackgroundImage") + finally(function() background:revert() end) + local label = track(Geyser.Label:new({name = "glsBackground", x = 0, y = 0, width = 40, height = 20})) + label:setStyleSheet("border: 1px solid red;") + label:setBackgroundImage(":/icons/mudlet.png") + assert.spy(background).was.called_with("glsBackground", ":/icons/mudlet.png") + assert.are.equal("border: 1px solid red;", getLabelStyleSheet("glsBackground")) + end) + + it("setTiledBackgroundImage goes through the stylesheet instead", function() + local label = track(Geyser.Label:new({name = "glsTiled", x = 0, y = 0, width = 40, height = 20})) + label:setTiledBackgroundImage("/tmp/whatever.png") + assert.are.equal("background-image: url(/tmp/whatever.png);", getLabelStyleSheet("glsTiled")) + end) + end) +end) + +-- The movie wrappers, the callback registration bookkeeping and the nested +-- label machinery. All three are places where Geyser keeps state of its own +-- alongside the widget's, and the state is what these specs read back: a real +-- mouse is what fires the callbacks and what drives the nest, and Lua cannot +-- make one. +describe("Tests Geyser.Label movies, callbacks and nesting", function() + local created + local container + local gifPath = getMudletHomeDir() .. "/geyser_label_spec.gif" + + local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} + end + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + -- The smallest animated GIF there is: 1x1 pixels, two frames, a two entry + -- colour table. Written at run time so that no binary has to be committed. + local function writeAnimatedGif(path) + local bytes = { + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, -- "GIF89a" + 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, -- 1x1, global colour table of 2 + 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, -- black, white + 0x21, 0xFF, 0x0B, -- application extension + 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, -- "NETSCAPE" + 0x32, 0x2E, 0x30, -- "2.0" + 0x03, 0x01, 0x00, 0x00, 0x00, -- loop forever + 0x21, 0xF9, 0x04, 0x00, 0x0A, 0x00, 0x00, 0x00, -- frame 1 control block + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0x02, 0x02, 0x44, 0x01, 0x00, -- frame 1: the black pixel + 0x21, 0xF9, 0x04, 0x00, 0x0A, 0x00, 0x00, 0x00, -- frame 2 control block + 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0x02, 0x02, 0x4C, 0x01, 0x00, -- frame 2: the white pixel + 0x3B, -- trailer + } + local characters = {} + for index, byte in ipairs(bytes) do + characters[index] = string.char(byte) + end + local file = assert(io.open(path, "wb"), "could not write the GIF fixture") + file:write(table.concat(characters)) + file:close() + end + + setup(function() + writeAnimatedGif(gifPath) + end) + + teardown(function() + os.remove(gifPath) + end) + + before_each(function() + created = {} + container = track(Geyser.Container:new({name = "glnHost", x = 0, y = 0, width = 600, height = 400})) + end) + + after_each(function() + -- doNestShow/doNestLeave arm a timer that closes the nest seconds later, + -- long after the labels it closes have been deleted + if Geyser.Label.closeAllTimer then + killTimer(Geyser.Label.closeAllTimer) + Geyser.Label.closeAllTimer = nil + end + for _, object in ipairs(created) do + -- the scroll tables are keyed by the label object and outlive it + Geyser.Label.scrollV[object] = nil + Geyser.Label.scrollH[object] = nil + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.Label movie wrappers", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glnMovie", x = 0, y = 0, width = 60, height = 40}, container)) + end) + + it("setMovie puts the GIF on the label", function() + assert.is_true(label:setMovie(gifPath)) + end) + + it("setMovie reports a file that is not a movie", function() + local ok, message = label:setMovie(getMudletHomeDir() .. "/nosuchmovie.gif") + assert.is_nil(ok) + assert.is_string(message) + assert.is_truthy(message:find("no valid movie", 1, true)) + end) + + it("startMovie and pauseMovie drive the movie that was set", function() + label:setMovie(gifPath) + assert.is_true(label:startMovie()) + assert.is_true(label:pauseMovie()) + assert.is_true(label:startMovie()) + end) + + it("the movie functions all refuse a label with no movie on it", function() + local bare = track(Geyser.Label:new({name = "glnNoMovie", x = 0, y = 0, width = 60, height = 40}, container)) + for _, call in ipairs({ + function() return bare:startMovie() end, + function() return bare:pauseMovie() end, + function() return bare:setMovieSpeed(200) end, + function() return bare:setMovieFrame(0) end, + function() return bare:scaleMovie() end, + }) do + local ok, message = call() + assert.is_nil(ok) + assert.is_truthy(message:find("no movie found at label 'glnNoMovie'", 1, true)) + end + end) + + it("setMovieSpeed takes a percentage and refuses anything else", function() + label:setMovie(gifPath) + assert.is_true(label:setMovieSpeed(200)) + assert.is_true(label:setMovieSpeed(50)) + assert.has_error(function() label:setMovieSpeed("double") end) + end) + + it("setMovieFrame reports whether the frame could be reached", function() + label:setMovie(gifPath) + assert.is_true(label:setMovieFrame(0)) + -- the fixture has two frames, so this one is out of reach + assert.is_false(label:setMovieFrame(99)) + assert.has_error(function() label:setMovieFrame("first") end) + end) + + -- whether the movie is actually being kept at the label's size is not + -- readable from Lua: the connection scaleMovie(true) makes lives on the + -- widget and there is no getter for the movie's scaled size + pending("the movie following the label's size needs a getter for the scaled size") + + it("scaleMovie turns scaling on unless it is explicitly told false", function() + -- the argument is what carries the meaning and the return value is true + -- either way, so watch what the wrapper passes on + local scaling = spy.on(_G, "scaleMovie") + finally(function() scaling:revert() end) + label:setMovie(gifPath) + + assert.is_true(label:scaleMovie(false)) + assert.spy(scaling).was.called_with("glnMovie", false) + + assert.is_true(label:scaleMovie(true)) + assert.spy(scaling).was.called_with("glnMovie", true) + end) + + it("scaleMovie treats anything that is not false as a yes", function() + local scaling = spy.on(_G, "scaleMovie") + finally(function() scaling:revert() end) + label:setMovie(gifPath) + + -- no argument at all, and a nil one, both mean scale + assert.is_true(label:scaleMovie()) + assert.is_true(label:scaleMovie(nil)) + -- so does something that is not a boolean, rather than raising + assert.is_true(label:scaleMovie("nonsense")) + assert.spy(scaling).was.called(3) + assert.spy(scaling).was_not.called_with("glnMovie", false) + end) + end) + + describe("Geyser.Label callback registration", function() + local label + + before_each(function() + label = track(Geyser.Label:new({name = "glnCallback", x = 0, y = 0, width = 60, height = 40}, container)) + end) + + -- firing needs a real mouse over a real widget, which the suite has no way + -- of producing; what is checked here is that the registration reached the + -- widget and that Geyser remembers it + pending("the label callbacks firing on a real mouse event needs GUI automation") + + it("remembers the function and the arguments it registered", function() + local handler = function() end + label:setClickCallback(handler, "first", 2) + assert.are.equal(handler, label.clickCallback) + assert.are.same({"first", 2}, label.clickArgs) + end) + + it("passes the label name, the function and the arguments straight through", function() + -- no getter for a registered callback, so spy on the global; spy.on + -- leaves the real registration in place + local registration = spy.on(_G, "setLabelClickCallback") + finally(function() registration:revert() end) + local handler = function() end + label:setClickCallback(handler, "first", 2) + assert.spy(registration).was.called_with("glnCallback", handler, "first", 2) + end) + + it("registers each kind of callback through its own global", function() + local handler = function() end + local globals = { + setClickCallback = "setLabelClickCallback", + setDoubleClickCallback = "setLabelDoubleClickCallback", + setReleaseCallback = "setLabelReleaseCallback", + setMoveCallback = "setLabelMoveCallback", + setWheelCallback = "setLabelWheelCallback", + setOnEnter = "setLabelOnEnter", + setOnLeave = "setLabelOnLeave", + } + -- reverting inside the loop would be skipped by a failing assertion, and + -- a spy left on a Mudlet global is picked up as the "real" function by + -- the next spy.on in any later spec file + local spied = {} + finally(function() + for _, global in ipairs(spied) do + _G[global]:revert() + end + end) + + for method, global in pairs(globals) do + local registration = spy.on(_G, global) + spied[#spied + 1] = global + label[method](label, handler, "arg") + assert.spy(registration).was.called_with("glnCallback", handler, "arg") + end + end) + + it("deregisters when it is handed nil instead of a function", function() + label:setClickCallback(function() end, "first") + label:setClickCallback(nil) + assert.is_nil(label.clickCallback) + assert.are.same({}, label.clickArgs) + end) + + -- setDoubleClickCallback is missing from the readback below on purpose: it + -- stores self.doubleclickCallback/doubleclickArgs while the constructor and + -- every other setter use the doubleClickCallback/doubleClickArgs spelling, + -- so there is nothing here worth freezing until that is settled + it("remembers what the other callbacks registered too", function() + local handler = function() end + label:setReleaseCallback(handler, "r") + label:setMoveCallback(handler, "m") + label:setWheelCallback(handler, "w") + label:setOnEnter(handler, "e") + label:setOnLeave(handler, "l") + assert.are.same({"r"}, label.releaseArgs) + assert.are.same({"m"}, label.moveArgs) + assert.are.same({"w"}, label.wheelArgs) + assert.are.same({"e"}, label.onEnterArgs) + assert.are.same({"l"}, label.onLeaveArgs) + assert.are.equal(handler, label.releaseCallback) + assert.are.equal(handler, label.moveCallback) + assert.are.equal(handler, label.wheelCallback) + assert.are.equal(handler, label.onEnter) + assert.are.equal(handler, label.onLeave) + end) + + it("hard-errors on a callback that is neither a function nor nil", function() + assert.has_error(function() label:setClickCallback(42) end) + assert.has_error(function() label:setWheelCallback({}) end) + end) + + it("registers the callbacks the constructor was given", function() + local built = track(Geyser.Label:new({ + name = "glnConsCallback", x = 0, y = 0, width = 60, height = 40, + clickCallback = "echo", clickArgs = {"hello"}, + onEnter = "echo", onEnterArgs = "hello", + }, container)) + assert.are.equal("echo", built.clickCallback) + assert.are.same({"hello"}, built.clickArgs) + assert.are.equal("echo", built.onEnter) + assert.are.same({"hello"}, built.onEnterArgs) + end) + end) + + describe("Geyser.Label:addChild", function() + local parent + + before_each(function() + parent = track(Geyser.Label:new({name = "glnParent", x = 100, y = 100, width = 50, height = 20}, container)) + end) + + it("hands back a hidden nested label that knows its parent", function() + local child = track(parent:addChild({name = "glnChild", width = 50, height = 20}, container)) + assert.are.equal("nestedLabel", child.type) + assert.are.equal(parent, child.nestParent) + assert.is_true(child.hidden) + assert.is_false(windowVisible("glnChild")) + assert.are.same({child}, parent.nestedLabels) + end) + + it("defaults to flying out to the left, laid out vertically", function() + local child = track(parent:addChild({name = "glnChildDefault", width = 50, height = 20}, container)) + assert.are.equal("L", child.flyDir) + assert.are.equal("V", child.layoutDir) + end) + + it("splits layoutDir into a fly direction and a layout axis", function() + local child = track(parent:addChild({name = "glnChildRH", width = 50, height = 20, layoutDir = "RH"}, container)) + assert.are.equal("R", child.flyDir) + assert.are.equal("H", child.layoutDir) + end) + + it("wires the child up so that hovering it opens its own nest", function() + local child = track(parent:addChild({name = "glnChildHover", width = 50, height = 20}, container)) + assert.are.equal("doNestEnter", child.onEnter) + assert.are.equal("doNestLeave", child.onLeave) + end) + + it("keeps the children in the order they were added", function() + local first = track(parent:addChild({name = "glnChildOne", width = 50, height = 20}, container)) + local second = track(parent:addChild({name = "glnChildTwo", width = 50, height = 20}, container)) + assert.are.same({first, second}, parent.nestedLabels) + end) + + it("puts a child with an index where the index says", function() + local first = track(parent:addChild({name = "glnIndexOne", width = 50, height = 20}, container)) + local jumped = track(parent:addChild({name = "glnIndexTwo", width = 50, height = 20, index = 1}, container)) + assert.are.same({jumped, first}, parent.nestedLabels) + end) + + it("nests a child under a child", function() + local child = track(parent:addChild({name = "glnGrandParent", width = 50, height = 20}, container)) + local grandChild = track(child:addChild({name = "glnGrandChild", width = 50, height = 20}, container)) + assert.are.equal(child, grandChild.nestParent) + assert.are.same({grandChild}, child.nestedLabels) + end) + + it("gives a nestable label the click callback that opens its nest", function() + local nestable = track(Geyser.Label:new({name = "glnNestable", x = 0, y = 0, width = 50, height = 20, nestable = true}, container)) + assert.are.equal("doNestShow", nestable.clickCallback) + end) + + it("gives a nestflyout label the hover callback that opens its nest", function() + local flyout = track(Geyser.Label:new({name = "glnFlyout", x = 0, y = 0, width = 50, height = 20, nestflyout = true}, container)) + assert.are.equal("doNestShow", flyout.onEnter) + end) + end) + + describe("Geyser.Label nest display and closing", function() + local parent, child + + before_each(function() + parent = track(Geyser.Label:new({name = "glnNestParent", x = 100, y = 100, width = 50, height = 20}, container)) + child = track(parent:addChild({name = "glnNestChild", width = 50, height = 20, layoutDir = "RV"}, container)) + end) + + it("displayNest shows the children and lays them out beside the parent", function() + parent:displayNest() + assert.is_true(windowVisible("glnNestChild")) + -- flyDir R puts the child past the parent's right edge, at its own top + assert.are.same({x = 150, y = 100, width = 50, height = 20}, geometry("glnNestChild")) + end) + + it("displayNest stacks a second child below the first", function() + local second = track(parent:addChild({name = "glnNestChildTwo", width = 50, height = 20, layoutDir = "RV"}, container)) + parent:displayNest() + assert.is_true(windowVisible("glnNestChildTwo")) + assert.are.equal(100, geometry("glnNestChild").y) + assert.are.equal(120, geometry(second.name).y) + end) + + it("displayNest lays a horizontal nest out sideways instead", function() + -- two children, because one H child lands on the same pixel as one V + -- child would: only the second one shows which axis the nest grew along + local first = track(parent:addChild({name = "glnNestSideways", width = 50, height = 20, layoutDir = "RH"}, container)) + local second = track(parent:addChild({name = "glnNestSidewaysTwo", width = 50, height = 20, layoutDir = "RH"}, container)) + parent:displayNest() + + assert.is_true(windowVisible(first.name)) + assert.is_true(windowVisible(second.name)) + assert.are.same({x = 150, y = 100}, {x = geometry(first.name).x, y = geometry(first.name).y}) + -- along x, where the vertical nest would have gone along y + assert.are.same({x = 200, y = 100}, {x = geometry(second.name).x, y = geometry(second.name).y}) + end) + + it("closeNestChildren hides the children again", function() + parent:displayNest() + closeNestChildren(parent) + assert.is_false(windowVisible("glnNestChild")) + end) + + it("closeNestChildren reaches grandchildren too", function() + local grandChild = track(child:addChild({name = "glnNestGrandChild", width = 50, height = 20}, container)) + parent:displayNest() + child:displayNest() + assert.is_true(windowVisible(grandChild.name)) + + closeNestChildren(parent) + assert.is_false(windowVisible("glnNestChild")) + assert.is_false(windowVisible(grandChild.name)) + end) + + it("closeNestChildren does nothing for a label with no nest", function() + local lonely = track(Geyser.Label:new({name = "glnLonely", x = 0, y = 0, width = 50, height = 20}, container)) + assert.has_no.errors(function() closeNestChildren(lonely) end) + assert.is_true(windowVisible("glnLonely")) + end) + + it("closeAllLevels hides every nested label in the container", function() + local other = track(Geyser.Label:new({name = "glnOtherParent", x = 300, y = 100, width = 50, height = 20}, container)) + local otherChild = track(other:addChild({name = "glnOtherChild", width = 50, height = 20}, container)) + parent:displayNest() + other:displayNest() + + closeAllLevels(parent) + + assert.is_false(windowVisible("glnNestChild")) + assert.is_false(windowVisible(otherChild.name)) + -- the parents themselves have no nestParent, so they stay put + assert.is_true(windowVisible("glnNestParent")) + assert.is_true(windowVisible(other.name)) + end) + + it("closeNeighbourChildren closes the nests either side of a child", function() + local sibling = track(parent:addChild({name = "glnSibling", width = 50, height = 20}, container)) + local siblingChild = track(sibling:addChild({name = "glnSiblingChild", width = 50, height = 20}, container)) + parent:displayNest() + sibling:displayNest() + assert.is_true(windowVisible(siblingChild.name)) + + closeNeighbourChildren(child) + + assert.is_false(windowVisible(siblingChild.name)) + end) + + it("doNestShow opens a closed nest and arms the timer that closes it", function() + assert.is_false(windowVisible("glnNestChild")) + doNestShow(parent) + assert.is_true(windowVisible("glnNestChild")) + assert.is_number(Geyser.Label.closeAllTimer) + assert.is_number(remainingTime(Geyser.Label.closeAllTimer)) + end) + + it("doNestShow closes a nest that is already open", function() + -- it is the click handler of a nestable label, so clicking twice has to + -- put the nest away again: it always closes everything first and only + -- reopens when the first child was hidden + doNestShow(parent) + assert.is_true(windowVisible("glnNestChild")) + doNestShow(parent) + assert.is_false(windowVisible("glnNestChild")) + end) + + it("doNestShow replaces the timer rather than stacking a second one", function() + doNestShow(parent) + local firstTimer = Geyser.Label.closeAllTimer + doNestShow(parent) + assert.are_not.equal(firstTimer, Geyser.Label.closeAllTimer) + assert.is_nil(remainingTime(firstTimer)) + end) + + it("doNestEnter opens the nest of a child that flies out", function() + local grandChild = track(child:addChild({name = "glnEnterGrandChild", width = 50, height = 20}, container)) + child.flyOut = true + doNestEnter(child) + assert.is_true(windowVisible(grandChild.name)) + end) + + it("doNestEnter leaves the nest of a child that does not fly out closed", function() + local grandChild = track(child:addChild({name = "glnNoFlyGrandChild", width = 50, height = 20}, container)) + child.flyOut = nil + doNestEnter(child) + assert.is_false(windowVisible(grandChild.name)) + end) + + it("doNestEnter cancels the timer that would have closed everything", function() + doNestShow(parent) + local armed = Geyser.Label.closeAllTimer + doNestEnter(child) + assert.is_nil(remainingTime(armed)) + end) + + it("doNestEnter ignores being handed nothing", function() + assert.has_no.errors(function() doNestEnter(nil) end) + end) + + it("doNestLeave arms the timer that closes everything", function() + doNestLeave(child) + assert.is_number(Geyser.Label.closeAllTimer) + assert.is_number(remainingTime(Geyser.Label.closeAllTimer)) + end) + end) + + describe("Geyser.Label:addScrollbars and doNestScroll", function() + local parent, first, second + + before_each(function() + parent = track(Geyser.Label:new({name = "glnScrollParent", x = 100, y = 100, width = 50, height = 20}, container)) + first = track(parent:addChild({name = "glnScrollOne", width = 50, height = 20, layoutDir = "RV"}, container)) + second = track(parent:addChild({name = "glnScrollTwo", width = 50, height = 20, layoutDir = "RV"}, container)) + end) + + local function makeScrollbars() + local bars = Geyser.Label:addScrollbars(parent, "RV") + track(bars[1]) + track(bars[2]) + Geyser.Label.scrollV[parent] = bars + finally(function() Geyser.Label.scrollV[parent] = nil end) + return bars[1], bars[2] + end + + it("makes a backward and a forward label named after the nest", function() + local backward, forward = makeScrollbars() + assert.are.equal("backScrollglnScrollOneRV", backward.name) + assert.are.equal("forScrollglnScrollOneRV", forward.name) + assert.are.equal(parent, backward.nestParent) + assert.are.equal(parent, forward.nestParent) + assert.are.equal("More...", forward.message) + end) + + it("sizes the forward scrollbar's reach to the nest it scrolls", function() + local _, forward = makeScrollbars() + -- two children in the nest, plus the scroll window's own end marker + assert.are.equal(3, forward.maxScroll) + end) + + it("wires both scrollbars up to doNestScroll", function() + local backward, forward = makeScrollbars() + assert.are.equal("doNestScroll", backward.clickCallback) + assert.are.equal("doNestScroll", forward.clickCallback) + assert.are.equal("doNestEnter", forward.onEnter) + assert.are.equal("doNestLeave", forward.onLeave) + end) + + it("doNestScroll moves the window forward when the forward bar is clicked", function() + local backward, forward = makeScrollbars() + backward.scroll, forward.scroll, forward.maxScroll = 0, 3, 5 + + doNestScroll(forward) + + assert.are.equal(1, backward.scroll) + assert.are.equal(4, forward.scroll) + end) + + it("doNestScroll moves the window back when the backward bar is clicked", function() + local backward, forward = makeScrollbars() + backward.scroll, forward.scroll, forward.maxScroll = 1, 4, 5 + + doNestScroll(backward) + + assert.are.equal(0, backward.scroll) + assert.are.equal(3, forward.scroll) + end) + + it("doNestScroll will not scroll back past the first entry", function() + local backward, forward = makeScrollbars() + backward.scroll, forward.scroll, forward.maxScroll = 0, 3, 5 + + doNestScroll(backward) + + assert.are.equal(0, backward.scroll) + assert.are.equal(3, forward.scroll, "the window has to keep its size when it hits the top") + end) + + it("doNestScroll will not scroll on past the last entry", function() + local backward, forward = makeScrollbars() + backward.scroll, forward.scroll, forward.maxScroll = 2, 5, 5 + + doNestScroll(forward) + + assert.are.equal(2, backward.scroll) + assert.are.equal(5, forward.scroll) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserMapper_spec.lua b/src/mudlet-lua/tests/GeyserMapper_spec.lua new file mode 100644 index 000000000..30c53e1ed --- /dev/null +++ b/src/mudlet-lua/tests/GeyserMapper_spec.lua @@ -0,0 +1,319 @@ +-- Geyser.Mapper drives Mudlet's one map per profile. The dockable map widget +-- has no window name of its own, so windowType/getWindowGeometry cannot see it; +-- what is observable is the Geyser object's resolved constraints plus +-- closeMapWidget(), which reports "map widget already closed" when the widget +-- is not on screen and so doubles as a visibility probe. +-- +-- Every mapper here is created with embedded = false or a dock position, which +-- is the map widget rather than a mapper drawn into the main console: see the +-- pending below for why the embedded form cannot be exercised in this suite. +-- +-- Host::closeMapWidget() hides the dock widget and records the close, so the map +-- window functions answer as they do for a profile that never opened one; what +-- it does not do is destroy the dock. busted runs its files in sorted order, so +-- this file opens the widget ahead of Mapper_spec.lua and its opening block +-- therefore keeps its "there is no map widget" premise but loses its "registered +-- before the widget was opened" one. Nothing there fails, but the deferred +-- registration path that block meant to cover is no longer reached from here on. +-- Restoring it means moving it into an earlier sorting file of its own. + +-- Reports whether the map widget is currently on screen, without leaving it in +-- a different state than it was found in. +local function mapWidgetVisible() + local closed = closeMapWidget() + if closed then + assert.is_true(openMapWidget(), "could not put the map widget back after probing it") + return true + end + return false +end + +describe("Tests functionality of Geyser.Mapper", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + -- The map widget outlives every mapper object, so put it away again rather + -- than leaving it over the specs that run after this file. + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + closeMapWidget() + end) + + describe("Geyser.Mapper:new/new2", function() + it("registers a mapper that has no addressable widget of its own", function() + local mapper = track(Geyser.Mapper:new({name = "gmpNew", x = 10, y = 20, width = 300, height = 200, embedded = false})) + assert.are.equal("mapper", mapper.type) + assert.are.equal(mapper, Geyser.windowList.gmpNew) + -- the map lives in a dock widget Mudlet does not name, so the window + -- getters cannot reach it + assert.is_nil(windowType("gmpNew")) + local found, message = getWindowGeometry("gmpNew") + assert.is_nil(found) + assert.is_truthy(message:find("gmpNew", 1, true)) + end) + + it("opens the map widget", function() + track(Geyser.Mapper:new({name = "gmpOpen", x = 10, y = 20, width = 300, height = 200, embedded = false})) + assert.is_true(mapWidgetVisible()) + end) + + it("resolves its constraints like any other Geyser window", function() + local mapper = track(Geyser.Mapper:new({name = "gmpPixels", x = 10, y = 20, width = 300, height = 200, embedded = false})) + assert.are.equal(10, mapper:get_x()) + assert.are.equal(20, mapper:get_y()) + assert.are.equal(300, mapper:get_width()) + assert.are.equal(200, mapper:get_height()) + end) + + it("resolves percentages against its container", function() + local container = track(Geyser.Container:new({name = "gmpBox", x = 100, y = 50, width = 400, height = 200})) + local mapper = track(Geyser.Mapper:new({name = "gmpInBox", x = "25%", y = "50%", width = "50%", height = "50%", embedded = false}, container)) + assert.are.equal(200, mapper:get_x()) + assert.are.equal(150, mapper:get_y()) + assert.are.equal(200, mapper:get_width()) + assert.are.equal(100, mapper:get_height()) + end) + + it("treats a mapper given a dock position as not embedded", function() + local mapper = track(Geyser.Mapper:new({name = "gmpDocked", x = 0, y = 0, width = 200, height = 150, dockPosition = "right"})) + assert.is_false(mapper.embedded) + assert.are.equal("right", mapper.dockPosition) + end) + + it("shortens a floating dock position to f", function() + local mapper = track(Geyser.Mapper:new({name = "gmpFloating", x = 0, y = 0, width = 200, height = 150, dockPosition = "floating"})) + assert.is_false(mapper.embedded) + assert.are.equal("f", mapper.dockPosition) + end) + + it("new2 marks the mapper as using add2", function() + local mapper = track(Geyser.Mapper:new2({name = "gmpNew2", x = 0, y = 0, width = 200, height = 150, embedded = false})) + assert.is_true(mapper.useAdd2) + assert.are.equal("mapper", mapper.type) + end) + end) + + describe("Geyser.Mapper:move/resize", function() + local mapper + + before_each(function() + mapper = track(Geyser.Mapper:new({name = "gmpMove", x = 10, y = 20, width = 300, height = 200, embedded = false})) + end) + + it("takes the new constraints and resolves them", function() + mapper:move(60, 70) + assert.are.equal("60px", mapper.x) + assert.are.equal("70px", mapper.y) + assert.are.equal(60, mapper:get_x()) + assert.are.equal(70, mapper:get_y()) + mapper:resize(150, 100) + assert.are.equal("150px", mapper.width) + assert.are.equal(150, mapper:get_width()) + assert.are.equal(100, mapper:get_height()) + end) + + it("refuses to move or resize while it is hidden", function() + mapper:hide() + mapper:move(200, 210) + mapper:resize(50, 60) + assert.are.equal("10px", mapper.x) + assert.are.equal("20px", mapper.y) + assert.are.equal(10, mapper:get_x()) + assert.are.equal(300, mapper:get_width()) + assert.are.equal(200, mapper:get_height()) + end) + end) + + describe("Geyser.Mapper:hide/show", function() + it("closes and reopens the map widget", function() + local mapper = track(Geyser.Mapper:new({name = "gmpHide", x = 10, y = 20, width = 300, height = 200, embedded = false})) + assert.is_true(mapWidgetVisible()) + mapper:hide() + assert.is_true(mapper.hidden) + assert.is_false(mapWidgetVisible()) + mapper:show() + assert.is_false(mapper.hidden) + assert.is_true(mapWidgetVisible()) + end) + + it("reports that a closed map widget is already closed", function() + local mapper = track(Geyser.Mapper:new({name = "gmpClosed", x = 10, y = 20, width = 300, height = 200, embedded = false})) + mapper:hide() + local closed, message = closeMapWidget() + assert.is_nil(closed) + assert.is_truthy(message:find("already closed", 1, true)) + end) + end) + + describe("Geyser.Mapper:setDockPosition", function() + it("puts a closed map widget back on screen", function() + local mapper = track(Geyser.Mapper:new({name = "gmpDockOpen", x = 10, y = 20, width = 300, height = 200, embedded = false})) + mapper:hide() + assert.is_false(mapWidgetVisible()) + assert.is_true(mapper:setDockPosition("f")) + assert.is_true(mapWidgetVisible()) + end) + + it("refuses a dock position that is not one of the five", function() + local mapper = track(Geyser.Mapper:new({name = "gmpDockBad", x = 10, y = 20, width = 300, height = 200, embedded = false})) + local result, message = mapper:setDockPosition("nonsense") + assert.is_nil(result) + assert.is_string(message) + assert.is_truthy(message:find("not available", 1, true)) + -- refusing is not fatal, the widget stays where it was + assert.is_true(mapWidgetVisible()) + end) + end) + + describe("Geyser.Mapper:reposition", function() + it("leaves the map widget alone when the main window is resized", function() + local mapper = track(Geyser.Mapper:new({name = "gmpReposition", x = 10, y = 20, width = 300, height = 200, embedded = false})) + local mainWidth, mainHeight = getMainWindowSize() + -- a mapper has no window for moveWindow/resizeWindow to act on, which is + -- why it overrides reposition to do nothing unless it is embedded. This + -- is a regression guard: the constraints cannot move today, so the + -- load-bearing assertion is that the widget is still on screen after. + GeyserReposition("sysWindowResizeEvent", mainWidth, mainHeight) + assert.are.equal(10, mapper:get_x()) + assert.are.equal(20, mapper:get_y()) + assert.are.equal(300, mapper:get_width()) + assert.are.equal(200, mapper:get_height()) + assert.is_true(mapWidgetVisible()) + end) + end) + + describe("Geyser.Mapper:setTitle/resetTitle", function() + it("remembers the title it was given, and empties it again on reset", function() + local mapper = track(Geyser.Mapper:new({ + name = "gmpTitle", + x = 10, y = 20, width = 300, height = 200, + embedded = false, + titleText = "My map", + })) + assert.are.equal("My map", mapper.titleText) + -- setMapWindowTitle answers nil and a message rather than raising when + -- there is no map window, so the return value is what says the title + -- reached one; without it titleText alone would look right regardless + assert.is_true(mapper:setTitle("Renamed")) + assert.are.equal("Renamed", mapper.titleText) + assert.is_true(mapper:resetTitle()) + assert.are.equal("", mapper.titleText) + end) + + it("applies a title that was set while the mapper was hidden", function() + local mapper = track(Geyser.Mapper:new({name = "gmpHiddenTitle", x = 10, y = 20, width = 300, height = 200, embedded = false})) + mapper:hide() + -- the map widget is off screen, so setMapWindowTitle has nothing to retitle + assert.is_nil(mapper:setTitle("Named while hidden")) + assert.are.equal("Named while hidden", mapper.titleText) + mapper:show() + assert.are.equal("Named while hidden", getMapWindowTitle()) + end) + + it("applies a reset that was made while the mapper was hidden", function() + local mapper = track(Geyser.Mapper:new({ + name = "gmpHiddenReset", + x = 10, y = 20, width = 300, height = 200, + embedded = false, + titleText = "Named before hiding", + })) + mapper:hide() + assert.is_nil(mapper:resetTitle()) + assert.are.equal("", mapper.titleText) + mapper:show() + -- an empty titleText is a reset, not "no title to apply", so the map + -- window has to come back with its generated default rather than the + -- title it carried before the hide + local title = getMapWindowTitle() + assert.is_truthy(title:find(getProfileName(), 1, true)) + assert.are_not.equal("Named before hiding", title) + end) + + it("does not overwrite a directly set title when a mapper is shown", function() + local mapper = track(Geyser.Mapper:new({name = "gmpNoClobber", x = 10, y = 20, width = 300, height = 200, embedded = false})) + mapper:hide() + openMapWidget() + assert.is_true(setMapWindowTitle("Set without Geyser")) + mapper:show() + -- this mapper never had a title of its own, so showing it has nothing to + -- reapply and must leave the map window titled as it was found + assert.are.equal("Set without Geyser", getMapWindowTitle()) + resetMapWindowTitle() + end) + + it("starts with an empty title when it was not given one", function() + local mapper = track(Geyser.Mapper:new({name = "gmpNoTitle", x = 10, y = 20, width = 300, height = 200, embedded = false})) + assert.are.equal("", mapper.titleText) + end) + end) + + pending("Geyser.Mapper:setTitle/resetTitle put the text on the map window's title bar - needs a getMapWindowTitle getter") + + pending("Geyser.Mapper:setDockPosition docks the map widget against the edge it names - which edge it ended up on is not readable from Lua") + + pending("Geyser.Mapper:raise/lower stack the map against the other windows - Mudlet exposes no z-order readback") + + -- An embedded mapper and the dockable map widget are mutually exclusive for + -- the life of a profile (TMainConsole::createMapper and Host::openMapWidget + -- each refuse when the other one exists), and neither can be destroyed once + -- made. Creating an embedded mapper here would take the map widget away from + -- Mapper_spec for the rest of the run. + pending("Geyser.Mapper embedded in the main console - an embedded mapper cannot be undone, so it cannot be created inside this suite") + + describe("Geyser.Mapper:type_delete", function() + it("closes the map widget and unregisters the mapper", function() + local mapper = track(Geyser.Mapper:new({name = "gmpDelete", x = 10, y = 20, width = 300, height = 200, embedded = false})) + assert.is_true(mapWidgetVisible()) + mapper:delete() + assert.is_nil(Geyser.windowList.gmpDelete) + assert.is_false(mapWidgetVisible()) + end) + + it("goes away with the container it was put in", function() + local container = track(Geyser.Container:new({name = "gmpOuter", x = 0, y = 0, width = 300, height = 200})) + local mapper = track(Geyser.Mapper:new({name = "gmpNested", x = 0, y = 0, width = "100%", height = "100%", embedded = false}, container)) + assert.are.equal(mapper, container.windowList.gmpNested) + assert.is_true(mapWidgetVisible()) + container:delete() + -- the widget closing is what says the cascade reached the mapper: + -- Geyser.Container:delete empties its own windowList either way + assert.is_false(mapWidgetVisible()) + assert.is_nil(container.windowList.gmpNested) + end) + + -- A profile has one map, so two Geyser.Mapper objects are two handles on + -- the same widget: deleting either one closes it under the other. That is + -- worth pinning down, because it is the trap a second mapper walks into. + it("closes the one shared map widget even when another mapper still holds it", function() + local first = track(Geyser.Mapper:new({name = "gmpShared", x = 10, y = 20, width = 300, height = 200, embedded = false})) + local second = track(Geyser.Mapper:new({name = "gmpSharing", x = 0, y = 0, width = 200, height = 150, embedded = false})) + assert.is_true(mapWidgetVisible()) + second:delete() + assert.is_false(mapWidgetVisible()) + -- the surviving mapper is untouched as an object, and can reopen the map + assert.are.equal(first, Geyser.windowList.gmpShared) + first:show() + assert.is_true(mapWidgetVisible()) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserMiniConsole_spec.lua b/src/mudlet-lua/tests/GeyserMiniConsole_spec.lua new file mode 100644 index 000000000..de7c92037 --- /dev/null +++ b/src/mudlet-lua/tests/GeyserMiniConsole_spec.lua @@ -0,0 +1,561 @@ +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.MiniConsole", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.MiniConsole:new/new2", function() + it("creates a miniconsole widget at the constrained geometry", function() + local console = track(Geyser.MiniConsole:new({name = "gmcNew", x = 30, y = 40, width = 300, height = 150})) + -- Geyser's own type string is camel cased, Mudlet's windowType is not + assert.are.equal("miniConsole", console.type) + assert.are.equal("miniconsole", windowType("gmcNew")) + assert.are.same({x = 30, y = 40, width = 300, height = 150}, geometry("gmcNew")) + assert.is_true(windowVisible("gmcNew")) + end) + + it("resolves percentages against its container", function() + local container = track(Geyser.Container:new({name = "gmcBox", x = 100, y = 50, width = 400, height = 200})) + track(Geyser.MiniConsole:new({name = "gmcInBox", x = "25%", y = "50%", width = "50%", height = "50%"}, container)) + assert.are.same({x = 200, y = 150, width = 200, height = 100}, geometry("gmcInBox")) + end) + + it("takes the font size from its container when it is not given one", function() + local container = track(Geyser.Container:new({name = "gmcFontBox", x = 0, y = 0, width = 200, height = 100, fontSize = 12})) + track(Geyser.MiniConsole:new({name = "gmcInheritsFont"}, container)) + assert.are.equal(12, getFontSize("gmcInheritsFont")) + end) + + it("new2 marks the console as using add2", function() + local console = track(Geyser.MiniConsole:new2({name = "gmcNew2", x = 0, y = 0, width = 100, height = 50})) + assert.is_true(console.useAdd2) + assert.are.equal("miniconsole", windowType("gmcNew2")) + end) + end) + + describe("Geyser.MiniConsole geometry and visibility", function() + local console + + before_each(function() + console = track(Geyser.MiniConsole:new({name = "gmcMove", x = 10, y = 20, width = 200, height = 100})) + end) + + it("moves and resizes the widget", function() + console:move(60, 70) + console:resize(120, 60) + assert.are.same({x = 60, y = 70, width = 120, height = 60}, geometry("gmcMove")) + end) + + it("hides and shows the widget", function() + console:hide() + assert.is_false(windowVisible("gmcMove")) + console:show() + assert.is_true(windowVisible("gmcMove")) + end) + + it("follows its container when the container moves", function() + local container = track(Geyser.Container:new({name = "gmcDragBox", x = 0, y = 0, width = 200, height = 100})) + track(Geyser.MiniConsole:new({name = "gmcDragged", x = 0, y = 0, width = "100%", height = "100%"}, container)) + container:move(150, 30) + assert.are.same({x = 150, y = 30, width = 200, height = 100}, geometry("gmcDragged")) + end) + end) + + describe("Geyser.MiniConsole:setWrap/enableAutoWrap/disableAutoWrap/resetAutoWrap", function() + it("sets the wrap column", function() + local console = track(Geyser.MiniConsole:new({name = "gmcWrap", x = 0, y = 0, width = 300, height = 100})) + console:setWrap(42) + assert.are.equal(42, console.wrapAt) + assert.are.equal(42, getWindowWrap("gmcWrap")) + end) + + it("refuses to set the wrap while auto wrap is on", function() + local console = track(Geyser.MiniConsole:new({name = "gmcWrapLocked", x = 0, y = 0, width = 300, height = 100})) + console:enableAutoWrap() + local derivedWrap = getWindowWrap("gmcWrapLocked") + local result, message = console:setWrap(11) + assert.is_nil(result) + assert.is_truthy(message:find("autoWrap is enabled", 1, true)) + assert.are.equal(derivedWrap, getWindowWrap("gmcWrapLocked")) + end) + + it("derives the wrap from the width when auto wrap is on", function() + local console = track(Geyser.MiniConsole:new({name = "gmcAutoWrap", x = 0, y = 0, width = 300, height = 100, wrapAt = "auto"})) + local charWidth = calcFontSize("gmcAutoWrap") + assert.is_true(console.autoWrap) + assert.are.equal(math.floor(300 / charWidth), getWindowWrap("gmcAutoWrap")) + end) + + it("re-derives the wrap when the console is resized", function() + local console = track(Geyser.MiniConsole:new({name = "gmcRewrap", x = 0, y = 0, width = 300, height = 100, autoWrap = true})) + local charWidth = calcFontSize("gmcRewrap") + assert.are.equal(math.floor(300 / charWidth), getWindowWrap("gmcRewrap")) + console:resize(150, 100) + assert.are.equal(math.floor(150 / charWidth), getWindowWrap("gmcRewrap")) + end) + + it("stops re-deriving the wrap once auto wrap is disabled", function() + local console = track(Geyser.MiniConsole:new({name = "gmcNoAutoWrap", x = 0, y = 0, width = 300, height = 100, autoWrap = true})) + console:disableAutoWrap() + console:setWrap(17) + console:resize(150, 100) + assert.is_false(console.autoWrap) + assert.are.equal(17, getWindowWrap("gmcNoAutoWrap")) + end) + + -- a console too narrow for even one character works out as zero columns, + -- which Mudlet refuses (and which used to hang it, issue #9622) + it("never derives a wrap of less than one column", function() + local console = track(Geyser.MiniConsole:new({name = "gmcTinyAutoWrap", x = 0, y = 0, width = 4, height = 100, autoWrap = true})) + assert.are.equal(1, console.wrapAt) + assert.are.equal(1, getWindowWrap("gmcTinyAutoWrap")) + end) + + it("passes on a refused wrap width instead of recording it", function() + local console = track(Geyser.MiniConsole:new({name = "gmcZeroWrap", x = 0, y = 0, width = 300, height = 100})) + console:setWrap(30) + local result, message = console:setWrap(0) + assert.is_nil(result) + assert.is_truthy(message:find("greater than zero", 1, true)) + -- the refused width must not be remembered, or every later setWrap() + -- would re-send it and be refused as well + assert.are.equal(30, console.wrapAt) + assert.are.equal(30, getWindowWrap("gmcZeroWrap")) + end) + + it("reports that resetAutoWrap has nothing to do when auto wrap is off", function() + local console = track(Geyser.MiniConsole:new({name = "gmcResetWrap", x = 0, y = 0, width = 300, height = 100})) + local result, message = console:resetAutoWrap() + assert.is_nil(result) + assert.is_truthy(message:find("Autowrap is not enabled", 1, true)) + end) + end) + + describe("Geyser.MiniConsole:setFontSize/getFont", function() + it("changes the font size of the console", function() + local console = track(Geyser.MiniConsole:new({name = "gmcFont", x = 0, y = 0, width = 300, height = 100, fontSize = 8})) + assert.are.equal(8, getFontSize("gmcFont")) + console:setFontSize(14) + assert.are.equal(14, getFontSize("gmcFont")) + assert.are.equal(14, console.fontSize) + end) + + it("re-derives an auto wrap from the new font size", function() + local console = track(Geyser.MiniConsole:new({name = "gmcFontWrap", x = 0, y = 0, width = 300, height = 100, fontSize = 8, autoWrap = true})) + local smallWrap = getWindowWrap("gmcFontWrap") + console:setFontSize(20) + local bigWrap = getWindowWrap("gmcFontWrap") + assert.are.equal(math.floor(300 / calcFontSize("gmcFontWrap")), bigWrap) + assert.is_true(bigWrap < smallWrap) + end) + + it("reads the font family back out of Mudlet", function() + local console = track(Geyser.MiniConsole:new({name = "gmcFontFamily", x = 0, y = 0, width = 300, height = 100})) + local family = getFont("gmcFontFamily") + assert.are.equal("string", type(family)) + assert.is_true(#family > 0) + -- getFont refreshes the cached family rather than reporting the cache + console.font = "not the real font" + assert.are.equal(family, console:getFont()) + assert.are.equal(family, console.font) + end) + end) + + describe("Geyser.MiniConsole:clear", function() + it("empties the console", function() + local console = track(Geyser.MiniConsole:new({name = "gmcClear", x = 0, y = 0, width = 300, height = 100})) + console:echo("one\ntwo\n") + assert.is_true(getLineCount("gmcClear") > 1) + console:clear() + assert.are.equal(0, getLineCount("gmcClear")) + end) + end) + + describe("Geyser.MiniConsole:type_delete", function() + it("deletes the widget with the object", function() + local console = track(Geyser.MiniConsole:new({name = "gmcDelete", x = 0, y = 0, width = 100, height = 50})) + assert.is_not_nil(getWindowGeometry("gmcDelete")) + console:delete() + assert.is_nil(getWindowGeometry("gmcDelete")) + assert.is_nil(Geyser.windowList.gmcDelete) + end) + end) + + describe("Geyser.MiniConsole command line", function() + local console + + before_each(function() + console = track(Geyser.MiniConsole:new({name = "gmcCmd", x = 0, y = 0, width = 300, height = 100})) + console:enableCommandLine() + end) + + it("enableCommandLine creates a command line the console can read back", function() + assert.are.equal("", console:getCmdLine()) + end) + + it("printCmd replaces the command line contents", function() + console:printCmd("first") + assert.are.equal("first", console:getCmdLine()) + console:printCmd("second") + assert.are.equal("second", console:getCmdLine()) + end) + + it("appendCmd adds to what is already there", function() + console:printCmd("hello") + console:appendCmd(" world") + assert.are.equal("hello world", console:getCmdLine()) + end) + + it("clearCmd empties the command line", function() + console:printCmd("something") + console:clearCmd() + assert.are.equal("", console:getCmdLine()) + end) + + it("selectCmdLinetext reports success after selecting the typed text", function() + console:printCmd("select me") + assert.is_true(console:selectCmdLinetext()) + end) + + it("selectCmdLinetext accepts the console's own command line", function() + console:printCmd("select me") + assert.has_no.errors(function() console:selectCmdLinetext() end) + -- selecting must not disturb what is typed + assert.are.equal("select me", console:getCmdLine()) + end) + + it("setCmdLineStyleSheet applies the sheet and remembers it", function() + -- the command line has no stylesheet getter, so spy on the global to + -- see what actually reached it; spy.on keeps the real function + local styleSheet = spy.on(_G, "setCmdLineStyleSheet") + finally(function() styleSheet:revert() end) + console:setCmdLineStyleSheet("color: red;") + assert.spy(styleSheet).was.called_with("gmcCmd", "color: red;") + assert.are.equal("color: red;", console.cmdLineStylesheet) + -- called with no argument it re-applies the remembered sheet + console:setCmdLineStyleSheet() + assert.spy(styleSheet).was.called(2) + assert.spy(styleSheet).was.called_with("gmcCmd", "color: red;") + end) + + it("disableCommandLine hides the command line without discarding what is typed", function() + local disable = spy.on(_G, "disableCommandLine") + finally(function() disable:revert() end) + console:printCmd("still here") + console:disableCommandLine() + assert.spy(disable).was.called_with("gmcCmd") + -- disabling only hides the widget, so the text is still readable and + -- comes back when the command line is enabled again + assert.are.equal("still here", console:getCmdLine()) + console:enableCommandLine() + assert.are.equal("still here", console:getCmdLine()) + end) + + -- An action only runs when the user presses return in the command line, + -- which nothing in Lua can make happen, so what is pinned here is the + -- registration: which function and arguments reached the widget, and what + -- the console remembers about them + pending("a command line action running on a real return keypress needs GUI automation") + + it("setCmdAction registers the function with the console's command line", function() + local action = spy.on(_G, "setCmdLineAction") + finally(function() action:revert() end) + local handler = function() end + + console:setCmdAction(handler, "first", 2) + + assert.spy(action).was.called_with("gmcCmd", handler, "first", 2) + assert.are.equal(handler, console.actionFunc) + assert.are.same({"first", 2}, console.actionArgs) + end) + + it("setCmdAction replaces the action rather than adding a second one", function() + local action = spy.on(_G, "setCmdLineAction") + finally(function() action:revert() end) + local first = function() end + local second = function() end + + console:setCmdAction(first, "one") + console:setCmdAction(second) + + -- the widget holds one action, so the second registration has to reach it + -- and the console has to forget the first one's arguments + assert.spy(action).was.called(2) + assert.spy(action).was.called_with("gmcCmd", second) + assert.are.equal(second, console.actionFunc) + assert.are.same({}, console.actionArgs) + end) + + it("setCmdAction takes the name of a function as a string too", function() + -- setCmdLineAction is wrapped in Lua, and that wrapper compiles a string + -- into a call of the function it names + assert.has_no.errors(function() console:setCmdAction("echo") end) + assert.are.equal("echo", console.actionFunc) + end) + + it("setCmdAction hard-errors on anything it cannot call", function() + assert.has_error(function() console:setCmdAction({}) end) + -- unlike the label callbacks, a command line action cannot be cleared by + -- registering nil: resetCmdAction is the way to put it back + assert.has_error(function() console:setCmdAction(nil) end) + end) + + it("resetCmdAction puts the command line back to sending to the game", function() + local reset = spy.on(_G, "resetCmdLineAction") + finally(function() reset:revert() end) + console:setCmdAction(function() end, "first") + + console:resetCmdAction() + + assert.spy(reset).was.called_with("gmcCmd") + assert.is_nil(console.actionFunc) + assert.is_nil(console.actionArgs) + end) + + it("resetCmdAction is safe on a command line that never had an action", function() + assert.has_no.errors(function() console:resetCmdAction() end) + assert.is_nil(console.actionFunc) + end) + end) + + describe("Geyser.MiniConsole:setBufferSize", function() + it("caps how many lines the console keeps", function() + local console = track(Geyser.MiniConsole:new({name = "gmcBuffer", x = 0, y = 0, width = 300, height = 100})) + console:setBufferSize(100, 20) + for i = 1, 600 do + console:echo("buffered line " .. i .. "\n") + end + -- trimming happens in batches once the limit is passed, so the line + -- count settles between the limit and limit + batch rather than at 600 + local kept = getLineCount("gmcBuffer") + assert.is_true(kept < 600, "a capped console must not keep every line, kept " .. kept) + assert.is_true(kept <= 121, "a capped console should settle near its limit, kept " .. kept) + end) + end) + + describe("Geyser.MiniConsole replace family", function() + local console + + local function firstLine() + console:moveCursor(0, 0) + console:selectCurrentLine() + return console:getCurrentLine() + end + + before_each(function() + console = track(Geyser.MiniConsole:new({name = "gmcReplace", x = 0, y = 0, width = 400, height = 200})) + console:setWrap(60) + console:echo("hello world\n") + console:moveCursor(0, 0) + end) + + it("replace swaps the current selection", function() + console:selectString("world", 1) + console:replace("earth") + assert.are.equal("hello earth", firstLine()) + end) + + it("replaceLine swaps the whole line", function() + console:replaceLine("a new line") + assert.are.equal("a new line", firstLine()) + end) + + it("dreplaceLine and hreplaceLine swap the line with colour", function() + console:dreplaceLine("<0,255,0>green line") + assert.are.equal("green line", firstLine()) + console:hreplaceLine("#0000ffblue line") + assert.are.equal("blue line", firstLine()) + end) + + it("fg and bg colour what is echoed next", function() + console:clear() + console:fg("red") + console:bg("blue") + console:echo("coloured\n") + console:selectString("coloured", 1) + assert.are.same(color_table["red"], {getFgColor("gmcReplace")}) + assert.are.same(color_table["blue"], {getBgColor("gmcReplace")}) + end) + + it("display renders a table into the console", function() + console:clear() + console:display({alpha = 1}) + local text = table.concat(getLines("gmcReplace", 0, getLineCount("gmcReplace")), "\n") + assert.is_truthy(text:find("alpha", 1, true)) + end) + + it("appendBuffer copies the main console selection in", function() + clearWindow() + echo("copy this line\n") + moveCursorEnd() + moveCursorUp() + selectCurrentLine() + copy() + console:clear() + console:appendBuffer() + assert.is_truthy(table.concat(getLines("gmcReplace", 0, getLineCount("gmcReplace") + 1), "\n"):find("copy this line", 1, true)) + end) + end) + + describe("Geyser.MiniConsole cursor movement", function() + local console + + before_each(function() + console = track(Geyser.MiniConsole:new({name = "gmcCursor", x = 0, y = 0, width = 400, height = 200})) + console:echo("one\ntwo\nthree\nfour\n") + console:moveCursor(0, 0) + end) + + it("moveCursorDown walks down the buffer and stops at the end", function() + console:moveCursorDown(2) + assert.are.equal(2, getLineNumber("gmcCursor")) + console:moveCursorDown(500) + assert.are.equal(getLastLineNumber("gmcCursor"), getLineNumber("gmcCursor")) + end) + + it("moveCursorUp walks back up and stops at the top", function() + console:moveCursorEnd() + console:moveCursorUp(1) + local afterOne = getLineNumber("gmcCursor") + console:moveCursorUp(500) + assert.are.equal(0, getLineNumber("gmcCursor")) + assert.is_true(afterOne > 0) + end) + end) + + describe("Geyser.MiniConsole link and popup echoes", function() + local console + + local function currentLine() + console:selectCurrentLine() + return console:getCurrentLine() + end + + before_each(function() + console = track(Geyser.MiniConsole:new({name = "gmcLinks", x = 0, y = 0, width = 400, height = 200})) + console:setWrap(60) + end) + + -- there is no getter for a link's command or hint, so the text each + -- variant lays down is the observable part + it("echoes plain, colour, decimal and hex links", function() + console:echoLink("plain link", "send('x')", "hint", true) + assert.are.equal("plain link", currentLine()) + + console:clear() + console:cechoLink("<red>colour link", "send('x')", "hint", true) + assert.are.equal("colour link", currentLine()) + + console:clear() + console:dechoLink("<0,255,0>decimal link", "send('x')", "hint", true) + assert.are.equal("decimal link", currentLine()) + + console:clear() + console:hechoLink("#0000ffhex link", "send('x')", "hint", true) + assert.are.equal("hex link", currentLine()) + end) + + it("inserts colour, decimal and hex links at the cursor", function() + console:echo("AB\n") + console:moveCursor(1, 0) + console:cinsertLink("<red>C", "send('x')", "hint", true) + console:moveCursor(0, 0) + assert.are.equal("ACB", currentLine()) + + console:clear() + console:echo("AB\n") + console:moveCursor(1, 0) + console:dinsertLink("<0,255,0>D", "send('x')", "hint", true) + console:moveCursor(0, 0) + assert.are.equal("ADB", currentLine()) + + console:clear() + console:echo("AB\n") + console:moveCursor(1, 0) + console:hinsertLink("#0000ffE", "send('x')", "hint", true) + console:moveCursor(0, 0) + assert.are.equal("AEB", currentLine()) + end) + + it("echoes and inserts popups in every colour syntax", function() + local commands = {"send('one')", "send('two')"} + local hints = {"first", "second"} + + console:echoPopup("plain popup", commands, hints, true) + assert.are.equal("plain popup", currentLine()) + + console:clear() + console:cechoPopup("<red>colour popup", commands, hints, true) + assert.are.equal("colour popup", currentLine()) + + console:clear() + console:dechoPopup("<0,255,0>decimal popup", commands, hints, true) + assert.are.equal("decimal popup", currentLine()) + + console:clear() + console:hechoPopup("#0000ffhex popup", commands, hints, true) + assert.are.equal("hex popup", currentLine()) + + console:clear() + console:echo("AB\n") + console:moveCursor(1, 0) + console:cinsertPopup("<red>C", commands, hints, true) + console:moveCursor(0, 0) + assert.are.equal("ACB", currentLine()) + end) + + it("setLink turns the current selection into a link", function() + -- a link's command and hint have no getter, so the observable part is + -- that the call is routed at this console and leaves the text alone + local setLinkSpy = spy.on(_G, "setLink") + finally(function() setLinkSpy:revert() end) + console:echo("clickable\n") + console:moveCursor(0, 0) + console:selectString("clickable", 1) + console:setLink("send('x')", "hint") + assert.spy(setLinkSpy).was.called_with("gmcLinks", "send('x')", "hint") + console:moveCursor(0, 0) + assert.are.equal("clickable", currentLine()) + end) + end) + + describe("Geyser.MiniConsole background image", function() + -- a Qt resource that ships with every Mudlet, so no fixture file is needed + local imagePath = ":/icons/mudlet.png" + + it("remembers the image it was given and forgets it on reset", function() + local console = track(Geyser.MiniConsole:new({name = "gmcBackground", x = 0, y = 0, width = 200, height = 100})) + assert.is_true(console:setBackgroundImage(imagePath, 2)) + assert.are.equal(imagePath, console.imgPath) + assert.is_true(console:resetBackgroundImage()) + assert.is_nil(console.imgPath) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserScrollBox_spec.lua b/src/mudlet-lua/tests/GeyserScrollBox_spec.lua new file mode 100644 index 000000000..a7f076f3f --- /dev/null +++ b/src/mudlet-lua/tests/GeyserScrollBox_spec.lua @@ -0,0 +1,268 @@ +-- A Geyser.ScrollBox is both a widget in its parent window and a parent window +-- of its own: it swaps its windowname for its own name so that everything added +-- to it is created inside the scroll box. Its children therefore report +-- geometry in the scroll box's coordinate space, not the main window's, which +-- is what lets a child be taller than the box and scroll. +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.ScrollBox", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.ScrollBox:new/new2", function() + it("creates a scroll box widget at the constrained geometry", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbNew", x = 10, y = 20, width = 200, height = 150})) + -- Geyser's own type string is camel cased, Mudlet's windowType is not + assert.are.equal("scrollBox", scrollBox.type) + assert.are.equal("scrollbox", windowType("gsbNew")) + assert.are.same({x = 10, y = 20, width = 200, height = 150}, geometry("gsbNew")) + assert.is_true(windowVisible("gsbNew")) + assert.are.equal(scrollBox, Geyser.windowList.gsbNew) + end) + + it("becomes a parent window of its own, remembering the one it was made in", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbParent", x = 0, y = 0, width = 100, height = 100})) + assert.are.equal("gsbParent", scrollBox.windowname) + assert.are.equal("main", scrollBox.parentWindowName) + assert.are.equal(scrollBox, Geyser.parentWindows.gsbParent) + end) + + it("reports its own origin as zero so children are placed inside it", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbOrigin", x = 40, y = 60, width = 100, height = 100})) + assert.are.equal(0, scrollBox.get_x()) + assert.are.equal(0, scrollBox.get_y()) + -- the widget itself is still where its constraints put it + assert.are.same({x = 40, y = 60, width = 100, height = 100}, geometry("gsbOrigin")) + end) + + it("uses Geyser's defaults when no constraints are given", function() + track(Geyser.ScrollBox:new({name = "gsbDefaults"})) + assert.are.same({x = 10, y = 10, width = 300, height = 200}, geometry("gsbDefaults")) + end) + + it("resolves percentages against its container", function() + local container = track(Geyser.Container:new({name = "gsbBox", x = 50, y = 60, width = 300, height = 200})) + track(Geyser.ScrollBox:new({name = "gsbInBox", x = "10%", y = "10%", width = "80%", height = "80%"}, container)) + assert.are.same({x = 80, y = 80, width = 240, height = 160}, geometry("gsbInBox")) + end) + + it("new2 marks the scroll box as using add2", function() + local scrollBox = track(Geyser.ScrollBox:new2({name = "gsbNew2", x = 0, y = 0, width = 50, height = 50})) + assert.is_true(scrollBox.useAdd2) + assert.are.equal("scrollbox", windowType("gsbNew2")) + end) + end) + + describe("Geyser.ScrollBox children", function() + local scrollBox + + before_each(function() + scrollBox = track(Geyser.ScrollBox:new({name = "gsbHolder", x = 10, y = 20, width = 200, height = 150})) + end) + + it("places children in its own coordinate space", function() + local console = track(Geyser.MiniConsole:new({name = "gsbConsole", x = "10%", y = "10%", width = "80%", height = "50%"}, scrollBox)) + assert.are.equal("gsbHolder", console.windowname) + -- 10%/10% of the box, not of the main window, and with no 10,20 offset + assert.are.same({x = 20, y = 15, width = 160, height = 75}, geometry("gsbConsole")) + end) + + it("holds a command line as well as a console", function() + track(Geyser.CommandLine:new({name = "gsbCmdLine", x = 0, y = "80%", width = "100%", height = 25}, scrollBox)) + assert.are.equal("commandline", windowType("gsbCmdLine")) + assert.are.same({x = 0, y = 120, width = 200, height = 25}, geometry("gsbCmdLine")) + end) + + it("lets a child be taller than the box, which is what makes it scroll", function() + track(Geyser.Label:new({name = "gsbTall", x = 0, y = 0, width = "100%", height = 2000}, scrollBox)) + assert.are.same({x = 0, y = 0, width = 200, height = 2000}, geometry("gsbTall")) + end) + + it("re-lays its children out when it is resized", function() + track(Geyser.MiniConsole:new({name = "gsbResized", x = "10%", y = "10%", width = "80%", height = "50%"}, scrollBox)) + scrollBox:resize(400, 300) + assert.are.same({x = 10, y = 20, width = 400, height = 300}, geometry("gsbHolder")) + assert.are.same({x = 40, y = 30, width = 320, height = 150}, geometry("gsbResized")) + end) + + it("keeps children where they are when the box itself moves", function() + track(Geyser.Label:new({name = "gsbFollower", x = "50%", y = 0, width = "50%", height = "100%"}, scrollBox)) + assert.are.same({x = 100, y = 0, width = 100, height = 150}, geometry("gsbFollower")) + scrollBox:move(80, 90) + assert.are.same({x = 80, y = 90, width = 200, height = 150}, geometry("gsbHolder")) + -- the child rides along inside the widget, so its own coordinates do not move + assert.are.same({x = 100, y = 0, width = 100, height = 150}, geometry("gsbFollower")) + end) + + it("nests a container of its own inside the scroll box", function() + local inner = track(Geyser.Container:new({name = "gsbInner", x = 0, y = 0, width = "50%", height = "50%"}, scrollBox)) + local label = track(Geyser.Label:new({name = "gsbInnerLabel", x = "50%", y = 0, width = "50%", height = "100%"}, inner)) + assert.are.equal("gsbHolder", label.windowname) + assert.are.same({x = 50, y = 0, width = 50, height = 75}, geometry("gsbInnerLabel")) + end) + + it("nests a scroll box inside a scroll box, each its own parent window", function() + local inner = track(Geyser.ScrollBox:new({name = "gsbNestedBox", x = 10, y = 10, width = "50%", height = "50%"}, scrollBox)) + assert.are.equal("gsbNestedBox", inner.windowname) + assert.are.equal("gsbHolder", inner.parentWindowName) + assert.are.same({x = 10, y = 10, width = 100, height = 75}, geometry("gsbNestedBox")) + -- a child of the inner box is placed in the inner box's own space again + local label = track(Geyser.Label:new({name = "gsbNestedLabel", x = "50%", y = 0, width = "50%", height = "100%"}, inner)) + assert.are.equal("gsbNestedBox", label.windowname) + assert.are.same({x = 50, y = 0, width = 50, height = 75}, geometry("gsbNestedLabel")) + end) + end) + + describe("Geyser.ScrollBox:hide/show", function() + it("hides and shows the scroll box and its children", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbVisible", x = 0, y = 0, width = 200, height = 150})) + track(Geyser.Label:new({name = "gsbVisibleChild", x = 0, y = 0, width = "100%", height = "100%"}, scrollBox)) + scrollBox:hide() + assert.is_true(scrollBox.hidden) + assert.is_false(windowVisible("gsbVisible")) + assert.is_false(windowVisible("gsbVisibleChild")) + scrollBox:show() + assert.is_false(scrollBox.hidden) + assert.is_true(windowVisible("gsbVisible")) + assert.is_true(windowVisible("gsbVisibleChild")) + end) + end) + + describe("Geyser.ScrollBox:reposition", function() + it("restores geometry that was changed behind Geyser's back", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbReposition", x = 10, y = 10, width = 100, height = 80})) + track(Geyser.Label:new({name = "gsbRepositionChild", x = 5, y = 5, width = "50%", height = "50%"}, scrollBox)) + moveWindow("gsbReposition", 300, 300) + resizeWindow("gsbReposition", 20, 20) + assert.are.same({x = 300, y = 300, width = 20, height = 20}, geometry("gsbReposition")) + local mainWidth, mainHeight = getMainWindowSize() + GeyserReposition("sysWindowResizeEvent", mainWidth, mainHeight) + assert.are.same({x = 10, y = 10, width = 100, height = 80}, geometry("gsbReposition")) + assert.are.same({x = 5, y = 5, width = 50, height = 40}, geometry("gsbRepositionChild")) + end) + + it("puts its own origin back to zero after repositioning", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbOriginKept", x = 30, y = 40, width = 100, height = 80})) + scrollBox:reposition() + assert.are.equal(0, scrollBox.get_x()) + assert.are.equal(0, scrollBox.get_y()) + assert.are.same({x = 30, y = 40, width = 100, height = 80}, geometry("gsbOriginKept")) + end) + end) + + -- Geyser:add2 runs from inside Geyser.Container:new, so the hide it asks for + -- lands before createScrollBox has made the widget. Every Geyser widget + -- constructor therefore hides itself again afterwards + -- (GeyserCommandLine.lua:89, GeyserMiniConsole.lua:605, GeyserLabel.lua:998, + -- GeyserTextEdit.lua:70, GeyserMapper.lua:133), and a Geyser.MiniConsole + -- built the same way is the reference behaviour asserted alongside. + describe("Geyser.ScrollBox created in a hidden container", function() + it("stays off screen, and comes back when the container is shown", function() + local parent = track(Geyser.Container:new2({name = "gsbHiddenParent", x = 0, y = 0, width = 300, height = 200})) + parent:hide() + local scrollBox = track(Geyser.ScrollBox:new2({name = "gsbBornHidden", x = 0, y = 0, width = "100%", height = "50%"}, parent)) + local console = track(Geyser.MiniConsole:new2({name = "gsbBornHiddenConsole", x = 0, y = "50%", width = "100%", height = "50%"}, parent)) + assert.is_true(scrollBox.auto_hidden) + assert.is_true(console.auto_hidden) + assert.is_false(windowVisible("gsbBornHiddenConsole")) + assert.is_false(windowVisible("gsbBornHidden")) + parent:show() + assert.is_true(windowVisible("gsbBornHidden")) + assert.is_true(windowVisible("gsbBornHiddenConsole")) + end) + + -- a scroll box that came up on screen while its bookkeeping said hidden + -- could not be taken off it again: Geyser.Container:hide skips hide_impl + -- for anything that already believes itself hidden, so neither an explicit + -- hide nor another parent:hide() reached it + it("can be taken off screen by hand without being shown first", function() + local parent = track(Geyser.Container:new2({name = "gsbHideParent", x = 0, y = 0, width = 300, height = 200})) + parent:hide() + local scrollBox = track(Geyser.ScrollBox:new2({name = "gsbHideByHand", x = 0, y = 0, width = "100%", height = "100%"}, parent)) + scrollBox:hide() + assert.is_false(windowVisible("gsbHideByHand")) + parent:hide() + assert.is_false(windowVisible("gsbHideByHand")) + end) + + -- add2 carries a hidden constraint through as well as an inherited one + it("stays off screen when it was asked to start hidden", function() + local scrollBox = track(Geyser.ScrollBox:new2({name = "gsbBornHiddenFlag", x = 0, y = 0, width = 100, height = 100, hidden = true})) + assert.is_true(scrollBox.hidden) + assert.is_false(windowVisible("gsbBornHiddenFlag")) + scrollBox:show() + assert.is_true(windowVisible("gsbBornHiddenFlag")) + end) + end) + + describe("Geyser.ScrollBox scroll bars", function() + -- A scroll box scrolls by being a QScrollArea (TScrollBox.h), not by being + -- a console, so Mudlet's scroll bar API cannot reach it: Host::findConsole + -- only looks through the sub-console map, and a scroll box is not in it. + -- Geyser.ScrollBox descends from Geyser.Window rather than + -- Geyser.MiniConsole, so it offers no scroll bar method of its own either. + -- Its scroll bars are Qt's, and appear on their own when a child overflows. + it("is not reachable by the console scroll bar functions", function() + track(Geyser.ScrollBox:new({name = "gsbScrollBar", x = 0, y = 0, width = 200, height = 150})) + local enabled, enableMessage = enableScrollBar("gsbScrollBar") + assert.is_nil(enabled) + assert.is_truthy(enableMessage:find("gsbScrollBar", 1, true)) + local disabled, disableMessage = disableScrollBar("gsbScrollBar") + assert.is_nil(disabled) + assert.is_truthy(disableMessage:find("gsbScrollBar", 1, true)) + assert.is_nil(Geyser.ScrollBox.enableScrollBar) + assert.is_nil(Geyser.ScrollBox.disableScrollBar) + end) + end) + + pending("Geyser.ScrollBox shows Qt's own scroll bar once a child overflows it - a scroll box is not a console, so no console scroll bar getter can report it; this needs a scroll box specific getter") + + pending("Geyser.ScrollBox:setStyleSheet - the method is commented out in GeyserScrollBox.lua because Mudlet has no setScrollBoxStyleSheet primitive to call") + + describe("Geyser.ScrollBox:type_delete", function() + it("deletes the widget, its children and its parent window registration", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "gsbDelete", x = 0, y = 0, width = 200, height = 150})) + track(Geyser.Label:new({name = "gsbDeleteChild", x = 0, y = 0, width = "100%", height = "100%"}, scrollBox)) + scrollBox:delete() + assert.is_nil(windowType("gsbDelete")) + assert.is_nil(windowType("gsbDeleteChild")) + assert.is_nil(Geyser.windowList.gsbDelete) + assert.is_nil(Geyser.parentWindows.gsbDelete) + end) + + it("goes away with the container it was put in", function() + local container = track(Geyser.Container:new({name = "gsbOuter", x = 0, y = 0, width = 300, height = 200})) + track(Geyser.ScrollBox:new({name = "gsbNested", x = 0, y = 0, width = "100%", height = "100%"}, container)) + container:delete() + assert.is_nil(windowType("gsbNested")) + assert.is_nil(Geyser.parentWindows.gsbNested) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserStyleSheet_spec.lua b/src/mudlet-lua/tests/GeyserStyleSheet_spec.lua index 6e21db07d..db785a142 100644 --- a/src/mudlet-lua/tests/GeyserStyleSheet_spec.lua +++ b/src/mudlet-lua/tests/GeyserStyleSheet_spec.lua @@ -303,4 +303,39 @@ describe("Tests functionality of Geyser.StyleSheet", function() assert.equal(expected, actual) end) end) -end) \ No newline at end of file + + -- The blocks above work on the stylesheet object alone; this one puts an + -- assembled sheet onto a real widget and reads it back out of Mudlet. + describe("Tests applying a Geyser.StyleSheet to a widget", function() + local label + + before_each(function() + label = Geyser.Label:new({name = "gssLabel", x = 0, y = 0, width = 100, height = 50}) + end) + + after_each(function() + if label and Geyser.windowList.gssLabel == label then + label:delete() + end + label = nil + end) + + it("applies an inherited stylesheet to a label", function() + local parent = Geyser.StyleSheet:new("background-color: black;\ncolor: green;") + local child = Geyser.StyleSheet:new("color: blue;", parent) + label:setStyleSheet(child:getCSS()) + local applied = getLabelStyleSheet("gssLabel") + assert.is_truthy(applied:find("background-color: black;", 1, true)) + assert.is_truthy(applied:find("color: blue;", 1, true)) + assert.is_nil(applied:find("color: green;", 1, true)) + end) + + it("follows a change made to the parent sheet after the fact", function() + local parent = Geyser.StyleSheet:new("background-color: black;") + local child = Geyser.StyleSheet:new("color: blue;", parent) + parent:set("border", "1px solid white") + label:setStyleSheet(child:getCSS()) + assert.is_truthy(getLabelStyleSheet("gssLabel"):find("border: 1px solid white;", 1, true)) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserUserWindow_spec.lua b/src/mudlet-lua/tests/GeyserUserWindow_spec.lua new file mode 100644 index 000000000..b941c1a7d --- /dev/null +++ b/src/mudlet-lua/tests/GeyserUserWindow_spec.lua @@ -0,0 +1,584 @@ +-- A Geyser.UserWindow is a Geyser.MiniConsole living in a dock widget of its +-- own. getWindowGeometry reports that dock, which is what move()/resize() drive, +-- while getUserWindowSize reports the usable area inside it. How much of the +-- dock that area leaves out is a platform matter: Qt draws a floating dock's +-- title bar and frame itself on X11 and Wayland, so there they are taken out of +-- the usable area, while Windows and macOS let the window manager decorate the +-- dock and so draw them outside it, leaving the whole dock usable. The usable +-- area is therefore never bigger than the dock, but only strictly shorter than +-- it where Qt draws the title bar. No size difference is hardcoded here. +-- +-- Geyser gives every user window an extra root container named +-- "<name>Container" whose size tracks the real user window; the user window is +-- that container's only child, which is why it is not in Geyser.windowList +-- itself. +local dockDecoratedByWindowManager = getOS() == "windows" or getOS() == "mac" + +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +-- Selects the line last written by a newline terminated echo. getLineCount +-- returns the index of the last line rather than a count, and the trailing +-- newline leaves the cursor on that still empty line, so the text is one above. +local function lastLine(name) + local index = getLineCount(name) - 1 + assert.is_true(index >= 0, "nothing has been echoed to " .. name .. " yet") + moveCursor(name, 0, index) + selectCurrentLine(name) + return getCurrentLine(name) +end + +describe("Tests functionality of Geyser.UserWindow", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.UserWindow:new/new2", function() + it("opens a user window at the geometry it was given", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwNew", x = 20, y = 30, width = 300, height = 200})) + assert.are.equal("userwindow", userWindow.type) + assert.are.equal("userwindow", windowType("guwNew")) + assert.are.same({x = 20, y = 30, width = 300, height = 200}, geometry("guwNew")) + assert.is_true(windowVisible("guwNew")) + end) + + it("resets its own constraints to fill the window it opened", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwFilled", x = 10, y = 10, width = 250, height = 180})) + assert.are.equal("0px", userWindow.x) + assert.are.equal("0px", userWindow.y) + assert.are.equal("100%", userWindow.width) + assert.are.equal("100%", userWindow.height) + local usableWidth, usableHeight = getUserWindowSize("guwFilled") + assert.are.equal(usableWidth, userWindow:get_width()) + assert.are.equal(usableHeight, userWindow:get_height()) + end) + + it("gives itself a root container sized to the user window", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwRoot", x = 10, y = 10, width = 300, height = 200})) + local container = userWindow.container + assert.are.equal("guwRootContainer", container.name) + assert.are.equal(container, Geyser.windowList.guwRootContainer) + -- the user window belongs to that container, not to the root window list + assert.is_nil(Geyser.windowList.guwRoot) + assert.are.equal(userWindow, container.windowList.guwRoot) + -- that the container really is sized to the user window is read off a + -- child widget filling it, rather than off the same getter the container + -- was given as its own get_width/get_height + track(Geyser.Label:new({name = "guwRootChild", x = 0, y = 0, width = "100%", height = "100%"}, userWindow)) + local usableWidth, usableHeight = getUserWindowSize("guwRoot") + assert.are.same({x = 0, y = 0, width = usableWidth, height = usableHeight}, geometry("guwRootChild")) + -- the dock is the size it was asked for, and the usable area is real and + -- inside it - by however much this platform's dock decoration costs + local dock = geometry("guwRoot") + assert.are.equal(300, dock.width) + assert.are.equal(200, dock.height) + assert.is_true(usableWidth > 0 and usableWidth <= dock.width, + string.format("usable width %d is not inside the dock width %d", usableWidth, dock.width)) + assert.is_true(usableHeight > 0 and usableHeight <= dock.height, + string.format("usable height %d is not inside the dock height %d", usableHeight, dock.height)) + if not dockDecoratedByWindowManager then + assert.is_true(usableHeight < dock.height, + string.format("Qt draws the dock title bar here, so it must cost height: usable %d, dock %d", + usableHeight, dock.height)) + end + end) + + it("registers itself as a parent window so children can be put in it", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwParent", x = 10, y = 10, width = 200, height = 150})) + assert.are.equal(userWindow, Geyser.parentWindows.guwParent) + assert.are.equal("guwParent", userWindow.windowname) + end) + + it("defaults to an undocked, auto docking window that does not restore a layout", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwDefaults", x = 10, y = 10, width = 200, height = 150})) + assert.is_false(userWindow.docked) + assert.is_false(userWindow.restoreLayout) + assert.is_true(userWindow.autoDock) + assert.are.equal("floating", userWindow.dockPosition) + end) + + it("takes the font size and wrap it was given", function() + track(Geyser.UserWindow:new({name = "guwFont", x = 10, y = 10, width = 250, height = 180, fontSize = 12, wrapAt = 40})) + assert.are.equal(12, getFontSize("guwFont")) + assert.are.equal(40, getWindowWrap("guwFont")) + end) + + -- Ubuntu Mono is asked for rather than a system font like Courier New: + -- Mudlet ships and loads it itself, so it is there to be had on every + -- platform, where a bare Linux CI image has no Courier New and Qt quietly + -- substitutes the nearest match. It is also not the console default + -- (Bitstream Vera Sans Mono), so a font that never reached the widget still + -- fails this. + it("takes the font family it was given", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwFamily", x = 10, y = 10, width = 250, height = 180, font = "Ubuntu Mono"})) + assert.are.equal("Ubuntu Mono", getFont("guwFamily")) + assert.are.equal("Ubuntu Mono", userWindow.font) + end) + + it("derives an auto wrap from its usable width", function() + track(Geyser.UserWindow:new({name = "guwAutoWrap", x = 10, y = 10, width = 300, height = 200, wrapAt = "auto"})) + local usableWidth = getUserWindowSize("guwAutoWrap") + local charWidth = calcFontSize("guwAutoWrap") + assert.are.equal(math.floor(usableWidth / charWidth), getWindowWrap("guwAutoWrap")) + end) + + -- Mudlet cannot report whether the scroll bar is on screen, but + -- Geyser.MiniConsole:resetAutoWrap keeps 15 pixels clear for one when it + -- is, so an auto wrapping console wraps that much earlier - which is + -- readable, and is what proves the constraint reached the widget. + it("keeps room for the scroll bar it was asked for when wrapping", function() + track(Geyser.UserWindow:new({name = "guwScrollBar", x = 10, y = 10, width = 300, height = 200, wrapAt = "auto", scrollBar = true})) + local usableWidth = getUserWindowSize("guwScrollBar") + local charWidth = calcFontSize("guwScrollBar") + assert.are.equal(math.floor((usableWidth - 15) / charWidth), getWindowWrap("guwScrollBar")) + end) + + it("ignores the geometry it was given when it is asked to start docked", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwDocked", x = 10, y = 10, width = 300, height = 200, docked = true})) + -- a docked window keeps the dock position it was opened with instead of + -- being floated, and the dock area decides its geometry, not the + -- constraints, so only the position it kept is asserted here + assert.are.equal("r", userWindow.dockPosition) + assert.are.equal("userwindow", windowType("guwDocked")) + assert.is_true(windowVisible("guwDocked")) + end) + + -- A window that is about to be floated must not be docked on the way there: + -- docking takes the dock's size off the main window, and the percentage + -- constraints the constructor resolves straight afterwards are measured + -- against the main window. Which dock position was asked for is what says + -- so; how much the main window shrinks by, and when, is Qt's business and + -- is not the same on every platform. + it("opens a window it is going to float as floating, not docked first", function() + local openWindow = spy.on(_G, "openUserWindow") + finally(function() openWindow:revert() end) + local mainWidth, mainHeight = getMainWindowSize() + track(Geyser.UserWindow:new({name = "guwPercent", x = "25%", y = "10%", width = "30%", height = "30%"})) + assert.spy(openWindow).was.called_with("guwPercent", false, true, "floating") + assert.are.same({ + x = math.floor(mainWidth * 0.25), + y = math.floor(mainHeight * 0.1), + width = math.floor(mainWidth * 0.3), + height = math.floor(mainHeight * 0.3), + }, geometry("guwPercent")) + end) + + it("still docks a window that was asked to start docked", function() + local openWindow = spy.on(_G, "openUserWindow") + finally(function() openWindow:revert() end) + local userWindow = track(Geyser.UserWindow:new({name = "guwStaysDocked", x = 10, y = 10, width = 300, height = 200, docked = true, dockPosition = "left"})) + assert.spy(openWindow).was.called_with("guwStaysDocked", false, true, "left") + -- a docked window keeps the position it was opened with, where a floated + -- one has its dockPosition rewritten to "floating" + assert.are.equal("left", userWindow.dockPosition) + assert.is_true(windowVisible("guwStaysDocked")) + end) + + it("new2 marks the user window as using add2", function() + local userWindow = track(Geyser.UserWindow:new2({name = "guwNew2", x = 10, y = 10, width = 200, height = 150})) + assert.is_true(userWindow.useAdd2) + assert.are.equal("userwindow", windowType("guwNew2")) + end) + + it("reopens a user window that was opened under the same name before", function() + local first = track(Geyser.UserWindow:new({name = "guwReused", x = 10, y = 10, width = 200, height = 150})) + local trackedWindows = #Geyser.windows + first:delete() + local second = track(Geyser.UserWindow:new({name = "guwReused", x = 40, y = 50, width = 260, height = 190})) + assert.are.same({x = 40, y = 50, width = 260, height = 190}, geometry("guwReused")) + assert.are.equal(trackedWindows, #Geyser.windows) + assert.are.equal("guwReusedContainer", second.container.name) + end) + end) + + describe("Geyser.UserWindow:move/resize", function() + local userWindow + + before_each(function() + userWindow = track(Geyser.UserWindow:new({name = "guwMove", x = 10, y = 20, width = 300, height = 200})) + end) + + it("moves and resizes the dock", function() + userWindow:move(60, 70) + userWindow:resize(320, 210) + assert.are.same({x = 60, y = 70, width = 320, height = 210}, geometry("guwMove")) + end) + + it("goes back to filling itself after a move", function() + userWindow:move(60, 70) + assert.are.equal("0px", userWindow.x) + assert.are.equal("100%", userWindow.width) + local usableWidth = getUserWindowSize("guwMove") + assert.are.equal(usableWidth, userWindow:get_width()) + end) + + it("re-resolves the size of percentage children when it is resized", function() + track(Geyser.Label:new({name = "guwMoveChild", x = 0, y = 0, width = "50%", height = "100%"}, userWindow)) + local firstWidth, firstHeight = getUserWindowSize("guwMove") + assert.are.same({x = 0, y = 0, width = math.floor(firstWidth / 2), height = firstHeight}, geometry("guwMoveChild")) + userWindow:resize(400, 260) + local secondWidth, secondHeight = getUserWindowSize("guwMove") + assert.is_true(secondWidth > firstWidth) + assert.are.same({x = 0, y = 0, width = math.floor(secondWidth / 2), height = secondHeight}, geometry("guwMoveChild")) + end) + end) + + describe("Geyser.UserWindow children", function() + local userWindow + + before_each(function() + userWindow = track(Geyser.UserWindow:new({name = "guwHolder", x = 10, y = 20, width = 300, height = 200})) + end) + + it("creates children inside the user window, sized against its usable area", function() + local label = track(Geyser.Label:new({name = "guwLabel", x = "50%", y = 0, width = "50%", height = "100%"}, userWindow)) + assert.are.equal("guwHolder", label.windowname) + local usableWidth, usableHeight = getUserWindowSize("guwHolder") + assert.are.same({ + x = math.floor(usableWidth / 2), + y = 0, + width = math.floor(usableWidth / 2), + height = usableHeight, + }, geometry("guwLabel")) + assert.is_true(windowVisible("guwLabel")) + end) + + it("holds a command line of its own", function() + track(Geyser.CommandLine:new({name = "guwCmdLine", x = 0, y = 0, width = 80, height = 20}, userWindow)) + assert.are.equal("commandline", windowType("guwCmdLine")) + assert.are.same({x = 0, y = 0, width = 80, height = 20}, geometry("guwCmdLine")) + end) + + it("holds a scroll box, which becomes a parent window inside it", function() + local scrollBox = track(Geyser.ScrollBox:new({name = "guwScrollBox", x = 0, y = 0, width = "100%", height = "50%"}, userWindow)) + assert.are.equal("scrollbox", windowType("guwScrollBox")) + -- the scroll box takes over as parent window, remembering the user window + assert.are.equal("guwScrollBox", scrollBox.windowname) + assert.are.equal("guwHolder", scrollBox.parentWindowName) + local usableWidth, usableHeight = getUserWindowSize("guwHolder") + assert.are.same({x = 0, y = 0, width = usableWidth, height = math.floor(usableHeight / 2)}, geometry("guwScrollBox")) + -- and a child of the scroll box is placed in the scroll box's own space + local label = track(Geyser.Label:new({name = "guwScrollBoxLabel", x = 0, y = 0, width = "50%", height = "100%"}, scrollBox)) + assert.are.equal("guwScrollBox", label.windowname) + assert.are.equal(math.floor(usableWidth / 2), geometry("guwScrollBoxLabel").width) + end) + + it("deletes its children with itself", function() + track(Geyser.Label:new({name = "guwDoomedLabel", x = 0, y = 0, width = 20, height = 20}, userWindow)) + userWindow:delete() + assert.is_nil(windowType("guwHolder")) + assert.is_nil(windowType("guwDoomedLabel")) + end) + end) + + describe("Geyser.UserWindow echo", function() + it("echoes into the console the user window contains", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwEcho", x = 10, y = 20, width = 300, height = 200})) + userWindow:echo("into the user window\n") + assert.are.equal("into the user window\n", userWindow.message) + assert.are.equal("into the user window", lastLine("guwEcho")) + end) + end) + + describe("Geyser.UserWindow:hide/show", function() + it("hides and shows the dock", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwHide", x = 10, y = 20, width = 200, height = 150})) + userWindow:hide() + assert.is_true(userWindow.hidden) + assert.is_false(windowVisible("guwHide")) + userWindow:show() + assert.is_false(userWindow.hidden) + assert.is_true(windowVisible("guwHide")) + end) + + it("hides the children in it along with the dock", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwHideChild", x = 10, y = 20, width = 200, height = 150})) + track(Geyser.Label:new({name = "guwHiddenLabel", x = 0, y = 0, width = "100%", height = "100%"}, userWindow)) + userWindow:hide() + assert.is_false(windowVisible("guwHiddenLabel")) + userWindow:show() + assert.is_true(windowVisible("guwHiddenLabel")) + end) + end) + + -- Geyser.UserWindow:show() forwards to its parent, and has to pass on the + -- `auto` flag its container hands down: the base class picks which of the two + -- hidden bits to clear from it. Without the flag an automatic show clears + -- self.hidden as if the user had asked for it, and never clears auto_hidden. + describe("Geyser.UserWindow show cascade", function() + it("stays hidden when its root container is shown after a hand hide", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwHandHidden", x = 10, y = 20, width = 200, height = 150})) + userWindow:hide() + assert.is_true(userWindow.hidden) + assert.is_false(windowVisible("guwHandHidden")) + userWindow.container:show() + assert.is_true(userWindow.hidden) + assert.is_false(windowVisible("guwHandHidden")) + end) + + it("comes back when the container that hid it is shown again", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwAutoHidden", x = 10, y = 20, width = 200, height = 150})) + userWindow.container:hide() + assert.is_true(userWindow.auto_hidden) + assert.is_false(windowVisible("guwAutoHidden")) + userWindow.container:show() + assert.is_false(userWindow.auto_hidden) + assert.is_true(windowVisible("guwAutoHidden")) + end) + end) + + describe("Geyser.UserWindow:setTitle/resetTitle", function() + -- setUserWindowTitle answers nil and a message rather than raising when it + -- cannot find the window, so the return value is what says the title + -- reached the right dock; without it a wrapper naming the wrong window + -- would still leave titleText looking right. + it("remembers the title it was given, and empties it again on reset", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwTitle", x = 10, y = 20, width = 200, height = 150, titleText = "My window"})) + assert.are.equal("My window", userWindow.titleText) + assert.is_true(userWindow:setTitle("Renamed")) + assert.are.equal("Renamed", userWindow.titleText) + assert.is_true(userWindow:resetTitle()) + assert.are.equal("", userWindow.titleText) + end) + + it("starts with an empty title when it was not given one", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwNoTitle", x = 10, y = 20, width = 200, height = 150})) + assert.are.equal("", userWindow.titleText) + end) + end) + + pending("Geyser.UserWindow:setTitle/resetTitle put the text on the dock's title bar - needs a getUserWindowTitle getter") + + describe("Geyser.UserWindow:setStyleSheet", function() + it("remembers the stylesheet it was given", function() + local userWindow = track(Geyser.UserWindow:new({ + name = "guwCss", + x = 10, y = 20, width = 200, height = 150, + stylesheet = "border: 1px solid red;", + })) + assert.are.equal("border: 1px solid red;", userWindow.stylesheet) + userWindow:setStyleSheet("background-color: green;") + assert.are.equal("background-color: green;", userWindow.stylesheet) + end) + end) + + pending("Geyser.UserWindow:setStyleSheet applies the stylesheet to the dock - needs a getUserWindowStyleSheet getter") + + pending("Geyser.UserWindow scrollBar constraint puts a scroll bar on screen - the wrap it leaves room for is covered above, but the scroll bar's own visibility is not readable from Lua and needs a scroll bar getter") + + describe("Geyser.UserWindow:enableAutoDock/disableAutoDock", function() + it("turns automatic docking off and on again without disturbing the window", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwAutoDock", x = 10, y = 20, width = 300, height = 200})) + assert.is_true(userWindow:disableAutoDock()) + assert.is_false(userWindow.autoDock) + -- both of these reopen the window, so the dock must survive them intact + assert.is_true(windowVisible("guwAutoDock")) + assert.are.same({x = 10, y = 20, width = 300, height = 200}, geometry("guwAutoDock")) + assert.is_true(userWindow:enableAutoDock()) + assert.is_true(userWindow.autoDock) + assert.is_true(windowVisible("guwAutoDock")) + assert.are.same({x = 10, y = 20, width = 300, height = 200}, geometry("guwAutoDock")) + end) + end) + + pending("Geyser.UserWindow:setDockPosition - which edge a user window ended up docked to, and whether it docks by itself when dragged, are not readable from Lua") + + -- restoreLayout = true makes the constructor reopen the window from the + -- layout saved in the profile and skip the move/resize it was given. Running + -- that here would write this file's window layouts into the shared self-test + -- profile, so repeat runs against the same profile would stop matching. + pending("Geyser.UserWindow restoreLayout reopens the window where it was last left") + + describe("Geyser.UserWindow:delete", function() + it("closes the dock and unregisters the user window", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwDelete", x = 10, y = 20, width = 200, height = 150})) + assert.are.equal("userwindow", windowType("guwDelete")) + userWindow:delete() + assert.is_nil(windowType("guwDelete")) + assert.is_nil(getWindowGeometry("guwDelete")) + assert.is_nil(Geyser.parentWindows.guwDelete) + end) + end) + + -- Geyser.Container:new makes the "<name>Container" root container for a user + -- window. An orphaned one is not inert: its get_width/get_height ask + -- getUserWindowSize for a window that is gone, which falls back to the main + -- window size, so every leftover claims the whole main window in every + -- layout pass. + describe("Geyser.UserWindow root container cleanup", function() + it("removes the root container it created", function() + local trackedWindows = #Geyser.windows + local userWindow = track(Geyser.UserWindow:new({name = "guwRootGone", x = 10, y = 20, width = 200, height = 150})) + assert.are.equal(userWindow.container, Geyser.windowList.guwRootGoneContainer) + userWindow:delete() + assert.is_nil(Geyser.windowList.guwRootGoneContainer) + assert.is_nil(table.index_of(Geyser.windows, "guwRootGoneContainer")) + assert.are.equal(trackedWindows, #Geyser.windows) + end) + + it("leaves nothing behind over repeated create and delete cycles", function() + local trackedWindows = #Geyser.windows + for index = 1, 5 do + Geyser.UserWindow:new({name = "guwCycle" .. index, x = 10, y = 20, width = 200, height = 150}):delete() + end + assert.are.equal(trackedWindows, #Geyser.windows) + end) + + -- anything else the user put in the root container is still using it, so it + -- has to survive the user window being deleted out of it + it("leaves a root container that still holds something else", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwRootShared", x = 10, y = 20, width = 200, height = 150})) + local root = userWindow.container + local lodger = track(Geyser.Label:new({name = "guwRootLodger", x = 0, y = 0, width = 20, height = 20}, root)) + userWindow:delete() + assert.are.equal(root, Geyser.windowList.guwRootSharedContainer) + assert.are.equal(lodger, root.windowList.guwRootLodger) + root:delete() + assert.is_nil(Geyser.windowList.guwRootSharedContainer) + end) + + -- a user window moved out of the root container Geyser made for it still + -- has to take that container with it, and the container is no longer the + -- one the user window reports as its own + it("removes the root container even after the user window was moved out of it", function() + local elsewhere = track(Geyser.Container:new({name = "guwNewHome", x = 0, y = 0, width = 200, height = 200})) + local userWindow = track(Geyser.UserWindow:new({name = "guwMovedOut", x = 10, y = 20, width = 200, height = 150})) + userWindow:changeContainer(elsewhere) + assert.are.equal(elsewhere, userWindow.container) + userWindow:delete() + assert.is_nil(Geyser.windowList.guwMovedOutContainer) + end) + + -- deleting the root container deletes the user window inside it, which + -- reaches back for the root container it is being deleted by, so that + -- cascade has to come apart cleanly rather than recursing + it("comes apart cleanly when the root container is the one deleted", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwRootKept", x = 10, y = 20, width = 200, height = 150})) + local root = userWindow.container + track(Geyser.Label:new({name = "guwRootKeptLabel", x = 0, y = 0, width = 20, height = 20}, userWindow)) + assert.are.equal(root, Geyser.windowList.guwRootKeptContainer) + root:delete() + assert.is_nil(Geyser.windowList.guwRootKeptContainer) + assert.is_nil(windowType("guwRootKept")) + assert.is_nil(windowType("guwRootKeptLabel")) + end) + end) +end) + +-- set_uwconstr is what move() and resize() call before they touch the dock: it +-- re-reads the window's own x/y/width/height against the main window rather +-- than against the user window's insides, which is what every other Geyser +-- object's set_constraints does. resetWindow() puts the usual behaviour back, +-- so the two are specced against each other here. +describe("Tests Geyser.UserWindow:set_uwconstr", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + it("resolves percentages against the main window", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwConstr", x = 10, y = 20, width = 200, height = 150})) + local mainWidth, mainHeight = getMainWindowSize() + + userWindow.x, userWindow.y = "50%", "25%" + userWindow.width, userWindow.height = "20%", "10%" + userWindow:set_uwconstr() + + assert.are.equal(mainWidth * 0.5, userWindow:get_x()) + assert.are.equal(mainHeight * 0.25, userWindow:get_y()) + assert.are.equal(mainWidth * 0.2, userWindow:get_width()) + assert.are.equal(mainHeight * 0.1, userWindow:get_height()) + end) + + it("resolves pixels as pixels", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwConstrPixels", x = 10, y = 20, width = 200, height = 150})) + + userWindow.x, userWindow.y = 120, 130 + userWindow.width, userWindow.height = 240, 260 + userWindow:set_uwconstr() + + assert.are.equal(120, userWindow:get_x()) + assert.are.equal(130, userWindow:get_y()) + assert.are.equal(240, userWindow:get_width()) + assert.are.equal(260, userWindow:get_height()) + end) + + it("is undone by resetWindow, which sizes the window against itself again", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwConstrReset", x = 10, y = 20, width = 200, height = 150})) + local mainWidth = getMainWindowSize() + + userWindow.width = "100%" + userWindow:set_uwconstr() + assert.are.equal(mainWidth, userWindow:get_width()) + + userWindow:resetWindow() + -- back to filling itself: "100%" now means the usable area inside the dock, + -- which is nothing like the main window's width + local usableWidth = getUserWindowSize("guwConstrReset") + assert.are.equal(usableWidth, userWindow:get_width()) + assert.is_true(usableWidth < mainWidth) + end) + + it("is what move and resize position the dock with", function() + local userWindow = track(Geyser.UserWindow:new({name = "guwConstrMove", x = 10, y = 20, width = 200, height = 150})) + + -- move()/resize() hand their arguments to set_uwconstr and then move the + -- real dock to what it worked out, so a percentage has to land on the same + -- pixel that set_uwconstr resolves it to + userWindow.x, userWindow.y = "10%", "10%" + userWindow:set_uwconstr() + local expectedX, expectedY = userWindow:get_x(), userWindow:get_y() + + userWindow:move("10%", "10%") + local x, y = getWindowGeometry("guwConstrMove") + assert.are.equal(math.floor(expectedX), x) + assert.are.equal(math.floor(expectedY), y) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserVBox_spec.lua b/src/mudlet-lua/tests/GeyserVBox_spec.lua new file mode 100644 index 000000000..9d152581a --- /dev/null +++ b/src/mudlet-lua/tests/GeyserVBox_spec.lua @@ -0,0 +1,270 @@ +-- A VBox stacks its children top to bottom by rewriting their constraints as +-- percentages of the box, so the pixel expectations below are the box geometry +-- divided by the shares each child is entitled to. +local function geometry(name) + local x, y, width, height = getWindowGeometry(name) + return {x = x, y = y, width = width, height = height} +end + +describe("Tests functionality of Geyser.VBox", function() + local created + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.VBox:new/new2", function() + it("defaults the type to VBox and starts empty", function() + local box = track(Geyser.VBox:new({name = "gvbNew", x = 0, y = 0, width = 100, height = 100})) + assert.are.equal("VBox", box.type) + assert.are.same({}, box.windows) + -- a box is a container, so it has no widget of its own + assert.is_nil(getWindowGeometry("gvbNew")) + end) + + it("new2 marks the box as using add2", function() + local box = track(Geyser.VBox:new2({name = "gvbNew2", x = 0, y = 0, width = 100, height = 100})) + assert.is_true(box.useAdd2) + assert.are.equal("VBox", box.type) + end) + + it("stacks the children of a new2 box the same way new does", function() + local box = track(Geyser.VBox:new2({name = "gvbNew2Layout", x = 0, y = 0, width = 200, height = 200})) + -- the children arrive through add2 rather than add + track(Geyser.Label:new2({name = "gvbNew2A", x = 10, y = 10, width = 300, height = 200}, box)) + track(Geyser.Label:new2({name = "gvbNew2B", x = 10, y = 10, width = 300, height = 200}, box)) + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gvbNew2A")) + assert.are.same({x = 0, y = 100, width = 200, height = 100}, geometry("gvbNew2B")) + end) + + it("lays out a new2 box nested in another new2 box", function() + local outer = track(Geyser.HBox:new2({name = "gvbNestedOuter", x = 0, y = 0, width = 200, height = 200})) + local inner = track(Geyser.VBox:new2({name = "gvbNestedInner"}, outer)) + track(Geyser.Label:new2({name = "gvbNestedSibling"}, outer)) + track(Geyser.Label:new2({name = "gvbNestedA"}, inner)) + track(Geyser.Label:new2({name = "gvbNestedB"}, inner)) + -- the inner box takes half the outer one, and splits it between its own two + assert.are.same({x = 0, y = 0, width = 100, height = 100}, geometry("gvbNestedA")) + assert.are.same({x = 0, y = 100, width = 100, height = 100}, geometry("gvbNestedB")) + assert.are.same({x = 100, y = 0, width = 100, height = 200}, geometry("gvbNestedSibling")) + end) + end) + + describe("Geyser.VBox:add/organize", function() + it("gives a single child the whole box", function() + local box = track(Geyser.VBox:new({name = "gvbOne", x = 10, y = 20, width = 200, height = 100})) + track(Geyser.Label:new({name = "gvbOneChild"}, box)) + assert.are.same({x = 10, y = 20, width = 200, height = 100}, geometry("gvbOneChild")) + end) + + it("splits the box evenly between two children", function() + local box = track(Geyser.VBox:new({name = "gvbTwo", x = 0, y = 0, width = 200, height = 200})) + track(Geyser.Label:new({name = "gvbTwoA"}, box)) + track(Geyser.Label:new({name = "gvbTwoB"}, box)) + assert.are.same({x = 0, y = 0, width = 200, height = 100}, geometry("gvbTwoA")) + assert.are.same({x = 0, y = 100, width = 200, height = 100}, geometry("gvbTwoB")) + end) + + it("re-splits the box when another child is added", function() + local box = track(Geyser.VBox:new({name = "gvbThree", x = 50, y = 60, width = 100, height = 100})) + track(Geyser.Label:new({name = "gvbThreeA"}, box)) + track(Geyser.Label:new({name = "gvbThreeB"}, box)) + assert.are.equal(50, geometry("gvbThreeA").height) + track(Geyser.Label:new({name = "gvbThreeC"}, box)) + -- a third of the box does not divide into whole pixels, and Mudlet + -- truncates the pixel values it is handed + for index, name in ipairs({"gvbThreeA", "gvbThreeB", "gvbThreeC"}) do + local expectedY = math.floor(60 + (index - 1) * 100 / 3) + assert.are.same({x = 50, y = expectedY, width = 100, height = 33}, geometry(name)) + end + end) + + it("stretches children over the full width of the box", function() + local box = track(Geyser.VBox:new({name = "gvbWide", x = 0, y = 0, width = 240, height = 100})) + track(Geyser.Label:new({name = "gvbWideChild", width = 20}, box)) + assert.are.equal("100%", box.windowList.gvbWideChild.width) + assert.are.equal(240, geometry("gvbWideChild").width) + end) + + it("keeps a fixed height child at its size and splits the rest", function() + local box = track(Geyser.VBox:new({name = "gvbFixed", x = 0, y = 0, width = 200, height = 300})) + track(Geyser.Label:new({name = "gvbFixedChild", height = 60, v_policy = Geyser.Fixed}, box)) + track(Geyser.Label:new({name = "gvbDynamicA"}, box)) + track(Geyser.Label:new({name = "gvbDynamicB"}, box)) + assert.is_true(box.contains_fixed) + assert.are.same({x = 0, y = 0, width = 200, height = 60}, geometry("gvbFixedChild")) + assert.are.same({x = 0, y = 60, width = 200, height = 120}, geometry("gvbDynamicA")) + assert.are.same({x = 0, y = 180, width = 200, height = 120}, geometry("gvbDynamicB")) + end) + + it("gives a stretch factor its extra share of the height", function() + local box = track(Geyser.VBox:new({name = "gvbStretch", x = 0, y = 0, width = 200, height = 400})) + track(Geyser.Label:new({name = "gvbStretchA", v_stretch_factor = 3}, box)) + track(Geyser.Label:new({name = "gvbStretchB"}, box)) + -- three shares against one out of a four share pool + assert.are.same({x = 0, y = 0, width = 200, height = 300}, geometry("gvbStretchA")) + assert.are.same({x = 0, y = 300, width = 200, height = 100}, geometry("gvbStretchB")) + end) + end) + + -- The box lays itself out when a child arrives, and has to do the same when + -- one leaves: without it the survivors keep the geometry computed for the old + -- child count and the box is left with a permanent hole. contains_fixed is + -- false for a box of plain labels, so reposition() does not heal it either. + describe("Geyser.VBox:remove", function() + local box + + before_each(function() + box = track(Geyser.VBox:new({name = "gvbShrink", x = 0, y = 0, width = 50, height = 600})) + track(Geyser.Label:new({name = "gvbShrinkA"}, box)) + track(Geyser.Label:new({name = "gvbShrinkB"}, box)) + end) + + it("re-stacks the column when a child is deleted", function() + local third = track(Geyser.Label:new({name = "gvbShrinkC"}, box)) + third:delete() + assert.are.same({"gvbShrinkA", "gvbShrinkB"}, box.windows) + assert.are.same({x = 0, y = 0, width = 50, height = 300}, geometry("gvbShrinkA")) + assert.are.same({x = 0, y = 300, width = 50, height = 300}, geometry("gvbShrinkB")) + end) + + it("re-stacks the column when a child is removed by hand", function() + box:remove(box.windowList.gvbShrinkB) + assert.are.same({"gvbShrinkA"}, box.windows) + assert.are.same({x = 0, y = 0, width = 50, height = 600}, geometry("gvbShrinkA")) + end) + + it("re-stacks the column a child left for another container", function() + local elsewhere = track(Geyser.Container:new({name = "gvbElsewhere", x = 100, y = 0, width = 100, height = 100})) + box.windowList.gvbShrinkB:changeContainer(elsewhere) + assert.are.same({"gvbShrinkA"}, box.windows) + assert.are.same({x = 0, y = 0, width = 50, height = 600}, geometry("gvbShrinkA")) + end) + + -- an emptied box has no children to divide its height between, and + -- organize() still has to come through that without raising + it("survives losing its last child", function() + assert.has_no.errors(function() + box:remove(box.windowList.gvbShrinkA) + box:remove(box.windowList.gvbShrinkB) + end) + assert.are.same({}, box.windows) + end) + + it("holds the layout back while updates are deferred", function() + local third = track(Geyser.Label:new({name = "gvbShrinkDeferred"}, box)) + local heightOfThree = geometry("gvbShrinkA").height + box.defer_updates = true + third:delete() + assert.are.equal(heightOfThree, geometry("gvbShrinkA").height) + box.defer_updates = false + box:reposition() + assert.are.equal(300, geometry("gvbShrinkA").height) + end) + + it("deletes a box that still holds children", function() + assert.has_no.errors(function() box:delete() end) + assert.is_nil(getWindowGeometry("gvbShrinkA")) + assert.is_nil(getWindowGeometry("gvbShrinkB")) + assert.is_nil(Geyser.windowList.gvbShrink) + end) + + -- one layout pass per child is what makes tearing a box down quadratic. The + -- fixed child is here because contains_fixed short circuits reposition()'s + -- check of the deferral, which could let the cost back in for boxes like it + it("does not stack the column again for each child it deletes", function() + track(Geyser.Label:new({name = "gvbShrinkCostFixed", height = 100, v_policy = Geyser.Fixed}, box)) + for i = 1, 3 do + track(Geyser.Label:new({name = "gvbShrinkCost" .. i}, box)) + end + local organizes = 0 + local organize = box.organize + box.organize = function(...) + organizes = organizes + 1 + return organize(...) + end + box:delete() + assert.are.equal(0, organizes) + assert.is_nil(getWindowGeometry("gvbShrinkA")) + assert.is_nil(getWindowGeometry("gvbShrinkCost1")) + end) + + -- the deferral belongs to the container being deleted, so a box losing a + -- whole subtree - one that defers itself on the way out - still re-stacks + it("re-stacks the column when a nested box of its own is deleted", function() + local nested = track(Geyser.HBox:new({name = "gvbShrinkNested"}, box)) + track(Geyser.Label:new({name = "gvbShrinkNestedA"}, nested)) + track(Geyser.Label:new({name = "gvbShrinkNestedB"}, nested)) + nested:delete() + assert.are.same({"gvbShrinkA", "gvbShrinkB"}, box.windows) + assert.are.same({x = 0, y = 0, width = 50, height = 300}, geometry("gvbShrinkA")) + assert.are.same({x = 0, y = 300, width = 50, height = 300}, geometry("gvbShrinkB")) + end) + end) + + describe("Geyser.VBox:reposition", function() + local box + + before_each(function() + box = track(Geyser.VBox:new({name = "gvbMove", x = 10, y = 20, width = 200, height = 200})) + track(Geyser.Label:new({name = "gvbMoveA"}, box)) + track(Geyser.Label:new({name = "gvbMoveB"}, box)) + end) + + it("drags the stack along when the box moves", function() + box:move(50, 60) + assert.are.same({x = 50, y = 60, width = 200, height = 100}, geometry("gvbMoveA")) + assert.are.same({x = 50, y = 160, width = 200, height = 100}, geometry("gvbMoveB")) + end) + + it("re-splits the stack when the box is resized", function() + box:resize(100, 100) + assert.are.same({x = 10, y = 20, width = 100, height = 50}, geometry("gvbMoveA")) + assert.are.same({x = 10, y = 70, width = 100, height = 50}, geometry("gvbMoveB")) + end) + + it("keeps a fixed child flush against its neighbour after a resize", function() + local fixedBox = track(Geyser.VBox:new({name = "gvbFixedMove", x = 0, y = 0, width = 200, height = 200})) + track(Geyser.Label:new({name = "gvbFixedMoveA", height = 50, v_policy = Geyser.Fixed}, fixedBox)) + track(Geyser.Label:new({name = "gvbFixedMoveB"}, fixedBox)) + fixedBox:resize(200, 250) + assert.are.same({x = 0, y = 0, width = 200, height = 50}, geometry("gvbFixedMoveA")) + assert.are.same({x = 0, y = 50, width = 200, height = 200}, geometry("gvbFixedMoveB")) + end) + end) + + describe("Geyser.VBox visibility", function() + it("hides and shows the whole stack", function() + local box = track(Geyser.VBox:new({name = "gvbHide", x = 0, y = 0, width = 100, height = 100})) + track(Geyser.Label:new({name = "gvbHideA"}, box)) + track(Geyser.Label:new({name = "gvbHideB"}, box)) + box:hide() + assert.is_false(windowVisible("gvbHideA")) + assert.is_false(windowVisible("gvbHideB")) + box:show() + assert.is_true(windowVisible("gvbHideA")) + assert.is_true(windowVisible("gvbHideB")) + end) + end) +end) diff --git a/src/mudlet-lua/tests/GeyserWindow_spec.lua b/src/mudlet-lua/tests/GeyserWindow_spec.lua new file mode 100644 index 000000000..2f8e041b6 --- /dev/null +++ b/src/mudlet-lua/tests/GeyserWindow_spec.lua @@ -0,0 +1,201 @@ +-- Geyser.Window is the abstract base for the Mudlet primitives that hold text. +-- Its methods are exercised through a Geyser.MiniConsole, the simplest +-- subclass that owns a real widget, and read back with the console getters. + +-- Selects the line last written by a newline terminated echo so +-- getCurrentLine/getTextFormat report on it. getLineCount returns the index of +-- the console's last line rather than a count, and the trailing newline leaves +-- the cursor on that (still empty) last line, so the text is one line above it. +local function lastLine(name) + local index = getLineCount(name) - 1 + assert.is_true(index >= 0, "nothing has been echoed to " .. name .. " yet") + moveCursor(name, 0, index) + selectCurrentLine(name) + return getCurrentLine(name) +end + +describe("Tests functionality of Geyser.Window", function() + local created + local console + + local function track(object) + created[#created + 1] = object + return object + end + + local function alive(object) + if not object or not object.container or not object.container.windowList then + return false + end + return object.container.windowList[object.name] == object + end + + before_each(function() + created = {} + console = track(Geyser.MiniConsole:new({name = "gwsConsole", x = 0, y = 0, width = 300, height = 100})) + end) + + after_each(function() + for _, object in ipairs(created) do + if alive(object) then + object:delete() + end + end + created = {} + end) + + describe("Geyser.Window:new", function() + it("defaults the type to window and owns no widget of its own", function() + local window = track(Geyser.Window:new({name = "gwsAbstract", x = 0, y = 0, width = 100, height = 100})) + assert.are.equal("window", window.type) + assert.are.equal(window, Geyser.windowList.gwsAbstract) + -- Geyser.Window is abstract: only its subclasses create a Mudlet primitive + assert.is_nil(getWindowGeometry("gwsAbstract")) + end) + + it("provides the colour defaults its subclasses inherit", function() + local window = track(Geyser.Window:new({name = "gwsColours", x = 0, y = 0, width = 10, height = 10})) + assert.are.equal("white", window.fgColor) + assert.are.equal("black", window.bgColor) + assert.are.equal("#202020", window.color) + assert.are.equal("", window.message) + end) + end) + + describe("Geyser.Window:echo/cecho/decho/hecho", function() + it("echoes plain text and remembers the message", function() + console:echo("plain line\n") + assert.are.equal("plain line\n", console.message) + assert.are.equal("plain line", lastLine("gwsConsole")) + end) + + it("cecho colours the text by name", function() + console:cecho("<green>green line\n") + assert.are.equal("green line", lastLine("gwsConsole")) + assert.are.same({0, 255, 0}, getTextFormat("gwsConsole").foreground) + end) + + it("decho colours the text by rgb triple", function() + console:decho("<0,0,255>blue line\n") + assert.are.equal("blue line", lastLine("gwsConsole")) + assert.are.same({0, 0, 255}, getTextFormat("gwsConsole").foreground) + end) + + it("hecho colours the text by hex code", function() + console:hecho("|cff0000red line\n") + assert.are.equal("red line", lastLine("gwsConsole")) + assert.are.same({255, 0, 0}, getTextFormat("gwsConsole").foreground) + end) + + it("reuses the last message when called without one", function() + console:echo("remembered\n") + console:cecho() + assert.are.equal("remembered\n", console.message) + assert.are.equal("remembered", lastLine("gwsConsole")) + end) + + it("echo without a message redisplays the stored one instead of wiping it", function() + console:echo("kept\n") + local lines = getLineCount("gwsConsole") + console:echo() + assert.are.equal("kept\n", console.message) + -- the line count pins that it was written again, not merely left alone + assert.are.equal(lines + 1, getLineCount("gwsConsole")) + assert.are.equal("kept", lastLine("gwsConsole")) + end) + end) + + describe("Geyser.Window:getFgColor/getBgColor/setBgColor/setFgColor", function() + it("round-trips the foreground colour", function() + console:setFgColor(255, 0, 0) + console:echo("coloured\n") + lastLine("gwsConsole") + assert.are.same({255, 0, 0}, getTextFormat("gwsConsole").foreground) + local red, green, blue = console:getFgColor() + assert.are.same({255, 0, 0}, {red, green, blue}) + end) + + it("round-trips the background colour", function() + console:setBgColor(0, 0, 255) + console:echo("coloured\n") + lastLine("gwsConsole") + assert.are.same({0, 0, 255}, getTextFormat("gwsConsole").background) + local red, green, blue = console:getBgColor() + assert.are.same({0, 0, 255}, {red, green, blue}) + end) + + it("accepts a colour name", function() + console:setFgColor("green") + console:echo("named\n") + lastLine("gwsConsole") + assert.are.same({0, 255, 0}, getTextFormat("gwsConsole").foreground) + end) + + it("accepts a hex colour", function() + console:setFgColor("#ff00ff") + console:echo("hexed\n") + lastLine("gwsConsole") + assert.are.same({255, 0, 255}, getTextFormat("gwsConsole").foreground) + end) + end) + + describe("Geyser.Window:setTextFormat/setBold/setUnderline/setItalics", function() + it("starts out with no attributes set", function() + console:echo("first\n") + lastLine("gwsConsole") + local format = getTextFormat("gwsConsole") + assert.is_false(format.bold) + assert.is_false(format.italic) + assert.is_false(format.underline) + end) + + it("turns bold, italics and underline on and off again", function() + console:setBold(true) + console:setItalics(true) + console:setUnderline(true) + console:echo("styled\n") + lastLine("gwsConsole") + local styled = getTextFormat("gwsConsole") + assert.is_true(styled.bold) + assert.is_true(styled.italic) + assert.is_true(styled.underline) + console:setBold(false) + console:setItalics(false) + console:setUnderline(false) + console:echo("plain\n") + lastLine("gwsConsole") + local plain = getTextFormat("gwsConsole") + assert.is_false(plain.bold) + assert.is_false(plain.italic) + assert.is_false(plain.underline) + end) + + it("sets both colours and all attributes at once", function() + -- the first colour triple is the background and the second the + -- foreground, matching Mudlet's setTextFormat + console:setTextFormat(10, 20, 30, 200, 100, 50, true, true, true) + console:echo("formatted\n") + lastLine("gwsConsole") + local format = getTextFormat("gwsConsole") + assert.are.same({10, 20, 30}, format.background) + assert.are.same({200, 100, 50}, format.foreground) + assert.is_true(format.bold) + assert.is_true(format.underline) + assert.is_true(format.italic) + end) + end) + + describe("Geyser.Window:paste", function() + it("pastes a selection copied from another console", function() + local source = track(Geyser.MiniConsole:new({name = "gwsSource", x = 0, y = 0, width = 300, height = 100})) + source:echo("copy me\n") + moveCursor("gwsSource", 0, 0) + selectCurrentLine("gwsSource") + copy("gwsSource") + console:paste() + moveCursor("gwsConsole", 0, 0) + selectCurrentLine("gwsConsole") + assert.are.equal("copy me", getCurrentLine("gwsConsole")) + end) + end) +end) diff --git a/src/mudlet-lua/tests/IDManager_spec.lua b/src/mudlet-lua/tests/IDManager_spec.lua index d6fef51ec..85f117ed7 100644 --- a/src/mudlet-lua/tests/IDManager_spec.lua +++ b/src/mudlet-lua/tests/IDManager_spec.lua @@ -456,14 +456,25 @@ describe("Tests the functionality of IDMgr", function() assert.are.same({"regex_trig"}, getNamedTriggers(user)) end) - it("Should list registered named triggers", function() + it("Should list registered named triggers, mixing substring and regex types", function() + -- https://github.com/Mudlet/Mudlet/issues/9542: substring and regex names + -- live in separate 1..n arrays, so merging them by index collides entries + -- and drops one. Registering one of each type guards that regression. registerNamedTrigger(user, "sub_one", "whatever_sub_one", function() end) - registerNamedTrigger(user, "sub_two", "whatever_sub_two", function() end) + registerNamedRegexTrigger(user, "regex_one", "^whatever_re$", function() end) local names = getNamedTriggers(user) assert.is_equal(2, #names) local present = {} for _, n in ipairs(names) do present[n] = true end - assert.is_true(present["sub_one"] and present["sub_two"], "both named triggers should be listed") + assert.is_true(present["sub_one"] and present["regex_one"], "both substring and regex named triggers should be listed") + end) + + it("Should list a name held by both a substring and a regex trigger only once", function() + -- the two stores can hold the same name at once; the listing is a set of + -- names, so the #9542 union must dedupe rather than report the name twice + registerNamedTrigger(user, "shared", "whatever_shared_sub", function() end) + registerNamedRegexTrigger(user, "shared", "^whatever_shared_re$", function() end) + assert.are.same({"shared"}, getNamedTriggers(user)) end) it("Should delete a named trigger", function() @@ -472,12 +483,508 @@ describe("Tests the functionality of IDMgr", function() assert.are.same({}, getNamedTriggers(user)) end) - it("Should delete all named triggers", function() + it("Should delete all named triggers across both substring and regex stores", function() registerNamedTrigger(user, "t1", "named_trig_all_a", function() end) - registerNamedTrigger(user, "t2", "named_trig_all_b", function() end) + registerNamedRegexTrigger(user, "t2", "^named_trig_all_re$", function() end) assert.is_equal(2, #getNamedTriggers(user)) assert.is_true(deleteAllNamedTriggers(user)) assert.are.same({}, getNamedTriggers(user)) end) end) + + describe("Tests the functionality of stopAllNamedTriggers", function() + local user = "stop all trig user" + + after_each(function() + deleteAllNamedTriggers(user) + _G.StopAllTrigFire = nil + end) + + it("Should stop a substring named trigger while leaving it registered", function() + _G.StopAllTrigFire = 0 + registerNamedTrigger(user, "sub", "stop_all_sub", function() _G.StopAllTrigFire = _G.StopAllTrigFire + 1 end) + feedTriggers("\nstop_all_sub\n") + assert.is_true(_G.StopAllTrigFire >= 1, "the trigger should fire before being stopped") + + assert.is_true(stopAllNamedTriggers(user)) + -- killTrigger defers the deletion to the end of the next feed + feedTriggers("\nstop_all_sub\n") + local afterFlush = _G.StopAllTrigFire + feedTriggers("\nstop_all_sub\n") + assert.is_equal(afterFlush, _G.StopAllTrigFire, "a stopped named trigger must not keep firing") + -- stopped, not deleted + assert.are.same({"sub"}, getNamedTriggers(user)) + end) + + it("Should stop regex named triggers too", function() + _G.StopAllTrigFire = 0 + registerNamedRegexTrigger(user, "re", "^stop_all_re$", function() _G.StopAllTrigFire = _G.StopAllTrigFire + 1 end) + feedTriggers("\nstop_all_re\n") + assert.is_true(_G.StopAllTrigFire >= 1) + + stopAllNamedTriggers(user) + -- killTrigger deactivates synchronously, so not one more fire is allowed + local atStop = _G.StopAllTrigFire + feedTriggers("\nstop_all_re\n") + feedTriggers("\nstop_all_re\n") + assert.is_equal(atStop, _G.StopAllTrigFire, "a stopped regex named trigger must not keep firing") + -- stopped, not deleted + assert.are.same({"re"}, getNamedTriggers(user)) + end) + + it("Should let a stopped regex named trigger be resumed", function() + _G.StopAllTrigFire = 0 + registerNamedRegexTrigger(user, "re", "^stop_all_resume_re$", function() _G.StopAllTrigFire = _G.StopAllTrigFire + 1 end) + stopAllNamedTriggers(user) + local atStop = _G.StopAllTrigFire + feedTriggers("\nstop_all_resume_re\n") + assert.is_equal(atStop, _G.StopAllTrigFire, "the regex named trigger should be stopped") + + assert.is_true(resumeNamedTrigger(user, "re"), "a stopped regex named trigger must be resumable") + feedTriggers("\nstop_all_resume_re\n") + assert.is_true(_G.StopAllTrigFire > atStop, "the resumed regex named trigger should fire again") + end) + + it("Should raise an error if the userName is missing or wrong type", function() + assert.has_error(function() stopAllNamedTriggers() end) + assert.has_error(function() stopAllNamedTriggers(5) end) + end) + end) + + describe("Tests the functionality of a private manager from getNewIDManager", function() + local mgr + + before_each(function() + mgr = getNewIDManager() + _G.PrivateMgrFire = nil + end) + + after_each(function() + mgr:deleteAllTimers() + mgr:deleteAllTriggers() + mgr:deleteAllEvents() + _G.PrivateMgrFire = nil + end) + + it("Should hand out managers with independent stores", function() + local other = getNewIDManager() + finally(function() other:deleteAllTimers() end) + mgr:registerTimer("shared name", 100, function() end) + assert.are.same({"shared name"}, mgr:getTimers()) + assert.are.same({}, other:getTimers()) + end) + + describe("Tests the functionality of IDMgr:registerTrigger and IDMgr:registerRegexTrigger", function() + it("Should register a substring trigger that fires", function() + _G.PrivateMgrFire = 0 + assert.is_true(mgr:registerTrigger("sub", "private_mgr_sub", function() _G.PrivateMgrFire = _G.PrivateMgrFire + 1 end)) + feedTriggers("\nprivate_mgr_sub\n") + assert.is_true(_G.PrivateMgrFire >= 1) + end) + + it("Should register a regex trigger that fires", function() + _G.PrivateMgrFire = 0 + assert.is_true(mgr:registerRegexTrigger("re", "^private_mgr_re$", function() _G.PrivateMgrFire = _G.PrivateMgrFire + 1 end)) + feedTriggers("\nprivate_mgr_re\n") + assert.is_true(_G.PrivateMgrFire >= 1) + end) + + it("Should keep substring and regex triggers in separate stores", function() + mgr:registerTrigger("same", "private_mgr_both_sub", function() end) + mgr:registerRegexTrigger("same", "^private_mgr_both_re$", function() end) + assert.is_not_nil(mgr.triggers["same"]) + assert.is_not_nil(mgr.regexTriggers["same"]) + end) + + it("Should report the upstream failure instead of raising", function() + local ok, err = mgr:registerTrigger("bad", {}, function() end) + assert.is_nil(ok) + assert.is_string(err) + assert.is_nil(mgr.triggers["bad"]) + end) + end) + + describe("Tests the functionality of IDMgr:getTriggers", function() + it("Should list substring and regex names once each, sorted", function() + mgr:registerTrigger("bravo", "private_mgr_list_a", function() end) + mgr:registerRegexTrigger("alpha", "^private_mgr_list_b$", function() end) + mgr:registerRegexTrigger("bravo", "^private_mgr_list_c$", function() end) + assert.are.same({"alpha", "bravo"}, mgr:getTriggers()) + end) + + it("Should return an empty list for a fresh manager", function() + assert.are.same({}, mgr:getTriggers()) + end) + end) + + describe("Tests the functionality of IDMgr:stopTrigger and IDMgr:resumeTrigger", function() + it("Should stop a substring trigger and resume it again", function() + _G.PrivateMgrFire = 0 + mgr:registerTrigger("sub", "private_mgr_stop", function() _G.PrivateMgrFire = _G.PrivateMgrFire + 1 end) + assert.is_true(mgr:stopTrigger("sub")) + assert.is_equal(-1, mgr.triggers["sub"].handlerID) + feedTriggers("\nprivate_mgr_stop\n") -- flush the deferred cleanup + local afterFlush = _G.PrivateMgrFire + feedTriggers("\nprivate_mgr_stop\n") + assert.is_equal(afterFlush, _G.PrivateMgrFire) + + assert.is_true(mgr:resumeTrigger("sub")) + local before = _G.PrivateMgrFire + feedTriggers("\nprivate_mgr_stop\n") + assert.is_true(_G.PrivateMgrFire > before, "a resumed trigger should fire again") + end) + + it("Should reach the regex store as well", function() + mgr:registerRegexTrigger("re", "^private_mgr_stop_re$", function() end) + assert.is_true(mgr:stopTrigger("re")) + assert.is_equal(-1, mgr.regexTriggers["re"].handlerID) + assert.is_true(mgr:resumeTrigger("re")) + assert.is_true(mgr.regexTriggers["re"].handlerID > 0) + end) + + it("Should return false for a name it does not know", function() + assert.is_false(mgr:stopTrigger("nope")) + assert.is_false(mgr:resumeTrigger("nope")) + end) + end) + + describe("Tests the functionality of IDMgr:deleteTrigger and IDMgr:deleteAllTriggers", function() + it("Should delete a substring trigger and forget its name", function() + mgr:registerTrigger("sub", "private_mgr_del", function() end) + assert.is_true(mgr:deleteTrigger("sub")) + assert.are.same({}, mgr:getTriggers()) + assert.is_nil(mgr.triggers["sub"]) + end) + + it("Should delete a regex trigger too", function() + mgr:registerRegexTrigger("re", "^private_mgr_del_re$", function() end) + assert.is_true(mgr:deleteTrigger("re")) + assert.are.same({}, mgr:getTriggers()) + end) + + it("Should return false for a name it does not know", function() + assert.is_false(mgr:deleteTrigger("nope")) + end) + + it("Should clear both stores at once", function() + mgr:registerTrigger("sub", "private_mgr_all_a", function() end) + mgr:registerRegexTrigger("re", "^private_mgr_all_b$", function() end) + assert.is_equal(2, #mgr:getTriggers()) + assert.is_true(mgr:deleteAllTriggers()) + assert.are.same({}, mgr:getTriggers()) + end) + end) + + describe("Tests the functionality of IDMgr:stopAllTimers and IDMgr:deleteAllTimers", function() + it("Should stop every timer while leaving them registered", function() + mgr:registerTimer("one", 100, function() end) + mgr:registerTimer("two", 100, function() end) + assert.is_true(mgr:stopAllTimers()) + assert.is_equal(-1, mgr.timers["one"].handlerID) + assert.is_equal(-1, mgr.timers["two"].handlerID) + assert.are.same({"one", "two"}, mgr:getTimers()) + end) + + it("Should delete every timer", function() + mgr:registerTimer("one", 100, function() end) + mgr:registerTimer("two", 100, function() end) + assert.is_true(mgr:deleteAllTimers()) + assert.are.same({}, mgr:getTimers()) + end) + end) + + describe("Tests the functionality of IDMgr:stopAllTriggers", function() + it("Should stop every substring trigger while leaving it registered", function() + mgr:registerTrigger("one", "private_mgr_stopall_a", function() end) + mgr:registerTrigger("two", "private_mgr_stopall_b", function() end) + assert.is_true(mgr:stopAllTriggers()) + assert.is_equal(-1, mgr.triggers["one"].handlerID) + assert.is_equal(-1, mgr.triggers["two"].handlerID) + assert.are.same({"one", "two"}, mgr:getTriggers()) + end) + + it("Should stop regex triggers as well", function() + mgr:registerTrigger("sub", "private_mgr_stopall_sub", function() end) + mgr:registerRegexTrigger("re", "^private_mgr_stopall_re$", function() end) + assert.is_true(mgr:stopAllTriggers()) + assert.is_equal(-1, mgr.regexTriggers["re"].handlerID) + -- the substring store must not regress while the regex one is added + assert.is_equal(-1, mgr.triggers["sub"].handlerID) + assert.are.same({"re", "sub"}, mgr:getTriggers()) + end) + end) + + describe("Tests the functionality of IDMgr:emergencyStop", function() + it("Should stop timers, events and substring triggers in one call", function() + mgr:registerTimer("timer", 100, function() end) + mgr:registerEvent("event", "someEventNameNobodyRaises", function() end) + mgr:registerTrigger("trigger", "private_mgr_emergency", function() end) + + assert.is_true(mgr:emergencyStop()) + + assert.is_equal(-1, mgr.timers["timer"].handlerID) + assert.is_equal(-1, mgr.events["event"].handlerID) + assert.is_equal(-1, mgr.triggers["trigger"].handlerID) + -- everything stays registered so it can be resumed + assert.are.same({"timer"}, mgr:getTimers()) + assert.are.same({"trigger"}, mgr:getTriggers()) + end) + + it("Should stop regex triggers too", function() + mgr:registerRegexTrigger("re", "^private_mgr_emergency_re$", function() end) + assert.is_true(mgr:emergencyStop()) + assert.is_equal(-1, mgr.regexTriggers["re"].handlerID) + -- stopped, not deleted, so it can still be resumed + assert.are.same({"re"}, mgr:getTriggers()) + end) + end) + + -- The event store is reached through the shared per-user managers elsewhere + -- in this file. Here it is driven directly, so that the answers a private + -- manager gives are pinned even for a package that keeps its own. + describe("Tests the functionality of IDMgr:getEvents", function() + local eventName = "privateMgrEventListEvent" + + it("Should return an empty list for a fresh manager", function() + assert.are.same({}, mgr:getEvents()) + end) + + it("Should list every registered handler name, sorted", function() + mgr:registerEvent("charlie", eventName, function() end) + mgr:registerEvent("alpha", eventName, function() end) + mgr:registerEvent("bravo", eventName, function() end) + assert.are.same({"alpha", "bravo", "charlie"}, mgr:getEvents()) + end) + + it("Should keep listing a handler that was stopped but not deleted", function() + mgr:registerEvent("stopped", eventName, function() end) + mgr:stopEvent("stopped") + assert.are.same({"stopped"}, mgr:getEvents()) + end) + + it("Should not report timers or triggers as event handlers", function() + mgr:registerTimer("timer", 100, function() end) + mgr:registerTrigger("trigger", "private_mgr_events_not_triggers", function() end) + assert.are.same({}, mgr:getEvents()) + end) + end) + + describe("Tests the functionality of IDMgr:stopEvent and IDMgr:resumeEvent", function() + local eventName = "privateMgrStopResumeEvent" + local fired + + before_each(function() + fired = 0 + mgr:registerEvent("handler", eventName, function() fired = fired + 1 end) + end) + + it("Should stop the handler firing while leaving it registered", function() + raiseEvent(eventName) + assert.are.equal(1, fired) + + assert.is_true(mgr:stopEvent("handler")) + raiseEvent(eventName) + assert.are.equal(1, fired) + assert.are.equal(-1, mgr.events["handler"].handlerID) + assert.are.same({"handler"}, mgr:getEvents()) + end) + + it("Should return false for a name it does not know", function() + assert.is_false(mgr:stopEvent("no such handler")) + end) + + it("Should let a stopped handler fire again once resumed", function() + mgr:stopEvent("handler") + assert.is_true(mgr:resumeEvent("handler")) + assert.are_not.equal(-1, mgr.events["handler"].handlerID) + raiseEvent(eventName) + assert.are.equal(1, fired) + end) + + it("Should leave a resumed handler registered exactly once", function() + -- resume goes through register, which stops the old registration first; + -- if it did not, the handler would run twice for one event + mgr:resumeEvent("handler") + raiseEvent(eventName) + assert.are.equal(1, fired) + assert.are.same({"handler"}, mgr:getEvents()) + end) + + it("Should return false when resuming a name it does not know", function() + assert.is_false(mgr:resumeEvent("no such handler")) + end) + end) + + describe("Tests the functionality of IDMgr:deleteEvent", function() + local eventName = "privateMgrDeleteEvent" + + it("Should stop the handler firing and forget its name", function() + local fired = 0 + mgr:registerEvent("handler", eventName, function() fired = fired + 1 end) + + assert.is_true(mgr:deleteEvent("handler")) + + raiseEvent(eventName) + assert.are.equal(0, fired) + assert.are.same({}, mgr:getEvents()) + assert.is_nil(mgr.events["handler"]) + end) + + it("Should leave a deleted handler beyond resuming", function() + mgr:registerEvent("handler", eventName, function() end) + mgr:deleteEvent("handler") + assert.is_false(mgr:resumeEvent("handler")) + end) + + it("Should return false for a name it does not know", function() + assert.is_false(mgr:deleteEvent("no such handler")) + end) + end) + + describe("Tests the functionality of IDMgr:stopAllEvents", function() + local eventName = "privateMgrStopAllEvent" + + it("Should stop every handler while leaving them all registered", function() + local fired = 0 + mgr:registerEvent("one", eventName, function() fired = fired + 1 end) + mgr:registerEvent("two", eventName, function() fired = fired + 1 end) + raiseEvent(eventName) + assert.are.equal(2, fired) + + assert.is_true(mgr:stopAllEvents()) + + raiseEvent(eventName) + assert.are.equal(2, fired) + assert.are.equal(-1, mgr.events["one"].handlerID) + assert.are.equal(-1, mgr.events["two"].handlerID) + assert.are.same({"one", "two"}, mgr:getEvents()) + end) + + it("Should leave the stopped handlers resumable one at a time", function() + local fired = 0 + mgr:registerEvent("one", eventName, function() fired = fired + 1 end) + mgr:registerEvent("two", eventName, function() fired = fired + 1 end) + mgr:stopAllEvents() + + mgr:resumeEvent("one") + raiseEvent(eventName) + assert.are.equal(1, fired) + end) + + it("Should not raise for a manager with no handlers", function() + assert.is_true(mgr:stopAllEvents()) + end) + + it("Should leave timers alone", function() + mgr:registerTimer("timer", 100, function() end) + mgr:stopAllEvents() + assert.is_number(mgr:remainingTime("timer")) + end) + end) + + -- stopAll and deleteAll are the store-agnostic bodies behind the seven + -- stopAll*/deleteAll* wrappers, so they are driven by store name here + describe("Tests the functionality of IDMgr:stopAll and IDMgr:deleteAll", function() + local eventName = "privateMgrStoreLoopEvent" + + it("Should stop everything in the store it is named", function() + mgr:registerEvent("event", eventName, function() end) + mgr:registerTimer("timer", 100, function() end) + + assert.is_true(mgr:stopAll("events")) + + assert.are.equal(-1, mgr.events["event"].handlerID) + -- the timer store was not named, so it is untouched + assert.is_number(mgr:remainingTime("timer")) + end) + + it("Should stop the timer store when that is the one named", function() + local handlerID + mgr:registerTimer("timer", 100, function() end) + handlerID = mgr.timers["timer"].handlerID + + assert.is_true(mgr:stopAll("timers")) + + assert.are.equal(-1, mgr.timers["timer"].handlerID) + -- the bookkeeping alone would say inactive, so check the real tempTimer + assert.is_nil(remainingTime(handlerID)) + end) + + it("Should not raise on an empty store", function() + assert.is_true(mgr:stopAll("events")) + assert.is_true(mgr:stopAll("regexTriggers")) + end) + + it("Should empty the store it is named and stop what was in it", function() + local fired = 0 + mgr:registerEvent("event", eventName, function() fired = fired + 1 end) + mgr:registerTimer("timer", 100, function() end) + + assert.is_true(mgr:deleteAll("events")) + + raiseEvent(eventName) + assert.are.equal(0, fired) + assert.are.same({}, mgr:getEvents()) + assert.are.same({"timer"}, mgr:getTimers()) + end) + + it("Should not raise on an empty store for deleteAll either", function() + assert.is_true(mgr:deleteAll("events")) + assert.is_true(mgr:deleteAll("timers")) + end) + end) + + describe("Tests the functionality of IDMgr:resumeTimer and IDMgr:deleteTimer", function() + it("Should give a stopped timer a running tempTimer again", function() + mgr:registerTimer("timer", 100, function() end) + mgr:stopTimer("timer") + assert.is_nil((mgr:remainingTime("timer"))) + + assert.is_true(mgr:resumeTimer("timer")) + + assert.are_not.equal(-1, mgr.timers["timer"].handlerID) + assert.is_number(mgr:remainingTime("timer")) + end) + + it("Should restart a timer that was still running rather than add a second one", function() + mgr:registerTimer("timer", 5000, function() end) + local firstID = mgr.timers["timer"].handlerID + + assert.is_true(mgr:resumeTimer("timer")) + + assert.are_not.equal(firstID, mgr.timers["timer"].handlerID) + assert.is_nil(remainingTime(firstID), "resuming has to kill the tempTimer it replaces") + assert.are.same({"timer"}, mgr:getTimers()) + end) + + it("Should return false when resuming a name it does not know", function() + assert.is_false(mgr:resumeTimer("no such timer")) + end) + + it("Should kill the underlying tempTimer and forget the name", function() + mgr:registerTimer("timer", 100, function() end) + local handlerID = mgr.timers["timer"].handlerID + + assert.is_true(mgr:deleteTimer("timer")) + + assert.is_nil(remainingTime(handlerID)) + assert.are.same({}, mgr:getTimers()) + local remaining, err = mgr:remainingTime("timer") + assert.is_nil(remaining) + assert.are.equal("timer not found", err) + end) + + it("Should leave the other timers alone", function() + mgr:registerTimer("keep", 100, function() end) + mgr:registerTimer("drop", 100, function() end) + mgr:deleteTimer("drop") + assert.are.same({"keep"}, mgr:getTimers()) + assert.is_number(mgr:remainingTime("keep")) + end) + + it("Should return false for a name it does not know", function() + assert.is_false(mgr:deleteTimer("no such timer")) + end) + end) + end) end) diff --git a/src/mudlet-lua/tests/KeyBinds_spec.lua b/src/mudlet-lua/tests/KeyBinds_spec.lua index a48683448..cf24605ea 100644 --- a/src/mudlet-lua/tests/KeyBinds_spec.lua +++ b/src/mudlet-lua/tests/KeyBinds_spec.lua @@ -186,6 +186,24 @@ describe("Tests keybind-related functions", function() assert.is_false(killKey("no_such_key_name"), "killing a missing key should return false") end) + it("killKey returns false the second time, as the key is already dead", function() + local id = tempKey(mudlet.key.F11, [[echo("x")]]) + assert.is_true(killKey(id), "killing a live temporary key should report success") + -- the key is still present here: only the deferred cleanup frees it, so the + -- second kill really is being told about a corpse it can find + assert.are.equal(1, exists(id, "keybind"), "the killed key is still present until cleanup runs") + assert.are.equal(0, isActive(id, "keybind"), "a killed key is no longer active") + assert.is_false(killKey(id), + "killing an already killed key achieves nothing and has to say so") + -- an incoming line runs every unit's deferred cleanup, which is what finally + -- frees the key; the answer has to be the same after it. A key press cannot be + -- synthesised headlessly, so there is no in-callback double kill to pin here - + -- KeyUnit's depth and cleanup machinery matches AliasUnit's, whose spec has one + feedTriggers("\nspec_key_kill_flush\n") + assert.are.equal(0, exists(id, "keybind"), "the key should be gone after kill and cleanup") + assert.is_false(killKey(id), "a freed key cannot be killed either") + end) + it("killKey returns false for a permanent key (they cannot be killed)", function() local id = permKey("SpecPermKeyKill", "", mudlet.key.F12, [[echo("x")]]) assert.is_true(id > 0) @@ -234,6 +252,43 @@ describe("Tests keybind-related functions", function() assert.are.equal(exists("SpecDupKeys", "keybind"), isActive("SpecDupKeys", "keybind"), "enabling by name must reactivate every duplicate") end) + it("freeing a temporary key leaves a same-named permanent one reachable", function() + -- tempKey names its key after its id, so a permanent key called after that + -- number shares the name - and the name lookup table holds several keys per + -- name + local tempId = tempKey(mudlet.key.F11, [[echo("x")]]) + local sharedName = tostring(tempId) + -- permanent keys cannot be deleted from Lua, so earlier local runs can leave + -- same-named ones behind: work from a relative baseline + local before = exists(sharedName, "keybind") + assert.is_true(permKey(sharedName, "", mudlet.key.F12, [[echo("x")]]) > 0) + finally(function() disableKey(sharedName) end) + assert.are.equal(before + 1, exists(sharedName, "keybind")) + + assert.is_true(killKey(tempId), "the temporary key is the one that can be killed") + -- an incoming line runs every unit's deferred cleanup, which frees it + feedTriggers("\nspec_key_eviction_flush\n") + + assert.are.equal(before, exists(sharedName, "keybind"), "only the temporary key should leave the lookup table") + assert.is_true(enableKey(sharedName), "the permanent key must still be reachable by name") + end) + + it("killKey finds a temporary key behind a same-named permanent one", function() + -- killKey walks the root node list in creation order, so a permanent key + -- restored from the profile sits in front of this session's temporaries: it + -- must be scanned past, not reported as a failure + local seed = tempKey(mudlet.key.F9, [[echo("x")]]) + killKey(seed) + -- permKey itself takes seed + 1, so the next temporary takes seed + 2 + local sharedName = tostring(seed + 2) + assert.is_true(permKey(sharedName, "", mudlet.key.F10, [[echo("x")]]) > 0) + finally(function() disableKey(sharedName) end) + + local tempId = tempKey(mudlet.key.F11, [[echo("x")]]) + assert.are.equal(seed + 2, tempId, "ids should still be handed out in sequence") + assert.is_true(killKey(tempId), "killKey must scan past the permanent key") + end) + end) end) diff --git a/src/mudlet-lua/tests/Mapper_spec.lua b/src/mudlet-lua/tests/Mapper_spec.lua index 4000f79c8..7659fd453 100644 --- a/src/mudlet-lua/tests/Mapper_spec.lua +++ b/src/mudlet-lua/tests/Mapper_spec.lua @@ -27,6 +27,23 @@ describe("Tests map events and menus before the map widget is opened", function( assert.is_nil(getMapMenus()["PreWidgetMenu"]) end) + -- the dock widget itself outlives closeMapWidget(), but a closed one answers + -- the map window functions exactly as a never-opened profile does, so these + -- two reach the same branch whether or not an earlier file opened it + it("should report that there is no map widget to read a title from", function() + closeMapWidget() + local title, err = getMapWindowTitle() + assert.is_nil(title) + assert.are.equal("no floating/dockable type map window found", err) + end) + + it("should report that there is no map widget to read a geometry from", function() + closeMapWidget() + local x, err = getMapWidgetGeometry() + assert.is_nil(x) + assert.are.equal("no floating/dockable type map window found", err) + end) + it("should retain a registration for when the widget opens later", function() assert.is_true(addMapEvent("preWidgetKeptEvent", "myEvent", "", "Kept Event")) end) @@ -391,3 +408,1841 @@ describe("Tests searchRoom", function() end) end) + +-- A shared in-memory fixture: three areas and ten rooms wired into a +-- pathfinding diamond, a cross-area link, a special exit and a pair of sandbox +-- rooms used for the mutation-heavy tests. Everything is torn down at the end. +describe("Tests mapper functions against a shared fixture", function() + + local missingRoomId = 990000001 + local missingAreaId = 990000002 + + local areaAlpha, areaBeta, areaGamma + local rA1, rA2, rA3, rA4, rA5 + local rB1, rB2, rG1 + local rSandA, rSandB + + setup(function() + -- The mapper widget is required for zoom, views, player room, export and + -- map info repaint paths; it persists once opened. Asserted so a headless + -- failure surfaces here rather than as dozens of downstream failures. + assert.is_true(openMapWidget()) + + areaAlpha = addAreaName("MapperSpecAlpha") + areaBeta = addAreaName("MapperSpecBeta") + areaGamma = addAreaName("MapperSpecGamma") + + local function makeRoom(area, x, y, z) + local id = createRoomID() + addRoom(id) + setRoomArea(id, area) + setRoomCoordinates(id, x, y, z) + return id + end + + rA1 = makeRoom(areaAlpha, 0, 0, 0) + rA2 = makeRoom(areaAlpha, 1, 0, 0) + rA3 = makeRoom(areaAlpha, 2, 0, 0) + rA4 = makeRoom(areaAlpha, 1, -1, 0) + rA5 = makeRoom(areaAlpha, 2, -1, 0) + rSandA = makeRoom(areaAlpha, 0, -3, 0) + rSandB = makeRoom(areaAlpha, 1, -3, 0) + rB1 = makeRoom(areaBeta, 0, 0, 1) + rB2 = makeRoom(areaBeta, 1, 0, 1) + rG1 = makeRoom(areaGamma, 0, 0, 2) + + -- Pathfinding diamond: a 2-hop east route and a 3-hop south route between + -- rA1 and rA3, both bidirectional. + setExit(rA1, rA2, "east"); setExit(rA2, rA1, "west") + setExit(rA2, rA3, "east"); setExit(rA3, rA2, "west") + setExit(rA1, rA4, "south"); setExit(rA4, rA1, "north") + setExit(rA4, rA5, "east"); setExit(rA5, rA4, "west") + setExit(rA5, rA3, "north"); setExit(rA3, rA5, "south") + -- Cross-area link into Beta. + setExit(rA3, rB1, "up"); setExit(rB1, rA3, "down") + setExit(rB1, rB2, "east"); setExit(rB2, rB1, "west") + -- Gamma is only reachable through a special exit from rB2. + addSpecialExit(rB2, rG1, "enter gate") + + -- Sandbox rooms carry the mutation-heavy exits so the diamond stays clean. + setExit(rSandA, rSandB, "east"); setExit(rSandB, rSandA, "west") + addSpecialExit(rSandA, rSandB, "wibble") + setExitStub(rSandB, "north", true) + end) + + teardown(function() + closeAllMapViews() + os.remove(getMudletHomeDir() .. "/mapper_spec_export.png") + for _, id in ipairs({rA1, rA2, rA3, rA4, rA5, rSandA, rSandB, rB1, rB2, rG1}) do + deleteRoom(id) + end + deleteArea("MapperSpecAlpha") + deleteArea("MapperSpecBeta") + deleteArea("MapperSpecGamma") + end) + + -- saveJsonMap/loadJsonMap are intentionally not covered here: the JSON export + -- runs through a progress dialog and bulk import, and loadJsonMap replaces and + -- re-initialises the entire map, which is incompatible with this shared + -- fixture. JSON persistence is exercised by the C++ MapRoundTripTest instead. + + describe("Tests area listing and naming", function() + it("getAreaTable maps every area name to its ID", function() + local areas = getAreaTable() + assert.is_table(areas) + assert.are.equal(areaAlpha, areas["MapperSpecAlpha"]) + assert.are.equal(areaBeta, areas["MapperSpecBeta"]) + assert.are.equal(areaGamma, areas["MapperSpecGamma"]) + end) + + it("getAreaTableSwap maps every area ID to its name", function() + local areas = getAreaTableSwap() + assert.is_table(areas) + assert.are.equal("MapperSpecAlpha", areas[areaAlpha]) + assert.are.equal("MapperSpecGamma", areas[areaGamma]) + end) + + it("getRoomAreaName resolves an area ID to its name", function() + assert.are.equal("MapperSpecAlpha", getRoomAreaName(areaAlpha)) + end) + + it("getRoomAreaName resolves an area name to its ID", function() + assert.are.equal(areaBeta, getRoomAreaName("MapperSpecBeta")) + end) + + it("getRoomAreaName returns -1 and a message for an unknown area ID", function() + local id, err = getRoomAreaName(missingAreaId) + assert.are.equal(-1, id) + assert.is_string(err) + end) + + it("getRoomAreaName hard-errors on a non-number, non-string argument", function() + assert.has_error(function() getRoomAreaName(true) end) + end) + + it("setAreaName renames an area and getAreaTable reflects it", function() + assert.is_true(setAreaName(areaGamma, "MapperSpecGammaRenamed")) + assert.are.equal("MapperSpecGammaRenamed", getRoomAreaName(areaGamma)) + -- restore so later assertions and teardown keep working + assert.is_true(setAreaName(areaGamma, "MapperSpecGamma")) + end) + + it("setAreaName rejects an empty new name with nil and a message", function() + local ok, err = setAreaName(areaAlpha, "") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("setAreaName rejects duplicating an existing area name", function() + local ok, err = setAreaName(areaAlpha, "MapperSpecBeta") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addAreaName rejects an empty (whitespace-only) name with nil and a message", function() + local ok, err = addAreaName(" ") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addAreaName rejects a duplicate name with nil and a message", function() + local ok, err = addAreaName("MapperSpecAlpha") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests deleteArea", function() + it("removes a throwaway area from getAreaTable", function() + addAreaName("MapperSpecDeleteMe") + assert.is_not_nil(getAreaTable()["MapperSpecDeleteMe"]) + assert.is_true(deleteArea("MapperSpecDeleteMe")) + assert.is_nil(getAreaTable()["MapperSpecDeleteMe"]) + end) + + it("returns nil and a message for an unknown areaID", function() + local ok, err = deleteArea(missingAreaId) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("returns nil and a message for an empty area name", function() + local ok, err = deleteArea("") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("refuses to delete the default area", function() + local ok, err = deleteArea(getRoomAreaName(-1)) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests area room membership", function() + it("getAreaRooms1 lists the rooms of an area 1-based", function() + local rooms = getAreaRooms1(areaBeta) + assert.is_table(rooms) + assert.is_not_nil(rooms[1]) + assert.is_not_nil(rooms[2]) + assert.is_nil(rooms[3]) + local set = {} + for _, id in pairs(rooms) do set[id] = true end + assert.is_true(set[rB1]) + assert.is_true(set[rB2]) + end) + + it("getAreaRooms lists the rooms of an area 0-based for compatibility", function() + local rooms = getAreaRooms(areaBeta) + assert.is_table(rooms) + assert.is_not_nil(rooms[0]) + assert.is_nil(rooms[2]) + end) + + it("getAreaRooms returns nil for an unknown area", function() + assert.is_nil(getAreaRooms(missingAreaId)) + end) + + it("getRoomsByPosition1 finds the room at a coordinate 1-based", function() + local rooms = getRoomsByPosition1(areaAlpha, 0, 0, 0) + assert.is_table(rooms) + assert.are.equal(rA1, rooms[1]) + end) + + it("getRoomsByPosition finds the room at a coordinate 0-based", function() + local rooms = getRoomsByPosition(areaAlpha, 1, 0, 0) + assert.is_table(rooms) + assert.are.equal(rA2, rooms[0]) + end) + + it("getRoomsByPosition returns nil for an unknown area", function() + assert.is_nil(getRoomsByPosition(missingAreaId, 0, 0, 0)) + end) + + it("getAreaExits lists the rooms with exits leaving the area", function() + local exits = getAreaExits(areaBeta) + assert.is_table(exits) + local set = {} + for _, id in pairs(exits) do set[id] = true end + -- rB1 exits Beta via "down" to rA3, rB2 exits via the special exit to rG1 + assert.is_true(set[rB1]) + assert.is_true(set[rB2]) + end) + + it("getAreaExits with full data keys by source room and command", function() + local exits = getAreaExits(areaBeta, true) + assert.is_table(exits) + assert.is_table(exits[rB1]) + -- rB1 leaves Beta down to rA3; the inner table maps a command to that room + local leavesToRA3 = false + for _, toRoom in pairs(exits[rB1]) do + if toRoom == rA3 then leavesToRA3 = true end + end + assert.is_true(leavesToRA3) + end) + + it("getAreaExits returns nil and a message for an unknown area", function() + local exits, err = getAreaExits(missingAreaId) + assert.is_nil(exits) + assert.is_string(err) + end) + end) + + describe("Tests grid mode", function() + it("getGridMode reports false for a normal area", function() + assert.is_false(getGridMode(areaGamma)) + end) + + it("setGridMode toggles the flag which getGridMode reads back", function() + assert.is_true(setGridMode(areaGamma, true)) + assert.is_true(getGridMode(areaGamma)) + assert.is_true(setGridMode(areaGamma, false)) + assert.is_false(getGridMode(areaGamma)) + end) + + it("setGridMode returns false for an unknown area", function() + assert.is_false(setGridMode(missingAreaId, true)) + end) + + it("getGridMode returns nil and a message for an unknown area", function() + local ok, err = getGridMode(missingAreaId) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests room area assignment", function() + it("getRoomArea returns the area a room belongs to", function() + assert.are.equal(areaBeta, getRoomArea(rB1)) + end) + + it("getRoomArea returns nil for an unknown room", function() + assert.is_nil(getRoomArea(missingRoomId)) + end) + + it("resetRoomArea parks a room in the default area (-1)", function() + local id = createRoomID() + addRoom(id) + setRoomArea(id, areaAlpha) + assert.are.equal(areaAlpha, getRoomArea(id)) + assert.is_true(resetRoomArea(id)) + assert.are.equal(-1, getRoomArea(id)) + deleteRoom(id) + end) + + it("resetRoomArea returns nil and a message for an unknown room", function() + local ok, err = resetRoomArea(missingRoomId) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests setRoomArea forms", function() + it("moves a table of rooms into an area in one call", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + local b = createRoomID(); addRoom(b); setRoomArea(b, areaAlpha) + assert.is_true(setRoomArea({a, b}, areaBeta)) + assert.are.equal(areaBeta, getRoomArea(a)) + assert.are.equal(areaBeta, getRoomArea(b)) + deleteRoom(a); deleteRoom(b) + end) + + it("accepts an area name as well as an ID", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + assert.is_true(setRoomArea(a, "MapperSpecBeta")) + assert.are.equal(areaBeta, getRoomArea(a)) + deleteRoom(a) + end) + + it("returns nil and a message for an unknown areaID", function() + local ok, err = setRoomArea(rSandA, missingAreaId) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("rejects an empty area name with nil and a message", function() + local ok, err = setRoomArea(rSandA, "") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests room existence and names", function() + it("roomExists is true for a fixture room and false for a missing one", function() + assert.is_true(roomExists(rA1)) + assert.is_false(roomExists(missingRoomId)) + end) + + it("getRooms maps every room ID to its name", function() + local rooms = getRooms() + assert.is_table(rooms) + assert.is_not_nil(rooms[rA1]) + assert.is_not_nil(rooms[rG1]) + end) + + it("setRoomName is read back by getRoomName", function() + assert.is_true(setRoomName(rSandA, "SandboxRoomName")) + assert.are.equal("SandboxRoomName", getRoomName(rSandA)) + end) + + it("getRoomName returns nil and a message for an unknown room", function() + local name, err = getRoomName(missingRoomId) + assert.is_nil(name) + assert.is_string(err) + end) + end) + + describe("Tests room coordinates", function() + it("getRoomCoordinates returns the stored x, y and z", function() + local x, y, z = getRoomCoordinates(rA3) + assert.are.equal(2, x) + assert.are.equal(0, y) + assert.are.equal(0, z) + end) + + it("getRoomCoordinates returns three nils for an unknown room", function() + local x, y, z = getRoomCoordinates(missingRoomId) + assert.is_nil(x) + assert.is_nil(y) + assert.is_nil(z) + end) + end) + + describe("Tests room environment", function() + it("setRoomEnv is read back by getRoomEnv", function() + assert.is_true(setRoomEnv(rSandA, 42)) + assert.are.equal(42, getRoomEnv(rSandA)) + end) + + it("setRoomEnv returns nil and a message for an unknown room", function() + local ok, err = setRoomEnv(missingRoomId, 1) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests room weight", function() + it("rooms default to a weight of 1", function() + assert.are.equal(1, getRoomWeight(rB2)) + end) + + it("setRoomWeight is read back by getRoomWeight", function() + assert.is_true(setRoomWeight(rSandB, 5)) + assert.are.equal(5, getRoomWeight(rSandB)) + setRoomWeight(rSandB, 1) + end) + + it("setRoomWeight returns nil and a message for an unknown room", function() + local ok, err = setRoomWeight(missingRoomId, 3) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests room symbol character", function() + it("setRoomChar is read back by getRoomChar", function() + assert.is_true(setRoomChar(rSandA, "@")) + assert.are.equal("@", getRoomChar(rSandA)) + end) + + it("an empty string clears the room symbol", function() + setRoomChar(rSandA, "#") + assert.is_true(setRoomChar(rSandA, "")) + assert.are.equal("", getRoomChar(rSandA)) + end) + + it("getRoomChar returns nil and a message for an unknown room", function() + local ch, err = getRoomChar(missingRoomId) + assert.is_nil(ch) + assert.is_string(err) + end) + end) + + describe("Tests room symbol colour", function() + it("setRoomCharColor is read back by getRoomCharColor", function() + assert.is_true(setRoomCharColor(rSandA, 10, 20, 30)) + local r, g, b = getRoomCharColor(rSandA) + assert.are.equal(10, r) + assert.are.equal(20, g) + assert.are.equal(30, b) + end) + + it("setRoomCharColor hard-errors on an out-of-range component", function() + assert.has_error(function() setRoomCharColor(rSandA, 256, 0, 0) end) + end) + + it("unsetRoomCharColor returns true and clears the stored colour", function() + setRoomCharColor(rSandA, 100, 100, 100) + -- The symbol colour is reset to an invalid QColor whose RGB read-back is + -- undefined, so only the success contract is pinned here. + assert.is_true(unsetRoomCharColor(rSandA)) + end) + + it("unsetRoomCharColor returns nil and a message for an unknown room", function() + local ok, err = unsetRoomCharColor(missingRoomId) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests room hidden state", function() + it("setRoomHidden is read back by getRoomHidden", function() + assert.is_true(setRoomHidden(rSandB, true)) + assert.is_true(getRoomHidden(rSandB)) + assert.is_true(setRoomHidden(rSandB, false)) + assert.is_false(getRoomHidden(rSandB)) + end) + + it("getHiddenRooms lists only the hidden rooms", function() + setRoomHidden(rSandB, true) + local hidden = getHiddenRooms() + assert.is_table(hidden) + local set = {} + for _, id in pairs(hidden) do set[id] = true end + assert.is_true(set[rSandB]) + assert.is_nil(set[rA1]) + setRoomHidden(rSandB, false) + end) + + it("setRoomHidden returns nil and a message for an unknown room", function() + local ok, err = setRoomHidden(missingRoomId, true) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests room locking", function() + it("lockRoom toggles a flag that roomLocked reads back", function() + assert.is_true(lockRoom(rSandB, true)) + assert.is_true(roomLocked(rSandB)) + assert.is_true(lockRoom(rSandB, false)) + assert.is_false(roomLocked(rSandB)) + end) + + it("lockRoom returns false for an unknown room", function() + assert.is_false(lockRoom(missingRoomId, true)) + end) + + it("roomLocked returns false for an unknown room", function() + assert.is_false(roomLocked(missingRoomId)) + end) + end) + + describe("Tests room hashes", function() + it("setRoomIDbyHash is read back by both hash getters", function() + setRoomIDbyHash(rSandA, "sandbox-hash") + assert.are.equal(rSandA, getRoomIDbyHash("sandbox-hash")) + assert.are.equal("sandbox-hash", getRoomHashByID(rSandA)) + end) + + it("getRoomIDbyHash returns -1 for an unknown hash", function() + assert.are.equal(-1, getRoomIDbyHash("no-such-hash-anywhere")) + end) + + it("getRoomHashByID returns nil and a message for a room without a hash", function() + local hash, err = getRoomHashByID(rB1) + assert.is_nil(hash) + assert.is_string(err) + end) + end) + + describe("Tests room highlighting", function() + it("highlightRoom returns true for a valid room", function() + assert.is_true(highlightRoom(rSandA, 255, 0, 0, 0, 255, 0, 10, 100, 100)) + end) + + it("highlightRoom returns false for an unknown room", function() + assert.is_false(highlightRoom(missingRoomId, 255, 0, 0, 0, 255, 0, 10, 100, 100)) + end) + + it("unHighlightRoom returns true for a valid room and false for a missing one", function() + highlightRoom(rSandA, 255, 0, 0, 0, 255, 0, 10, 100, 100) + assert.is_true(unHighlightRoom(rSandA)) + assert.is_false(unHighlightRoom(missingRoomId)) + end) + end) + + describe("Tests normal exits", function() + it("getRoomExits reports every stored exit direction", function() + local exits = getRoomExits(rA1) + assert.is_table(exits) + assert.are.equal(rA2, exits["east"]) + assert.are.equal(rA4, exits["south"]) + end) + + it("getRoomExits returns nothing for an unknown room", function() + assert.is_nil(getRoomExits(missingRoomId)) + end) + + it("setExit adds a new exit that getRoomExits reflects", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + local b = createRoomID(); addRoom(b); setRoomArea(b, areaAlpha) + assert.is_true(setExit(a, b, "north")) + assert.are.equal(b, getRoomExits(a)["north"]) + deleteRoom(a); deleteRoom(b) + end) + + it("setExit hard-errors on an unparseable direction", function() + assert.has_error(function() setExit(rA1, rA2, "sideways") end) + end) + + it("getAllRoomEntrances lists the rooms that exit into a room", function() + local entrances = getAllRoomEntrances(rA2) + assert.is_table(entrances) + local set = {} + for _, id in pairs(entrances) do set[id] = true end + -- rA1 (east) and rA3 (west) both lead into rA2 + assert.is_true(set[rA1]) + assert.is_true(set[rA3]) + end) + + it("getAllRoomEntrances returns nil and a message for an unknown room", function() + local entrances, err = getAllRoomEntrances(missingRoomId) + assert.is_nil(entrances) + assert.is_string(err) + end) + end) + + describe("Tests exit stubs", function() + it("getExitStubs1 lists stub direction codes 1-based", function() + local stubs = getExitStubs1(rSandB) + assert.is_table(stubs) + assert.are.equal(1, stubs[1]) -- DIR_NORTH + end) + + it("getExitStubs lists stub direction codes 0-based for compatibility", function() + local stubs = getExitStubs(rSandB) + assert.is_table(stubs) + assert.are.equal(1, stubs[0]) + end) + + it("getExitStubsNames maps stub codes to direction names", function() + local names = getExitStubsNames(rSandB) + assert.is_table(names) + assert.are.equal("north", names[1]) + end) + + it("getExitStubs1 returns nil and a message for an unknown room", function() + local stubs, err = getExitStubs1(missingRoomId) + assert.is_nil(stubs) + assert.is_string(err) + end) + + it("setExitStub hard-errors when the room does not exist", function() + assert.has_error(function() setExitStub(missingRoomId, "north", true) end) + end) + + it("setExitStub hard-errors on an unparseable direction", function() + assert.has_error(function() setExitStub(rSandA, "sideways", true) end) + end) + + it("connectExitStub turns a stub into a real exit", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + local b = createRoomID(); addRoom(b); setRoomArea(b, areaAlpha) + -- Both rooms need a matching stub: the source going up, the target the + -- reverse (down). + setExitStub(a, "up", true) + setExitStub(b, "down", true) + assert.is_true(connectExitStub(a, b, "up")) + assert.are.equal(b, getRoomExits(a)["up"]) + deleteRoom(a); deleteRoom(b) + end) + + it("connectExitStub hard-errors when the second argument is missing", function() + assert.has_error(function() connectExitStub(rSandA) end) + end) + + it("connectExitStub with only a target ID reports when there is no matching stub", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + local b = createRoomID(); addRoom(b); setRoomArea(b, areaAlpha) + local ok, err = connectExitStub(a, b) + assert.is_nil(ok) + assert.is_string(err) + deleteRoom(a); deleteRoom(b) + end) + + it("connectExitStub returns nil and a message for an unparseable direction", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + local b = createRoomID(); addRoom(b); setRoomArea(b, areaAlpha) + local ok, err = connectExitStub(a, b, "sideways") + assert.is_nil(ok) + assert.is_string(err) + deleteRoom(a); deleteRoom(b) + end) + end) + + describe("Tests special exits", function() + it("getSpecialExits reports the special exit and its lock state", function() + local exits = getSpecialExits(rB2) + assert.is_table(exits) + assert.is_table(exits[rG1]) + assert.are.equal("0", exits[rG1]["enter gate"]) + end) + + it("getSpecialExitsSwap keys special exits by command", function() + local exits = getSpecialExitsSwap(rB2) + assert.is_table(exits) + assert.are.equal(rG1, exits["enter gate"]) + end) + + it("getSpecialExits returns nil and a message for an unknown room", function() + local exits, err = getSpecialExits(missingRoomId) + assert.is_nil(exits) + assert.is_string(err) + end) + + it("addSpecialExit is read back and removeSpecialExit clears it", function() + assert.is_true(addSpecialExit(rSandB, rSandA, "crawl")) + assert.are.equal(rSandA, getSpecialExitsSwap(rSandB)["crawl"]) + assert.is_true(removeSpecialExit(rSandB, "crawl")) + assert.is_nil(getSpecialExitsSwap(rSandB)["crawl"]) + end) + + it("addSpecialExit rejects an empty command with nil and a message", function() + local ok, err = addSpecialExit(rSandA, rSandB, "") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addSpecialExit returns nil and a message for an unknown source room", function() + local ok, err = addSpecialExit(missingRoomId, rSandB, "go") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addSpecialExit returns nil and a message for an unknown entrance room", function() + local ok, err = addSpecialExit(rSandA, missingRoomId, "go") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("getSpecialExits picks the best unlocked exit, or lists all with showAllExits", function() + local x = createRoomID(); addRoom(x); setRoomArea(x, areaAlpha) + local y = createRoomID(); addRoom(y); setRoomArea(y, areaAlpha) + addSpecialExit(x, y, "path1") + addSpecialExit(x, y, "path2") + lockSpecialExit(x, 0, "path1", true) + + -- Default: only the best (unlocked) command to y is returned. + local best = getSpecialExits(x)[y] + assert.is_table(best) + assert.is_nil(best["path1"]) + assert.are.equal("0", best["path2"]) + + -- showAllExits=true: every command is returned with its lock state. + local all = getSpecialExits(x, true)[y] + assert.are.equal("1", all["path1"]) + assert.are.equal("0", all["path2"]) + + deleteRoom(x); deleteRoom(y) + end) + + it("removeSpecialExit returns nil and a message for a non-existent command", function() + local ok, err = removeSpecialExit(rB2, "no such command") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("clearSpecialExits removes every special exit of a room", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaAlpha) + addSpecialExit(a, rSandB, "one") + addSpecialExit(a, rSandA, "two") + clearSpecialExits(a) + assert.is_nil(next(getSpecialExitsSwap(a))) + deleteRoom(a) + end) + end) + + describe("Tests exit weights", function() + it("setExitWeight is read back by getExitWeights", function() + assert.is_true(setExitWeight(rSandA, "east", 7)) + assert.are.equal(7, getExitWeights(rSandA)["e"]) + setExitWeight(rSandA, "east", 0) + end) + + it("setExitWeight rejects a negative weight with nil and a message", function() + local ok, err = setExitWeight(rSandA, "east", -1) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("setExitWeight returns nil and a message for a direction with no exit", function() + local ok, err = setExitWeight(rSandA, "down", 3) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("getExitWeights returns an empty table for a room with no weights", function() + assert.is_nil(next(getExitWeights(rB1))) + end) + end) + + describe("Tests exit locks", function() + it("lockExit is read back by hasExitLock", function() + lockExit(rSandA, "east", true) + assert.is_true(hasExitLock(rSandA, "east")) + lockExit(rSandA, "east", false) + assert.is_false(hasExitLock(rSandA, "east")) + end) + + it("hasExitLock returns nothing for an unknown room", function() + assert.is_nil(hasExitLock(missingRoomId, "east")) + end) + + it("lockSpecialExit is read back by hasSpecialExitLock", function() + assert.is_true(lockSpecialExit(rB2, 0, "enter gate", true)) + assert.is_true(hasSpecialExitLock(rB2, 0, "enter gate")) + assert.is_true(lockSpecialExit(rB2, 0, "enter gate", false)) + assert.is_false(hasSpecialExitLock(rB2, 0, "enter gate")) + end) + + it("lockSpecialExit returns nil and a message for a non-existent command", function() + local ok, err = lockSpecialExit(rB2, 0, "no such command", true) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("hasSpecialExitLock returns nil and a message for a non-existent command", function() + local ok, err = hasSpecialExitLock(rB2, 0, "no such command") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests doors", function() + it("setDoor is read back by getDoors", function() + assert.is_true(setDoor(rSandA, "e", 2)) + assert.are.equal(2, getDoors(rSandA)["e"]) + setDoor(rSandA, "e", 0) + end) + + it("setDoor rejects an out-of-range door type with nil and a message", function() + local ok, err = setDoor(rSandA, "e", 9) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("setDoor returns nil and a message for a direction with no exit", function() + local ok, err = setDoor(rSandA, "w", 1) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("getDoors returns nil and a message for an unknown room", function() + local doors, err = getDoors(missingRoomId) + assert.is_nil(doors) + assert.is_string(err) + end) + end) + + describe("Tests custom exit lines", function() + it("addCustomLine is read back by getCustomLines1 and removed by removeCustomLine", function() + assert.is_true(addCustomLine(rSandA, {{2, 2, 0}}, "e", "dash line", {10, 20, 30}, true)) + local lines = getCustomLines1(rSandA) + assert.is_table(lines["e"]) + assert.are.equal("dash line", lines["e"]["attributes"]["style"]) + assert.is_true(lines["e"]["attributes"]["arrow"]) + assert.is_true(removeCustomLine(rSandA, "e")) + assert.is_nil(getCustomLines1(rSandA)["e"]) + end) + + it("getCustomLines uses 0-based point indexing for compatibility", function() + addCustomLine(rSandA, {{2, 2, 0}}, "e", "solid line", {1, 2, 3}, false) + local lines = getCustomLines(rSandA) + assert.is_table(lines["e"]) + assert.is_not_nil(lines["e"]["points"][0]) + removeCustomLine(rSandA, "e") + end) + + it("addCustomLine rejects a direction the room has no exit for", function() + local ok, err = addCustomLine(rSandA, {{1, 1, 0}}, "w", "solid line", {0, 0, 0}, false) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addCustomLine draws a line to a target given as a room number", function() + assert.is_true(addCustomLine(rSandA, rSandB, "e", "solid line", {0, 0, 0}, false)) + assert.is_table(getCustomLines1(rSandA)["e"]) + removeCustomLine(rSandA, "e") + end) + + it("addCustomLine rejects an empty coordinate table (Issue #5272 crash guard)", function() + local ok, err = addCustomLine(rSandA, {{}}, "e", "solid line", {0, 0, 0}, false) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addCustomLine rejects a target room in a different area", function() + local ok, err = addCustomLine(rSandA, rB1, "e", "solid line", {0, 0, 0}, false) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addCustomLine rejects an invalid line style", function() + local ok, err = addCustomLine(rSandA, {{2, 2, 0}}, "e", "wiggly line", {0, 0, 0}, false) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addCustomLine rejects an out-of-range colour component", function() + local ok, err = addCustomLine(rSandA, {{2, 2, 0}}, "e", "solid line", {256, 0, 0}, false) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("addCustomLine hard-errors when the second argument is neither number nor table", function() + assert.has_error(function() addCustomLine(rSandA, "notvalid", "e", "solid line", {0, 0, 0}, false) end) + end) + + it("getCustomLines1 returns nil and a message for an unknown room", function() + local lines, err = getCustomLines1(missingRoomId) + assert.is_nil(lines) + assert.is_string(err) + end) + end) + + describe("Tests custom environment colours", function() + it("setCustomEnvColor is read back by getCustomEnvColorTable", function() + assert.is_true(setCustomEnvColor(500, 11, 22, 33, 44)) + local colors = getCustomEnvColorTable() + assert.is_table(colors[500]) + assert.are.equal(11, colors[500][1]) + assert.are.equal(22, colors[500][2]) + assert.are.equal(33, colors[500][3]) + assert.are.equal(44, colors[500][4]) + end) + + it("setCustomEnvColor for IDs 257-272 also updates the profile ANSI colour (documented sync)", function() + -- Since Mudlet 4.20 setting 257-272 deliberately mutates the profile's + -- mapper colours; getCustomEnvColorTable reflects the stored value. This + -- is profile state that outlives even deleteMap, so restore it from a + -- finally() hook: a failed assertion below must not leave the persistent + -- self-test profile stuck on the test colour for later runs. + local before = getCustomEnvColorTable()[257] + finally(function() + setCustomEnvColor(257, before[1], before[2], before[3], before[4]) + end) + assert.is_true(setCustomEnvColor(257, 1, 2, 3, 255)) + local colors = getCustomEnvColorTable() + assert.are.equal(1, colors[257][1]) + assert.are.equal(2, colors[257][2]) + assert.are.equal(3, colors[257][3]) + end) + + it("setCustomEnvColor returns nil and a message for an out-of-range component", function() + local ok, err = setCustomEnvColor(501, 256, 0, 0) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests map labels", function() + local labelId + + it("createMapLabel returns a numeric label ID", function() + labelId = createMapLabel(areaAlpha, "MapperSpecLabel", 0, 0, 0, 255, 255, 255, 0, 0, 0) + assert.is_number(labelId) + assert.is_true(labelId >= 0) + end) + + it("getMapLabels lists the label by ID and text", function() + local labels = getMapLabels(areaAlpha) + assert.is_table(labels) + assert.are.equal("MapperSpecLabel", labels[labelId]) + end) + + it("getMapLabel returns the properties of a label looked up by ID", function() + local label = getMapLabel(areaAlpha, labelId) + assert.is_table(label) + assert.are.equal("MapperSpecLabel", label.Text) + end) + + it("getMapLabel returns nil and a message for an unknown area", function() + local label, err = getMapLabel(missingAreaId, 0) + assert.is_nil(label) + assert.is_string(err) + end) + + it("createMapImageLabel creates a label (ID >= 0) even when the image is missing", function() + -- A missing image still creates a real (image-less) label; only an invalid + -- area returns -1, so pin ID >= 0 and its presence in the area. + local id = createMapImageLabel(areaAlpha, getMudletHomeDir() .. "/nonexistent.png", 0, 0, 0, 10, 10, 30.0, true) + assert.is_number(id) + assert.is_true(id >= 0) + assert.is_not_nil(getMapLabels(areaAlpha)[id]) + deleteMapLabel(areaAlpha, id) + end) + + it("deleteMapLabel removes the label from getMapLabels", function() + deleteMapLabel(areaAlpha, labelId) + assert.is_nil(getMapLabels(areaAlpha)[labelId]) + end) + end) + + describe("Tests room user data", function() + it("setRoomUserData is read back by getRoomUserData", function() + assert.is_true(setRoomUserData(rSandA, "colour", "blue")) + assert.are.equal("blue", getRoomUserData(rSandA, "colour")) + end) + + it("getRoomUserData returns an empty string for a missing key in back-compat mode", function() + assert.are.equal("", getRoomUserData(rSandA, "no-such-key")) + end) + + it("getRoomUserData returns nil and a message for a missing key with full error reporting", function() + local value, err = getRoomUserData(rSandA, "no-such-key", true) + assert.is_nil(value) + assert.is_string(err) + end) + + it("getRoomUserDataKeys lists the keys of a room", function() + setRoomUserData(rSandB, "alpha", "1") + setRoomUserData(rSandB, "beta", "2") + local keys = getRoomUserDataKeys(rSandB) + assert.is_table(keys) + local set = {} + for _, k in pairs(keys) do set[k] = true end + assert.is_true(set["alpha"]) + assert.is_true(set["beta"]) + end) + + it("getAllRoomUserData returns the whole key/value map", function() + local data = getAllRoomUserData(rSandB) + assert.is_table(data) + assert.are.equal("1", data["alpha"]) + end) + + it("clearRoomUserDataItem removes a single key", function() + setRoomUserData(rSandB, "toremove", "x") + assert.is_true(clearRoomUserDataItem(rSandB, "toremove")) + assert.is_false(clearRoomUserDataItem(rSandB, "toremove")) + end) + + it("clearRoomUserData empties the room and returns false when already empty", function() + local id = createRoomID(); addRoom(id); setRoomArea(id, areaAlpha) + setRoomUserData(id, "k", "v") + assert.is_true(clearRoomUserData(id)) + assert.is_nil(next(getAllRoomUserData(id))) + assert.is_false(clearRoomUserData(id)) + deleteRoom(id) + end) + + it("setRoomUserData returns nil and a message for an unknown room", function() + local ok, err = setRoomUserData(missingRoomId, "k", "v") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("searchRoomUserData with no arguments lists all room-data keys", function() + setRoomUserData(rSandA, "searchable", "yes") + local keys = searchRoomUserData() + assert.is_table(keys) + local set = {} + for _, k in pairs(keys) do set[k] = true end + assert.is_true(set["searchable"]) + end) + + it("searchRoomUserData with a key and value returns the matching room IDs", function() + setRoomUserData(rSandA, "team", "red") + local rooms = searchRoomUserData("team", "red") + assert.is_table(rooms) + local set = {} + for _, id in pairs(rooms) do set[id] = true end + assert.is_true(set[rSandA]) + end) + end) + + describe("Tests room name offset and visibility", function() + -- these three wrap the room.ui_nameOffset / room.ui_showName user data + -- keys the map renderer reads, so the round trip is the observable effect + after_each(function() + clearRoomUserDataItem(rSandA, "room.ui_nameOffset") + clearRoomUserDataItem(rSandA, "room.ui_showName") + end) + + it("getRoomNameOffset returns zeroes for a room that has never been offset", function() + assert.are.same({0, 0}, {getRoomNameOffset(rSandA)}) + end) + + it("setRoomNameOffset round-trips an x and y shift", function() + setRoomNameOffset(rSandA, 3, 4) + assert.are.same({3, 4}, {getRoomNameOffset(rSandA)}) + assert.are.equal("3 4", getRoomUserData(rSandA, "room.ui_nameOffset")) + end) + + it("setRoomNameOffset stores only the y shift when x is zero", function() + setRoomNameOffset(rSandA, 0, 5) + assert.are.equal("5", getRoomUserData(rSandA, "room.ui_nameOffset")) + assert.are.same({0, 5}, {getRoomNameOffset(rSandA)}) + end) + + it("getRoomNameOffset reads a legacy single value as the y shift", function() + setRoomUserData(rSandA, "room.ui_nameOffset", "7") + assert.are.same({0, 7}, {getRoomNameOffset(rSandA)}) + end) + + it("setRoomNameVisible writes the flag the renderer looks for", function() + setRoomNameVisible(rSandA, true) + assert.are.equal("1", getRoomUserData(rSandA, "room.ui_showName")) + setRoomNameVisible(rSandA, false) + assert.are.equal("0", getRoomUserData(rSandA, "room.ui_showName")) + end) + + it("all three reject arguments of the wrong type", function() + assert.has_error(function() getRoomNameOffset("1") end) + assert.has_error(function() setRoomNameOffset(rSandA, "1", 1) end) + assert.has_error(function() setRoomNameOffset(rSandA, 1, "1") end) + assert.has_error(function() setRoomNameVisible(rSandA, "yes") end) + end) + + it("getRoomNameOffset keeps the sign of a negative shift", function() + setRoomNameOffset(rSandA, -3, -4) + assert.are.equal("-3 -4", getRoomUserData(rSandA, "room.ui_nameOffset")) + assert.are.same({-3, -4}, {getRoomNameOffset(rSandA)}) + end) + + it("getRoomNameOffset keeps the sign of a mixed pair", function() + setRoomNameOffset(rSandA, -3, 4) + assert.are.same({-3, 4}, {getRoomNameOffset(rSandA)}) + setRoomNameOffset(rSandA, 3, -4) + assert.are.same({3, -4}, {getRoomNameOffset(rSandA)}) + end) + + it("getRoomNameOffset keeps the sign of a lone negative y shift", function() + -- x == 0 makes setRoomNameOffset store the y shift on its own, which is + -- the one-value branch of the reader + setRoomNameOffset(rSandA, 0, -5) + assert.are.equal("-5", getRoomUserData(rSandA, "room.ui_nameOffset")) + assert.are.same({0, -5}, {getRoomNameOffset(rSandA)}) + end) + + it("getRoomNameOffset keeps the sign of a fractional offset", function() + -- T2DMap reads the same user data with QString::toDouble(), so the Lua + -- getter has to accept everything the renderer does + setRoomUserData(rSandA, "room.ui_nameOffset", "-1.5 -2.5") + assert.are.same({-1.5, -2.5}, {getRoomNameOffset(rSandA)}) + end) + end) + + describe("Tests area user data", function() + it("setAreaUserData is read back by getAreaUserData", function() + assert.is_true(setAreaUserData(areaAlpha, "climate", "temperate")) + assert.are.equal("temperate", getAreaUserData(areaAlpha, "climate")) + end) + + it("getAllAreaUserData returns the whole key/value map", function() + setAreaUserData(areaAlpha, "climate", "temperate") + local data = getAllAreaUserData(areaAlpha) + assert.is_table(data) + assert.are.equal("temperate", data["climate"]) + end) + + it("getAreaUserData returns nil and a message for a missing key", function() + local value, err = getAreaUserData(areaAlpha, "no-such-key") + assert.is_nil(value) + assert.is_string(err) + end) + + it("setAreaUserData rejects an empty key with nil and a message", function() + local ok, err = setAreaUserData(areaAlpha, "", "value") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("clearAreaUserDataItem removes a single key", function() + setAreaUserData(areaBeta, "toremove", "x") + assert.is_true(clearAreaUserDataItem(areaBeta, "toremove")) + assert.is_false(clearAreaUserDataItem(areaBeta, "toremove")) + end) + + it("clearAreaUserData empties the area and returns false when already empty", function() + setAreaUserData(areaGamma, "k", "v") + assert.is_true(clearAreaUserData(areaGamma)) + assert.is_nil(next(getAllAreaUserData(areaGamma))) + assert.is_false(clearAreaUserData(areaGamma)) + end) + + it("searchAreaUserData with a key and value returns the matching area IDs", function() + setAreaUserData(areaBeta, "region", "north") + local areas = searchAreaUserData("region", "north") + assert.is_table(areas) + local set = {} + for _, id in pairs(areas) do set[id] = true end + assert.is_true(set[areaBeta]) + end) + + it("getAllAreaUserData returns nil and a message for an unknown area", function() + local data, err = getAllAreaUserData(missingAreaId) + assert.is_nil(data) + assert.is_string(err) + end) + end) + + describe("Tests map user data", function() + it("setMapUserData is read back by getMapUserData", function() + assert.is_true(setMapUserData("mapper.spec.key", "value")) + assert.are.equal("value", getMapUserData("mapper.spec.key")) + end) + + it("getMapUserData returns nil and a message for a missing key", function() + local value, err = getMapUserData("mapper.spec.missing") + assert.is_nil(value) + assert.is_string(err) + end) + + it("setMapUserData rejects an empty key with nil and a message", function() + local ok, err = setMapUserData("", "value") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("getAllMapUserData includes a set key", function() + setMapUserData("mapper.spec.all", "here") + local data = getAllMapUserData() + assert.is_table(data) + assert.are.equal("here", data["mapper.spec.all"]) + end) + + it("clearMapUserDataItem removes a single key", function() + setMapUserData("mapper.spec.item", "x") + assert.is_true(clearMapUserDataItem("mapper.spec.item")) + assert.is_false(clearMapUserDataItem("mapper.spec.item")) + end) + + it("clearMapUserData wipes all map user data and reports it had data", function() + setMapUserData("mapper.spec.clearall", "x") + assert.is_true(clearMapUserData()) + assert.is_nil(getAllMapUserData()["mapper.spec.clearall"]) + end) + end) + + describe("Tests collision detection", function() + it("getCollisionLocationsInArea reports coordinates shared by rooms", function() + local a = createRoomID(); addRoom(a); setRoomArea(a, areaGamma); setRoomCoordinates(a, 7, 7, 7) + local b = createRoomID(); addRoom(b); setRoomArea(b, areaGamma); setRoomCoordinates(b, 7, 7, 7) + local collisions = getCollisionLocationsInArea(areaGamma) + assert.is_table(collisions) + local found = false + for _, coordinate in pairs(collisions) do + if coordinate[1] == 7 and coordinate[2] == 7 and coordinate[3] == 7 then + found = true + end + end + assert.is_true(found) + deleteRoom(a); deleteRoom(b) + end) + + it("getCollisionLocationsInArea returns nil and a message for an unknown area", function() + local collisions, err = getCollisionLocationsInArea(missingAreaId) + assert.is_nil(collisions) + assert.is_string(err) + end) + end) + + describe("Tests pathfinding with getPath", function() + after_each(function() + -- Guarantee a clean routing graph even if an assertion above failed. + setExitWeightFilter(nil) + setExitWeight(rA1, "east", 0) + lockRoom(rA2, false) + lockExit(rA1, "east", false) + end) + + it("finds the shortest route and fills the speedwalk globals", function() + local ok, weight = getPath(rA1, rA3) + assert.is_true(ok) + assert.are.equal(2, weight) + assert.are.same({"e", "e"}, speedWalkDir) + assert.are.same({tostring(rA2), tostring(rA3)}, speedWalkPath) + end) + + it("routes across an area boundary", function() + local ok = getPath(rA1, rB2) + assert.is_true(ok) + assert.are.same({"e", "e", "up", "e"}, speedWalkDir) + assert.are.same({tostring(rA2), tostring(rA3), tostring(rB1), tostring(rB2)}, speedWalkPath) + end) + + it("routes through a special exit using its command as the direction", function() + local ok = getPath(rB2, rG1) + assert.is_true(ok) + assert.are.same({"enter gate"}, speedWalkDir) + assert.are.same({tostring(rG1)}, speedWalkPath) + end) + + it("returns false, -1 and a message when no path exists", function() + local ok, weight, err = getPath(rA1, rSandA) + assert.is_false(ok) + assert.are.equal(-1, weight) + assert.is_string(err) + end) + + it("returns nil and a message for an invalid source roomID", function() + local ok, err = getPath(missingRoomId, rA3) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("reroutes when an exit weight makes the short route expensive", function() + setExitWeight(rA1, "east", 100) + local ok = getPath(rA1, rA3) + assert.is_true(ok) + assert.are.same({"s", "e", "n"}, speedWalkDir) + assert.are.same({tostring(rA4), tostring(rA5), tostring(rA3)}, speedWalkPath) + end) + + it("reroutes around a locked room", function() + lockRoom(rA2, true) + local ok = getPath(rA1, rA3) + assert.is_true(ok) + assert.are.same({tostring(rA4), tostring(rA5), tostring(rA3)}, speedWalkPath) + end) + + it("reroutes around a locked exit", function() + lockExit(rA1, "east", true) + local ok = getPath(rA1, rA3) + assert.is_true(ok) + assert.are.same({tostring(rA4), tostring(rA5), tostring(rA3)}, speedWalkPath) + end) + + it("honours an exit weight filter that blocks every exit", function() + setExitWeightFilter(function() return "block" end) + local ok = getPath(rA1, rA3) + assert.is_false(ok) + end) + + it("honours an exit weight filter that overrides a weight to reroute", function() + setExitWeightFilter(function(roomId) if roomId == rA2 then return 100000 end end) + local ok = getPath(rA1, rA3) + assert.is_true(ok) + assert.are.same({tostring(rA4), tostring(rA5), tostring(rA3)}, speedWalkPath) + end) + end) + + describe("Tests gotoRoom argument contract", function() + it("returns nil and a message for an invalid target room", function() + local ok, err = gotoRoom(missingRoomId) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("returns nil and a message when no path leads to the target", function() + -- Set the player room to an isolated sandbox room so the target is + -- unreachable and no speedwalk command is ever sent. + centerview(rSandA) + -- gotoRoom reports the no-path failure with false + message (it uses + -- warnArgumentValue's useFalseInsteadofNil form), unlike the invalid-room + -- case above which returns nil + message. + local ok, err = gotoRoom(rA1) + assert.is_false(ok) + assert.is_string(err) + end) + end) + + describe("Tests player room and centering", function() + it("centerview sets the player room that getPlayerRoom reads back", function() + assert.is_true(centerview(rA1)) + assert.are.equal(rA1, getPlayerRoom()) + end) + + it("centerview returns nil and a message for an unknown room", function() + local ok, err = centerview(missingRoomId) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests map zoom", function() + it("setMapZoom is read back by getMapZoom for a given area", function() + assert.is_true(setMapZoom(15, areaAlpha)) + assert.are.equal(15, getMapZoom(areaAlpha)) + end) + + it("getMapZoom returns nil and a message for an unknown area", function() + local zoom, err = getMapZoom(missingAreaId) + assert.is_nil(zoom) + assert.is_string(err) + end) + end) + + describe("Tests createMapper argument contract", function() + it("hard-errors when the required coordinate arguments are missing", function() + assert.has_error(function() createMapper() end) + end) + end) + + describe("Tests secondary map views", function() + it("createMapView, getMapViewIds, getMapViewInfo and closeMapView round-trip", function() + local viewId = createMapView(areaBeta) + assert.is_number(viewId) + assert.is_true(viewId > 0) + + local ids = getMapViewIds() + local set = {} + for _, id in pairs(ids) do set[id] = true end + assert.is_true(set[viewId]) + + local info = getMapViewInfo(viewId) + assert.is_table(info) + assert.are.equal(areaBeta, info.areaId) + assert.is_number(info.zoom) + assert.is_number(info.zLevel) + assert.is_number(info.centeredRoomId) + + assert.is_true(closeMapView(viewId)) + end) + + it("closeAllMapViews reports how many views it closed", function() + createMapView(areaAlpha) + createMapView(areaBeta) + local count = closeAllMapViews() + assert.is_number(count) + assert.is_true(count >= 2) + end) + + it("getMapViewInfo returns nil and a message for an unknown view", function() + local info, err = getMapViewInfo(987654) + assert.is_nil(info) + assert.is_string(err) + end) + end) + + describe("Tests registered map info", function() + it("registerMapInfo makes the label appear in getMapInfo and killMapInfo removes it", function() + assert.is_true(registerMapInfo("MapperSpecInfo", function() return "info", false, false end)) + assert.is_not_nil(getMapInfo()["MapperSpecInfo"]) + assert.is_true(killMapInfo("MapperSpecInfo")) + assert.is_nil(getMapInfo()["MapperSpecInfo"]) + end) + + it("killMapInfo returns nil and a message for an unknown label", function() + local ok, err = killMapInfo("NoSuchMapInfoLabel") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("Tests area image export", function() + it("exportAreaImage returns true for a valid area (the file is written asynchronously)", function() + assert.is_true(exportAreaImage(areaAlpha, getMudletHomeDir() .. "/mapper_spec_export.png")) + end) + + it("exportAreaImage returns nil and a message for an unknown area", function() + local ok, err = exportAreaImage(missingAreaId, getMudletHomeDir() .. "/unused.png") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + -- Selection functions require a mouse-driven selection that Lua cannot make, + -- and audit/updateMap have no directly observable return; only their argument + -- contracts and safe no-op paths are pinned here. + describe("Tests selection, audit and repaint contracts", function() + it("getMapSelection returns an empty table when nothing is selected", function() + local selection = getMapSelection() + assert.is_table(selection) + assert.is_nil(selection.center) + end) + + it("clearMapSelection returns false when there is no selection to clear", function() + assert.is_false(clearMapSelection()) + end) + + it("auditAreas runs without error", function() + assert.has_no.errors(function() auditAreas() end) + end) + + it("updateMap runs without error", function() + assert.has_no.errors(function() updateMap() end) + end) + end) + +end) + +-- closeMapWidget() has to leave the profile in a state that is distinguishable +-- from "the map widget is open", or every map window function keeps answering +-- for a widget the script just put away. +-- +-- The map dock has no window name, so windowVisible() cannot reach it and these +-- specs read the state through the map window functions instead. That works +-- because Host::mapWidget() derives its answer from the dock's own hidden +-- state: drop the hide() out of Host::closeMapWidget() and the two specs below +-- that assert the closed answers fail. +describe("Tests the open and closed states of the map widget", function() + setup(function() + assert.is_true(openMapWidget()) + end) + + teardown(function() + -- back to a right-docked, open widget: the position loop below leaves it + -- docked at the bottom otherwise, which shrinks the main console for + -- everything that runs after this file + openMapWidget("r") + resetMapWindowTitle() + end) + + before_each(function() + openMapWidget() + end) + + -- companion guard rather than a guard for the bug: closeMapWidget() reported + -- "already closed" before this was fixed too. It is here so that a fix which + -- stopped distinguishing the two calls would be caught. + it("reports the widget as closed once, and as already closed after that", function() + assert.is_true(closeMapWidget()) + local closed, message = closeMapWidget() + assert.is_nil(closed) + assert.are.equal("map widget already closed", message) + end) + + it("stops setMapWindowTitle from retitling a widget that was closed", function() + assert.is_true(setMapWindowTitle("still open")) + assert.is_true(closeMapWidget()) + local set, message = setMapWindowTitle("closed already") + assert.is_nil(set) + assert.are.equal("no floating/dockable type map window found", message) + end) + + it("makes the map window getters agree with setMapWindowTitle", function() + assert.is_true(closeMapWidget()) + local title, titleMessage = getMapWindowTitle() + assert.is_nil(title) + local x, geometryMessage = getMapWidgetGeometry() + assert.is_nil(x) + -- same wording from all three, so a script can test one and trust the rest + assert.are.equal("no floating/dockable type map window found", titleMessage) + assert.are.equal("no floating/dockable type map window found", geometryMessage) + end) + + it("hands the widget back on reopen", function() + setMapWindowTitle("before the close") + assert.is_true(closeMapWidget()) + assert.is_true(openMapWidget()) + -- the same dock comes back rather than a fresh one, so its title survives + assert.are.equal("before the close", getMapWindowTitle()) + assert.are.equal(4, select("#", getMapWidgetGeometry())) + assert.is_true(setMapWindowTitle("after the reopen")) + assert.are.equal("after the reopen", getMapWindowTitle()) + end) + + it("reopens from every docking position", function() + for _, position in ipairs({"f", "l", "r", "t", "b"}) do + assert.is_true(closeMapWidget()) + assert.is_true(openMapWidget(position), "could not reopen the map widget at " .. position) + assert.is_string(getMapWindowTitle()) + end + end) + + -- moveMapWidget/resizeMapWidget are openMapWidget in disguise, so they reopen + -- a closed widget rather than failing the way the getters do. Pinned because + -- it is the one place where the map functions do not agree about the state. + it("lets moveMapWidget and resizeMapWidget reopen a closed widget", function() + assert.is_true(closeMapWidget()) + resizeMapWidget(640, 480) + local _, _, width, height = getMapWidgetGeometry() + assert.are.same({640, 480}, {width, height}) + + assert.is_true(closeMapWidget()) + moveMapWidget(120, 130) + assert.are.equal(4, select("#", getMapWidgetGeometry())) + end) + + -- Neither of these can be reached from Lua, so they are recorded rather than + -- covered: the dock's own title bar close button and mudlet's map toolbar + -- button both hide the same dock, and Host::mapWidget() reads the dock's + -- hidden state so that it follows them without either having to know. + pending("the map dock's title bar close button leaves the map window functions reporting no map window - needs GUI automation") + + pending("the map toolbar button handing the map to a main window dock leaves the map window functions reporting no map window - needs GUI automation") +end) + +-- deleteMap wipes the whole map, so it lives in its own block that runs after +-- the shared-fixture tests and builds its own throwaway rooms. +describe("Tests deleteMap", function() + it("removes every room from the map", function() + local a = createRoomID(); addRoom(a) + local b = createRoomID(); addRoom(b) + assert.is_true(roomExists(a)) + assert.is_true(deleteMap()) + assert.is_false(roomExists(a)) + assert.is_false(roomExists(b)) + assert.is_nil(next(getRooms())) + end) +end) + +-- saveMap/loadMap replace the whole map, so this block runs last, after +-- deleteMap has already emptied it, and puts back whatever it found: the map is +-- shared with everything that runs after this file. +describe("Tests saveMap and loadMap", function() + local specDirectory = debug.getinfo(1, "S").source:match("^@(.*)[/\\]") + assert(specDirectory, "Mapper_spec.lua has to be run from a file so that it can find its fixtures") + local fixtureMap = specDirectory .. "/fixtures/maps/minimal-map.xml" + + -- Scratch names inside the self-test profile that nothing else writes. A run + -- that died between the setup below and its teardown leaves them behind, and + -- the backup one would then be a map from a different run, so they are + -- cleared on the way in rather than trusted. + local mapDirectory = getMudletHomeDir() .. "/map" + local backupPath = mapDirectory .. "/mapper_spec_backup.dat" + local savePath = mapDirectory .. "/mapper_spec_roundtrip.dat" + local brokenXmlPath = getMudletHomeDir() .. "/mapper_spec_broken.xml" + + -- saveMap() with no arguments writes a timestamped file of its own choosing, + -- so the only way to clear up after it is to spot what appeared + local function mapFiles() + local files = {} + for entry in lfs.dir(mapDirectory) do + if entry:lower():match("%.dat$") then + files[entry] = true + end + end + return files + end + + local function removeNewMapFiles(before) + for entry in pairs(mapFiles()) do + if not before[entry] then + os.remove(mapDirectory .. "/" .. entry) + end + end + end + + -- three rooms in one area, carrying a value for every kind of room data the + -- binary format stores separately, so a round-trip that dropped one shows up + local roomA, roomB, roomC + local function buildMap() + deleteMap() + local area = addAreaName("MapperSpecSaveArea") + roomA, roomB, roomC = createRoomID(), nil, nil + addRoom(roomA) + roomB = createRoomID(); addRoom(roomB) + roomC = createRoomID(); addRoom(roomC) + for _, id in ipairs({roomA, roomB, roomC}) do + setRoomArea(id, area) + end + setRoomCoordinates(roomA, 0, 0, 0) + setRoomCoordinates(roomB, 3, -4, 5) + setRoomCoordinates(roomC, 1, 1, 1) + setRoomName(roomA, "Saved Room A") + setRoomName(roomB, "Saved Room B") + setRoomEnv(roomB, 42) + setRoomWeight(roomB, 7) + setExit(roomA, roomB, "east") + setExit(roomB, roomA, "west") + addSpecialExit(roomB, roomC, "squeeze through") + setDoor(roomA, "e", 2) + setRoomUserData(roomA, "spec key", "spec value") + setRoomIDbyHash(roomA, "mapperSpecSavedHash") + -- the room symbol is stored as a number below format version 19 and as a + -- string from 19 up, and the custom environment colours are their own + -- section, so both are here for the versioned round-trip below + setRoomChar(roomB, "X") + setCustomEnvColor(42, 10, 20, 30, 255) + return area + end + + local function assertMapRestored() + assert.is_true(roomExists(roomA)) + assert.is_true(roomExists(roomB)) + assert.are.equal("Saved Room A", getRoomName(roomA)) + assert.are.equal("Saved Room B", getRoomName(roomB)) + assert.are.same({3, -4, 5}, {getRoomCoordinates(roomB)}) + assert.are.equal(42, getRoomEnv(roomB)) + assert.are.equal(7, getRoomWeight(roomB)) + assert.are.equal(roomB, getRoomExits(roomA)["east"]) + assert.are.equal(roomC, getSpecialExitsSwap(roomB)["squeeze through"]) + assert.are.equal(2, getDoors(roomA)["e"]) + assert.are.equal("spec value", getRoomUserData(roomA, "spec key")) + assert.are.equal(roomA, getRoomIDbyHash("mapperSpecSavedHash")) + assert.are.equal("MapperSpecSaveArea", getRoomAreaName(getRoomArea(roomA))) + assert.are.equal("X", getRoomChar(roomB)) + assert.are.same({10, 20, 30, 255}, getCustomEnvColorTable()[42]) + end + + setup(function() + os.remove(backupPath) + os.remove(savePath) + os.remove(brokenXmlPath) + -- snapshot whatever map the rest of the suite left behind, so that the + -- teardown can hand it back untouched + assert.is_true(saveMap(backupPath), "the map to be replaced could not be saved first") + end) + + teardown(function() + assert.is_true(loadMap(backupPath), "the map this block replaced could not be put back") + -- loadMap shows the mapper wherever it last was; the block above this one + -- guarantees an open, right-docked widget to everything that follows, so + -- put that back rather than leaving it wherever the loads left it + openMapWidget("r") + os.remove(backupPath) + os.remove(savePath) + os.remove(brokenXmlPath) + end) + + describe("Tests the saveMap argument contract", function() + it("hard-errors on a save location that is not a string", function() + -- a table rather than a number: Lua coerces a number to a string, and + -- saveMap takes it, writing a map file named after the number + assert.has_error(function() saveMap({}) end) + end) + + it("hard-errors on a format version that is not a number", function() + assert.has_error(function() saveMap(savePath, "twenty") end) + end) + + it("reports failure rather than raising when the file cannot be written", function() + -- false means the save failed: saveMap answers with success, not with an + -- error flag, which is worth pinning because it reads the other way round + assert.is_false(saveMap("/nosuchdirectory/mapper_spec.dat")) + end) + + it("refuses a format version this Mudlet cannot write", function() + finally(function() + -- a refused save leaves the map flagged as unsaved, which puts a + -- warning on the mapper for every spec that runs after this one + saveMap(savePath) + os.remove(savePath) + end) + assert.is_false(saveMap(savePath, 9999)) + end) + end) + + -- Careful with the order of anything added here: a load that fails still + -- empties the map first, both for a missing binary file (TMainConsole::loadMap + -- clears before it restores) and for an XML one (TMap::readXmlMapFile clears + -- before it parses), so none of these leave a map behind for the next spec. + describe("Tests the loadMap argument contract", function() + it("hard-errors on a path that is not a string", function() + assert.has_error(function() loadMap({}) end) + end) + + it("returns false for a binary map file that is not there", function() + assert.is_false(loadMap(mapDirectory .. "/nosuchmapfile.dat")) + end) + + it("returns nil and a message naming the missing XML file", function() + local ok, message = loadMap(mapDirectory .. "/nosuchmapfile.xml") + assert.is_nil(ok) + assert.is_string(message) + assert.is_truthy(message:find("was not found", 1, true)) + assert.is_truthy(message:find("nosuchmapfile.xml", 1, true)) + end) + + it("returns nil and a message for an XML file it cannot parse", function() + local file = assert(io.open(brokenXmlPath, "w")) + file:write("<map><areas><area id=\"1\" name=\"unterminated\">") + file:close() + + local ok, message = loadMap(brokenXmlPath) + assert.is_nil(ok) + assert.is_string(message) + assert.is_truthy(message:find("failure to import XML map file", 1, true)) + end) + end) + + describe("Tests the saveMap and loadMap round-trip", function() + it("puts every kind of room data back exactly as it was saved", function() + buildMap() + assert.is_true(saveMap(savePath)) + + -- wipe the lot, so that a loadMap which did nothing at all cannot pass + deleteMap() + assert.is_false(roomExists(roomA)) + + assert.is_true(loadMap(savePath)) + assertMapRestored() + end) + + it("replaces what is on the map rather than merging into it", function() + buildMap() + saveMap(savePath) + + local strayArea = addAreaName("MapperSpecStrayArea") + local stray = createRoomID() + addRoom(stray) + setRoomArea(stray, strayArea) + + assert.is_true(loadMap(savePath)) + assert.is_false(roomExists(stray)) + assert.is_nil(getAreaTable()["MapperSpecStrayArea"]) + assertMapRestored() + end) + + it("round-trips through the oldest format version Mudlet still writes", function() + buildMap() + assert.is_true(saveMap(savePath, 17)) + deleteMap() + + assert.is_true(loadMap(savePath)) + -- everything, including the room symbol, which version 17 writes as a + -- number where 19 and up write a string: the older spelling has to come + -- back as the same character + assertMapRestored() + end) + + it("saves into the profile's own map folder when given no path", function() + local before = mapFiles() + finally(function() removeNewMapFiles(before) end) + + buildMap() + assert.is_true(saveMap()) + + local added = 0 + for entry in pairs(mapFiles()) do + if not before[entry] then + added = added + 1 + end + end + -- the name it picks is a timestamp to the second, so a second save + -- inside the same second would land on the same file rather than a new + -- one; what matters is that it wrote into the profile at all + assert.is_true(added >= 1, "saveMap() with no path should write a map file of its own") + end) + + it("restores the profile's most recent map when given no path", function() + local before = mapFiles() + finally(function() removeNewMapFiles(before) end) + + buildMap() + -- the other map files in this folder also hold a buildMap() map, so mark + -- this one: loadMap() picks the newest file and has to pick this one + setRoomName(roomC, "Only In The Newest Save") + assert.is_true(saveMap()) + deleteMap() + + assert.is_true(loadMap()) + assertMapRestored() + assert.are.equal("Only In The Newest Save", getRoomName(roomC)) + end) + end) + + describe("Tests loadMap importing an XML map", function() + -- the fixture's own IDs, so that a load which quietly did nothing cannot + -- be mistaken for a successful import + local importedRoomA, importedRoomB = 4001, 4002 + + before_each(function() + deleteMap() + assert.is_true(loadMap(fixtureMap)) + end) + + it("creates the rooms the file describes", function() + assert.is_true(roomExists(importedRoomA)) + assert.is_true(roomExists(importedRoomB)) + assert.are.equal("Import Room One", getRoomName(importedRoomA)) + assert.are.equal("Import Room Two", getRoomName(importedRoomB)) + end) + + it("puts the rooms in the area the file names", function() + assert.are.equal(4001, getAreaTable()["Mapper Spec Import Area"]) + assert.are.equal(4001, getRoomArea(importedRoomA)) + assert.are.equal(4001, getRoomArea(importedRoomB)) + end) + + it("reads the coordinates and the environment of each room", function() + assert.are.same({0, 0, 0}, {getRoomCoordinates(importedRoomA)}) + assert.are.same({1, 2, 3}, {getRoomCoordinates(importedRoomB)}) + assert.are.equal(169, getRoomEnv(importedRoomA)) + assert.are.equal(170, getRoomEnv(importedRoomB)) + end) + + it("reads normal exits, doors and IRE-style special exits", function() + assert.are.equal(importedRoomB, getRoomExits(importedRoomA)["east"]) + assert.are.equal(importedRoomA, getRoomExits(importedRoomB)["west"]) + assert.are.equal(2, getDoors(importedRoomB)["w"]) + -- an exit with no direction but a command is how IRE maps spell a + -- special exit, and it has to arrive as one + assert.are.equal(importedRoomB, getSpecialExitsSwap(importedRoomA)["enter gate"]) + end) + + it("turns a hidden exit into a locked door", function() + -- IRE maps mark an exit the player cannot see with hidden="1" rather than + -- with a door type, and it arrives as door type 3, a locked door + assert.are.equal(importedRoomA, getRoomExits(importedRoomB)["north"]) + assert.are.equal(3, getDoors(importedRoomB)["n"]) + end) + + it("turns a room feature into room user data", function() + assert.are.equal("true", getRoomUserData(importedRoomA, "feature-shop")) + end) + + -- the file's <environments> block fills TMap::mEnvColors, which maps an + -- environment id to a stock colour index. getCustomEnvColorTable() reads + -- mCustomEnvColors, a different map, so there is nothing to read this back + -- with from Lua + pending("the environment colours an XML map declares have no Lua getter") + + it("throws away the map that was there before the import", function() + local stray = createRoomID() + addRoom(stray) + assert.is_true(loadMap(fixtureMap)) + assert.is_false(roomExists(stray)) + end) + end) + + -- setMapPerspective/shiftMapPerspective only exist in a build made with 3D + -- mapper support, which the CI and release builds are not + pending("setMapPerspective needs a Mudlet built with the 3D mapper") + + pending("shiftMapPerspective needs a Mudlet built with the 3D mapper") +end) diff --git a/src/mudlet-lua/tests/Media_spec.lua b/src/mudlet-lua/tests/Media_spec.lua new file mode 100644 index 000000000..7a7b7af79 --- /dev/null +++ b/src/mudlet-lua/tests/Media_spec.lua @@ -0,0 +1,1746 @@ +-- Specs for the media and text-to-speech Lua APIs. +-- +-- Both families were previously homed in other domain spec files: the media +-- contracts in Networking_spec.lua and the text-to-speech ones in +-- Miscallaneous_spec.lua. They live here now so the audio side of the API has +-- one home. +-- +-- The contract specs check what is deterministic without any backend at all: +-- argument validation and the nil+message / hard-error shapes. The effect specs +-- need a backend, so they play a WAV these specs generate into the profile's +-- media directory and drive Qt's mock speech engine, and they skip cleanly +-- where neither is available. Nothing here mocks a real API function. + +local function contains(haystack, needle) + return type(haystack) == "string" and haystack:find(needle, 1, true) ~= nil +end + +-- Asserts that calling fn raises a Lua error whose message contains needle. +-- Matching a message substring (rather than merely "did it error?") ensures the +-- function is actually registered and reached its own argument validation: an +-- unregistered/nil function would raise a different "attempt to call" error. +local function assertArgError(fn, needle) + local ok, err = pcall(fn) + assert.is_false(ok) + assert.is_true(contains(err, needle)) +end + +describe("Media playback functions validate their parameters", function() + -- None of these reach playMedia()/stopMedia() on a real file, so no playback + -- is started: each returns before the media engine is touched. + describe("playSoundFile", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() playSoundFile() end, "playSoundFile: need at least one argument") + end) + + it("raises a Lua error when the table form has no name", function() + assert.has_error(function() playSoundFile({}) end) + end) + + it("raises a Lua error for a negative fadein in the table form", function() + assert.has_error(function() playSoundFile({name = "x.wav", fadein = -1}) end) + end) + + it("returns nil when the ordered form supplies no filename", function() + local ok, err = playSoundFile(nil) + assert.is_nil(ok) + assert.is_true(contains(err, "missing argument 1")) + end) + end) + + describe("playMusicFile", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() playMusicFile() end, "playMusicFile: need at least one argument") + end) + + it("raises a Lua error when the table form has no name", function() + assert.has_error(function() playMusicFile({}) end) + end) + + it("raises a Lua error for a negative fadeout in the table form", function() + assert.has_error(function() playMusicFile({name = "x.mp3", fadeout = -5}) end) + end) + + it("raises a clean, non-doubled error when continue is not a boolean", function() + -- Regression #9547 (same defect class): the field publicName must not carry + -- "must be boolean", which errorArgumentType would then double. + local ok, err = pcall(function() playMusicFile({name = "x.mp3", continue = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for continue as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + end) + + describe("playVideoFile", function() + -- playVideoFileAsTableArgument shared the identical doubled-message defect on + -- its continue/stream/close boolean fields (#9547 defect class). + it("raises a clean, non-doubled error when continue is not a boolean", function() + local ok, err = pcall(function() playVideoFile({name = "x.mp4", continue = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for continue as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + + it("raises a clean, non-doubled error when stream is not a boolean", function() + local ok, err = pcall(function() playVideoFile({name = "x.mp4", stream = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for stream as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + + it("raises a clean, non-doubled error when close is not a boolean", function() + local ok, err = pcall(function() playVideoFile({name = "x.mp4", close = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for close as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + end) + + describe("getPlayingSounds", function() + it("raises a clean, non-doubled error when priority is not an integer", function() + -- Regression #9547 (same defect class): "value for priority must be integer" + -- doubled into "must be integer as number expected". + local ok, err = pcall(function() getPlayingSounds({priority = "high"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for priority as number expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be integer"), tostring(err)) + end) + end) + + describe("pauseSounds", function() + it("raises a Lua error when the single argument is not a table", function() + assertArgError(function() pauseSounds(5) end, "pauseSounds: needs to be a table") + end) + end) + + describe("pauseMusic", function() + it("raises a Lua error when the single argument is not a table", function() + assertArgError(function() pauseMusic("all") end, "pauseMusic: needs to be a table") + end) + end) + + describe("stopSounds", function() + it("returns true when stopping everything with no arguments", function() + assert.is_true(stopSounds()) + assert.equals(0, #getPlayingSounds()) + end) + + it("raises a Lua error for a negative fadeout in the table form", function() + assert.has_error(function() stopSounds({fadeout = -1}) end) + end) + + it("raises a clean, non-doubled error when priority is not an integer", function() + -- Regression #9547 (same defect class, adjacent field in this very parser): + -- "value for priority must be integer" doubled the type constraint. + local ok, err = pcall(function() stopSounds({priority = "high"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for priority as number expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be integer"), tostring(err)) + end) + + it("raises a clean, non-doubled error when fadeaway is not a boolean", function() + -- Regression #9547: the message must not double "boolean" (the field's + -- publicName previously carried "must be boolean" while the type validator + -- also appended "as boolean expected"). It is reported like the sibling + -- table-field validations in this parser (fadeout, name, key). + local ok, err = pcall(function() stopSounds({fadeaway = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for fadeaway as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + end) + + -- stopMusic and stopVideos parse the same table shape and shared the identical + -- doubled-"boolean" fadeaway defect fixed for stopSounds (#9547). + describe("stopMusic", function() + it("raises a clean, non-doubled error when fadeaway is not a boolean", function() + local ok, err = pcall(function() stopMusic({fadeaway = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for fadeaway as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + end) + + describe("stopVideos", function() + it("raises a clean, non-doubled error when fadeaway is not a boolean", function() + local ok, err = pcall(function() stopVideos({fadeaway = "yes"}) end) + assert.is_false(ok) + assert.is_true(contains(err, "value for fadeaway as boolean expected, got string!"), tostring(err)) + assert.is_false(contains(err, "must be boolean"), tostring(err)) + end) + end) +end) + +describe("Media load functions validate their parameters", function() + -- loadMusicFile/loadSoundFile/loadVideoFile are one preload request behind + -- three names: they share a pair of parsers which set no media type at all, + -- so what actually differs between them is the name in their error messages + -- and loadVideoFile taking the table form only. Nothing here names a file + -- that exists, so no preload gets as far as the media engine. + it("each raises a Lua error when called with no arguments", function() + assertArgError(function() loadMusicFile() end, "loadMusicFile: need at least one argument") + assertArgError(function() loadSoundFile() end, "loadSoundFile: need at least one argument") + assertArgError(function() loadVideoFile() end, "loadVideoFile: need at least one argument") + end) + + it("loadVideoFile raises a Lua error when its argument is not a table", function() + -- the video calls take the table form only + assertArgError(function() loadVideoFile("busted-media-absent.mkv") end, "loadVideoFile: needs to be a table") + end) + + it("the ordered form returns nil when it is given no file name", function() + local ok, err = loadSoundFile(nil) + assert.is_nil(ok) + assert.is_true(contains(err, "missing argument 1"), tostring(err)) + + ok, err = loadMusicFile("") + assert.is_nil(ok) + assert.is_true(contains(err, "missing argument 1"), tostring(err)) + end) + + it("the table form raises a Lua error when it is given no name", function() + -- Only the tail of the message: all three loads report this one as + -- loadMusicFile, whichever was called, and pinning that here would hold + -- that in place. + assertArgError(function() loadSoundFile({}) end, "missing name") + end) + + it("the ordered form raises a Lua error when the url is not a string", function() + assertArgError(function() loadSoundFile("busted-media-absent.wav", {}) end, "url as string expected, got table!") + end) + + it("the table form raises a Lua error for a wrongly typed name or url", function() + assertArgError(function() loadMusicFile({name = {}}) end, "value for name as string expected, got table!") + assertArgError(function() loadMusicFile({name = "busted-media-absent.mp3", url = {}}) end, "value for url as string expected, got table!") + end) +end) + +describe("Media query and stop functions validate their parameters", function() + -- The video calls, the pause calls and the paused-media queries take a table + -- and nothing else; the sound and music queries and stops take either form. + -- pauseSounds and pauseMusic have this same refusal checked above + local tableOnly = { + "getPlayingVideos", "getPausedSounds", "getPausedMusic", "getPausedVideos", + "pauseVideos", "stopVideos", + } + + for _, fnName in ipairs(tableOnly) do + it(fnName .. " raises a Lua error when its argument is not a table", function() + assertArgError(function() _G[fnName](5) end, fnName .. ": needs to be a table") + end) + end + + it("the ordered query forms raise a Lua error for a wrongly typed filter", function() + assertArgError(function() getPlayingSounds("busted-media-absent.wav", {}) end, "key as string expected, got table!") + assertArgError(function() getPlayingSounds("busted-media-absent.wav", "k", {}) end, "tag as string expected, got table!") + assertArgError(function() getPlayingSounds("busted-media-absent.wav", "k", "t", "loud") end, "priority as number expected, got string!") + assertArgError(function() getPlayingMusic("busted-media-absent.mp3", {}) end, "key as string expected, got table!") + end) + + it("the table query forms raise a Lua error for a wrongly typed filter", function() + assertArgError(function() getPlayingMusic({name = {}}) end, "value for name as string expected, got table!") + assertArgError(function() getPausedSounds({key = {}}) end, "value for key as string expected, got table!") + assertArgError(function() getPausedMusic({tag = {}}) end, "value for tag as string expected, got table!") + assertArgError(function() getPausedVideos({name = {}}) end, "value for name as string expected, got table!") + assertArgError(function() getPlayingVideos({key = {}}) end, "value for key as string expected, got table!") + end) + + it("the ordered stop forms raise a Lua error for a wrongly typed argument", function() + assertArgError(function() stopSounds("busted-media-absent.wav", "k", "t", "loud") end, "priority as number expected, got string!") + assertArgError(function() stopSounds("busted-media-absent.wav", "k", "t", 10, "yes") end, "fadeaway as boolean expected, got string!") + assertArgError(function() stopMusic("busted-media-absent.mp3", "k", "t", "yes") end, "fadeaway as boolean expected, got string!") + assertArgError(function() stopMusic("busted-media-absent.mp3", "k", "t", true, -1) end, "bad argument range for fadeout") + end) + + it("the table pause and stop forms raise a Lua error for a wrongly typed filter", function() + assertArgError(function() pauseSounds({name = {}}) end, "value for name as string expected, got table!") + assertArgError(function() pauseMusic({key = {}}) end, "value for key as string expected, got table!") + assertArgError(function() pauseVideos({tag = {}}) end, "value for tag as string expected, got table!") + assertArgError(function() stopVideos({name = {}}) end, "value for name as string expected, got table!") + end) + + it("the ordered play forms raise a Lua error for a wrongly typed argument", function() + assertArgError(function() playMusicFile("busted-media-absent.mp3", {}) end, "volume as number expected, got table!") + assertArgError(function() playMusicFile("busted-media-absent.mp3", 50, 0, 0, 0, 1, {}) end, "key as string expected, got table!") + assertArgError(function() playSoundFile("busted-media-absent.wav", 50, 0, 0, 0, 1, "k", {}) end, "tag as string expected, got table!") + end) + + it("a numeric key in a table argument does not stop the rest of it being read", function() + -- Reading a numeric key with lua_tostring() converts it in place, and the + -- step of the iteration that follows then refuses the key it is handed, so + -- every table parser reads its keys from a copy. Lua walks a table's array + -- part first, which puts the numeric key ahead of the named ones here. + assert.is_true(playSoundFile({[1] = "junk", name = "busted-media-absent.wav"})) + assert.is_true(stopSounds({[1] = "junk", key = "busted-media-no-such-key"})) + assert.is_table(getPlayingMusic({[1] = "junk", name = "busted-media-absent.mp3"})) + assert.is_table(getPausedVideos({[1] = "junk", key = "busted-media-no-such-key"})) + end) + + it("the ordered play forms refuse a negative fade", function() + -- Only the range refusal, not the whole message: the music parser's fade + -- messages name playSoundFile, and pinning that here would hold it in + -- place. + assertArgError(function() playMusicFile("busted-media-absent.mp3", 50, -1) end, "bad argument range for fadein") + assertArgError(function() playSoundFile("busted-media-absent.wav", 50, 0, -1) end, "bad argument range for fadeout") + end) + + it("every query returns an empty table while nothing is playing", function() + -- with everything stopped, each of the six queries answers with a table + -- rather than with nil or a false-plus-message pair + assert.is_true(stopSounds()) + assert.is_true(stopMusic()) + assert.is_true(stopVideos()) + + for _, query in ipairs({getPlayingSounds, getPlayingMusic, getPlayingVideos, getPausedSounds, getPausedMusic, getPausedVideos}) do + local result = query() + assert.is_table(result) + assert.equals(0, #result) + -- and the same with a filter that matches nothing + assert.same({}, query({key = "busted-media-no-such-key"})) + end + end) +end) + +describe("Media playback effects with a generated sound file", function() + -- The API media functions play files out of the profile's own media + -- directory, so instead of shipping a binary fixture these specs write a + -- short WAV there: 150ms of 8 bit, 8kHz mono silence, which every decoder + -- accepts and which keeps a play-to-finish round trip under a fifth of a + -- second. + -- + -- Playback needs Qt Multimedia to actually run a player. It does so without + -- any audio device - verified locally with PulseAudio and ALSA made + -- unreachable, where the ffmpeg backend still drives the player from start + -- to finish - which is the situation on CI. Should an environment turn up + -- where it cannot, the canary in mediaPlaybackUnavailable() pends these + -- specs instead of failing them. + -- Two files: a short one for the spec that waits for playback to end on its + -- own, and a long one for the specs that have to still be playing when they + -- stop or pause it, so a slow runner cannot turn a natural finish into a + -- spurious failure. + -- A second long file so a spec can tell one playback from another by name. + local soundFile = "busted-media-tone.wav" + local longSoundFile = "busted-media-hold.wav" + local otherLongSoundFile = "busted-media-hold-other.wav" + local mediaDirectory = getMudletHomeDir() .. "/media" + local playbackObserved + + local function littleEndian(value, byteCount) + local bytes = {} + for _ = 1, byteCount do + bytes[#bytes + 1] = string.char(value % 256) + value = math.floor(value / 256) + end + return table.concat(bytes) + end + + local function silentWav(milliseconds) + local sampleRate = 8000 + -- 128 is silence for unsigned 8 bit samples + local samples = string.rep(string.char(128), math.floor(sampleRate * milliseconds / 1000)) + local format = "fmt " .. littleEndian(16, 4) .. littleEndian(1, 2) .. littleEndian(1, 2) + .. littleEndian(sampleRate, 4) .. littleEndian(sampleRate, 4) .. littleEndian(1, 2) .. littleEndian(8, 2) + local data = "data" .. littleEndian(#samples, 4) .. samples + local body = "WAVE" .. format .. data + return "RIFF" .. littleEndian(#body, 4) .. body + end + + local function writeMediaFile(name, milliseconds) + lfs.mkdir(mediaDirectory) + local handle = io.open(mediaDirectory .. "/" .. name, "wb") + assert.is_not_nil(handle, "could not write the media fixture " .. name) + handle:write(silentWav(milliseconds)) + handle:close() + end + + local function writeSoundFiles() + writeMediaFile(soundFile, 150) + writeMediaFile(longSoundFile, 10000) + writeMediaFile(otherLongSoundFile, 10000) + end + + -- Cleanups to run at the end of the current spec. busted's finally() holds + -- one function rather than a list (busted/init.lua: `env.finally = + -- function(fn) finally = fn end`), so a spec that has two things to undo - + -- and several here do - would keep only the last of them. after_each drains + -- this instead, in reverse, and runs whatever a failed spec got as far as + -- registering. + local cleanups = {} + + local function onCleanup(undo) + cleanups[#cleanups + 1] = undo + end + + -- purgeMediaCache() empties the whole media directory, not just the fixtures + -- these specs wrote, and the self-test profile persists between runs on a + -- developer's machine. Anything else already in there is moved aside for the + -- duration of the spec and put back afterwards. + local function preserveMediaDirectory() + local stash = getMudletHomeDir() .. "/busted-media-stash" + local preserved = {} + for entry in lfs.dir(mediaDirectory) do + if entry ~= "." and entry ~= ".." and entry ~= soundFile and entry ~= longSoundFile and entry ~= otherLongSoundFile then + preserved[#preserved + 1] = entry + end + end + if #preserved == 0 then + return + end + lfs.mkdir(stash) + for _, entry in ipairs(preserved) do + os.rename(mediaDirectory .. "/" .. entry, stash .. "/" .. entry) + end + onCleanup(function() + lfs.mkdir(mediaDirectory) + for _, entry in ipairs(preserved) do + os.rename(stash .. "/" .. entry, mediaDirectory .. "/" .. entry) + end + lfs.rmdir(stash) + end) + end + + -- Collects every occurrence of a media event for the duration of one spec. + -- stopSounds() and pauseSounds() change the player's state inside the call + -- itself, so the matching event is raised before a waitForEvent() could be + -- armed; a handler sees those as well as the asynchronous ones. + local function collect(eventName, into) + local handler = registerAnonymousEventHandler(eventName, function(_, file, path, mediaType, key, tag) + into[#into + 1] = {file = file, path = path, mediaType = mediaType, key = key, tag = tag} + end) + onCleanup(function() killAnonymousEventHandler(handler) end) + end + + -- Waits until collected holds count entries. A media event can be raised + -- inside the call that caused it, so waiting has to start with a look. + local function waitForCount(eventName, collected, count) + for _ = 1, 5 do + if #collected >= count then + return + end + waitForEvent(eventName, 1000) + end + end + + -- The media events carry QUrl::path(), which puts a slash in front of a + -- drive-lettered Windows path ("/C:/..."). Take that back off so one + -- expected value works on every platform. + local function eventPath(path) + return (tostring(path):gsub("^/(%a:/)", "%1")) + end + + -- CI sets this so a missing playback turns into a failure there rather than + -- into a green skip; a developer's machine without a media backend still + -- passes. + local requireMedia = os.getenv("MUDLET_TEST_REQUIRE_MEDIA") + + -- Returns true when the caller must stop because this environment has no + -- working media backend at all. Runs one throwaway playback to find out, and + -- takes its fixtures back out of the profile when there is no point keeping + -- them. + local function mediaPlaybackUnavailable() + if playbackObserved == nil then + writeSoundFiles() + playSoundFile({name = soundFile, key = "busted-media-canary"}) + playbackObserved = waitForEvent("sysMediaStarted", 5000) ~= nil + stopSounds() + if not playbackObserved then + os.remove(mediaDirectory .. "/" .. soundFile) + os.remove(mediaDirectory .. "/" .. longSoundFile) + os.remove(mediaDirectory .. "/" .. otherLongSoundFile) + end + end + if playbackObserved then + return false + end + if requireMedia then + assert.is_true(false, "MUDLET_TEST_REQUIRE_MEDIA is set but playing a sound raised no sysMediaStarted event") + end + pending("Qt Multimedia did not start playback in this environment") + return true + end + + -- The fixture server of CI/http-fixture-server.py, when the harness started + -- one and handed its ephemeral port over. A preload's only observable effect + -- is the fetch it starts for a file the profile does not have, so the two + -- load specs below are the media ones that need a server to talk to. + local httpPort = os.getenv("MUDLET_TEST_HTTP_PORT") + local requireFixture = os.getenv("MUDLET_TEST_REQUIRE_HTTP_FIXTURE") + -- the file CI/http-fixtures/ serves, and its contents + local fixtureFile = "fixture.txt" + local fixtureBody = "Mudlet self-test HTTP fixture.\n" + + local function noFixtureServer() + if httpPort then + return false + end + if requireFixture then + assert.is_true(false, "MUDLET_TEST_REQUIRE_HTTP_FIXTURE is set but MUDLET_TEST_HTTP_PORT is not - the fixture server was not started") + end + pending("no local HTTP fixture server (set MUDLET_TEST_HTTP_PORT)") + return true + end + + -- The url a media request is given is a directory: TMedia appends the file + -- name to it. + local function fixtureUrl() + return "http://127.0.0.1:" .. httpPort + end + + local function readFile(path) + local handle = io.open(path, "rb") + if not handle then + return nil + end + local contents = handle:read("*a") + handle:close() + return contents + end + + -- Video playback draws into a widget the request names with its key: + -- TMainConsole::setupVideoOutput() looks that key up among the profile's + -- labels and user windows, and refuses the request when it finds neither. The + -- label is not deleted afterwards, because the player that was handed its + -- video widget outlives the spec - it is only hidden again, so it does not + -- sit over the main console for every spec that runs later. + local videoLabel = "busted-media-video-label" + local videoLabelReady + + -- Handing a player that widget is the only thing this suite does that brings + -- a GL context up: Qt loads its XCB GL integration, and Mesa initialises and + -- then - at shutdown, with the context - unloads a driver. On the leak job's + -- Mesa that driver initialisation leaks around 240 bytes, and by the time + -- LeakSanitizer looks, the library holding the allocating frame is gone, so + -- no leak: line in asan-suppressions.txt can name it. That file asks for + -- exactly this: keep the context from being created test-side, which is also + -- why Other_spec leaves show3dMapView alone. The refusal spec below needs no + -- widget and no context, and every leg without leak checking - Windows CI and + -- a developer's own run - still plays the video. + local leakChecked = (os.getenv("ASAN_OPTIONS") or ""):find("detect_leaks=1", 1, true) ~= nil + + local function videoWidgetUnavailable() + if leakChecked then + pending("a video widget's GL context leaks in this job's GL driver, where nothing is left to suppress by name") + return true + end + return false + end + + local function withVideoLabel() + if not videoLabelReady then + createLabel(videoLabel, 0, 0, 40, 40, 1) + assert.equals("label", windowType(videoLabel)) + videoLabelReady = true + end + onCleanup(function() hideWindow(videoLabel) end) + end + + after_each(function() + -- before the stops below, not after: a spec's own event handlers have to + -- be gone before anything raises sysMediaFinished at them, or a handler + -- that starts a sound of its own leaves one playing into the next spec + for index = #cleanups, 1, -1 do + cleanups[index]() + end + cleanups = {} + + stopSounds() + stopMusic() + stopVideos() + end) + + it("playSoundFile plays the file and reports it from start to finish", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playSoundFile({name = soundFile, key = "busted-key", tag = "busted-tag"})) + + local event, file, path, mediaType, key, tag = waitForEvent("sysMediaStarted", 5000) + assert.equals("sysMediaStarted", event) + assert.equals(soundFile, file) + assert.equals(mediaDirectory .. "/" .. soundFile, eventPath(path)) + assert.equals("sound", mediaType) + assert.equals("busted-key", key) + assert.equals("busted-tag", tag) + + local finishedEvent, finishedFile, _, finishedType, finishedKey = waitForEvent("sysMediaFinished", 5000) + assert.equals("sysMediaFinished", finishedEvent) + assert.equals(soundFile, finishedFile) + assert.equals("sound", finishedType) + assert.equals("busted-key", finishedKey) + assert.equals(0, #getPlayingSounds()) + end) + + it("playSoundFile plays a file given in the ordered argument form", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + -- name[,volume]: the ordered form has its own parser, and it is the form + -- most scripts use + assert.is_true(playSoundFile(longSoundFile, 80)) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + local playing = getPlayingSounds() + assert.equals(1, #playing) + assert.equals(longSoundFile, playing[1].name) + assert.equals(80, playing[1].volume) + end) + + it("getPlayingSounds lists the sound that is playing", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-listed", tag = "busted-listed-tag"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + local playing = getPlayingSounds() + assert.equals(1, #playing) + assert.equals(longSoundFile, playing[1].name) + assert.equals("busted-listed", playing[1].key) + assert.equals("busted-listed-tag", playing[1].tag) + assert.is_number(playing[1].volume) + end) + + it("stopSounds stops the sound and reports it as finished", function() + if mediaPlaybackUnavailable() then + return + end + local finished = {} + collect("sysMediaFinished", finished) + + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-stopped"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + assert.equals(1, #getPlayingSounds()) + + assert.is_true(stopSounds()) + if #finished == 0 then + waitForEvent("sysMediaFinished", 5000) + end + -- the file runs for ten seconds, so a finish reported this soon after the + -- start is the stop taking effect rather than the file running out + assert.equals(1, #finished) + assert.equals("busted-stopped", finished[1].key) + assert.equals(0, #getPlayingSounds()) + end) + + it("pauseSounds parks the sound and playing it again resumes it", function() + if mediaPlaybackUnavailable() then + return + end + local paused = {} + collect("sysMediaPaused", paused) + + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-paused"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + assert.is_true(pauseSounds()) + if #paused == 0 then + -- the backend here reports the pause inside the call; wait in case + -- another one reports it a turn later + waitForEvent("sysMediaPaused", 5000) + end + assert.equals(1, #paused) + assert.equals("busted-paused", paused[1].key) + assert.equals(0, #getPlayingSounds()) + local pausedSounds = getPausedSounds() + assert.equals(1, #pausedSounds) + assert.equals(longSoundFile, pausedSounds[1].name) + + -- playing the same file again resumes the paused player rather than + -- starting a second one + playSoundFile({name = longSoundFile, key = "busted-paused"}) + assert.equals(1, #getPlayingSounds()) + assert.equals(0, #getPausedSounds()) + end) + + it("playing a different sound while one is paused ends the paused one and starts the new one", function() + if mediaPlaybackUnavailable() then + return + end + local finished, started = {}, {} + collect("sysMediaFinished", finished) + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-parked", tag = "busted-parked-tag"})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(pauseSounds()) + assert.equals(1, #getPausedSounds()) + + assert.is_true(playSoundFile({name = otherLongSoundFile, key = "busted-replacement"})) + + assert.equals(1, #finished) + assert.equals(longSoundFile, finished[1].file) + assert.equals("busted-parked", finished[1].key) + assert.equals("busted-parked-tag", finished[1].tag) + + waitForCount("sysMediaStarted", started, 2) + assert.equals(2, #started) + assert.equals(otherLongSoundFile, started[2].file) + local playing = getPlayingSounds() + assert.equals(1, #playing) + assert.equals(otherLongSoundFile, playing[1].name) + assert.equals(0, #getPausedSounds()) + end) + + it("playing different music while some is paused ends the paused track", function() + if mediaPlaybackUnavailable() then + return + end + local finished, started = {}, {} + collect("sysMediaFinished", finished) + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playMusicFile({name = longSoundFile, key = "busted-music-parked", tag = "busted-music-parked-tag"})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(pauseMusic()) + assert.equals(1, #getPausedMusic()) + + assert.is_true(playMusicFile({name = otherLongSoundFile, key = "busted-music-new"})) + + assert.equals(1, #finished) + assert.equals(longSoundFile, finished[1].file) + assert.equals("busted-music-parked", finished[1].key) + assert.equals("busted-music-parked-tag", finished[1].tag) + + waitForCount("sysMediaStarted", started, 2) + local music = getPlayingMusic() + assert.equals(1, #music) + assert.equals(otherLongSoundFile, music[1].name) + assert.equals(0, #getPausedMusic()) + end) + + it("a request refused on priority leaves the paused sound it would have taken over", function() + if mediaPlaybackUnavailable() then + return + end + local finished, started = {}, {} + collect("sysMediaFinished", finished) + collect("sysMediaStarted", started) + + writeSoundFiles() + -- first, because a priority of its own would stop every sound that has none + assert.is_true(playSoundFile({name = otherLongSoundFile, key = "busted-priority-loud", priority = 90})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-priority-parked"})) + waitForCount("sysMediaStarted", started, 2) + assert.is_true(pauseSounds({key = "busted-priority-parked"})) + assert.equals(1, #getPausedSounds()) + assert.equals(1, #getPlayingSounds()) + + -- refused, since the sound already playing is louder - and a paused player + -- is what a request is handed before anything else in the pool + assert.is_true(playSoundFile({name = soundFile, key = "busted-priority-refused", priority = 10})) + + assert.equals(0, #getPlayingSounds({key = "busted-priority-refused"})) + assert.equals(0, #finished) + local paused = getPausedSounds() + assert.equals(1, #paused) + assert.equals(longSoundFile, paused[1].name) + assert.equals("busted-priority-parked", paused[1].key) + end) + + it("a finite loop count plays every pass and reports each one", function() + if mediaPlaybackUnavailable() then + return + end + local finished, started = {}, {} + collect("sysMediaFinished", finished) + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playSoundFile({name = soundFile, key = "busted-looped", tag = "busted-looped-tag", loops = 2})) + waitForCount("sysMediaFinished", finished, 2) + + assert.equals(2, #started) + assert.equals(2, #finished) + for _, pass in ipairs(finished) do + assert.equals(soundFile, pass.file) + assert.equals("busted-looped", pass.key) + assert.equals("busted-looped-tag", pass.tag) + end + assert.equals(0, #getPlayingSounds()) + end) + + it("a sysMediaFinished handler that starts a sound leaves the caller's own request playing", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + + -- A player joins the pool only once the play() that made it has returned, + -- so warming several up - each started while the one before it is playing - + -- is what puts the re-entrant call in reach of the one being set up below. + local warmed = {} + collect("sysMediaStarted", warmed) + for index, key in ipairs({"busted-warm-one", "busted-warm-two", "busted-warm-three"}) do + assert.is_true(playSoundFile({name = longSoundFile, key = key})) + waitForCount("sysMediaStarted", warmed, index) + end + assert.equals(3, #getPlayingSounds()) + assert.is_true(stopSounds()) + + local reentered = 0 + local handler = registerAnonymousEventHandler("sysMediaFinished", function() + reentered = reentered + 1 + playSoundFile({name = otherLongSoundFile, key = "busted-handler-sound"}) + end) + onCleanup(function() killAnonymousEventHandler(handler) end) + + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-quiet", priority = 10})) + -- stops the sound above while it is still loading, which raises + -- sysMediaFinished into the handler from inside this very call + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-loud", priority = 90, loops = 3})) + + assert.is_true(reentered > 0, "the priority stop raised no sysMediaFinished, so nothing re-entered") + local loud = getPlayingSounds({key = "busted-loud"}) + assert.equals(1, #loud) + assert.equals(longSoundFile, loud[1].name) + assert.is_true(#getPlayingSounds({key = "busted-handler-sound"}) > 0) + end) + + it("the key filter picks out which sound is listed and stopped", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-filter"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + assert.equals(1, #getPlayingSounds({key = "busted-filter"})) + assert.equals(0, #getPlayingSounds({key = "busted-other-key"})) + + -- a stop aimed at another key leaves this sound alone + assert.is_true(stopSounds({key = "busted-other-key"})) + assert.equals(1, #getPlayingSounds()) + + assert.is_true(stopSounds({key = "busted-filter"})) + assert.equals(0, #getPlayingSounds()) + end) + + it("playMusicFile reports the music type and stopMusic ends it", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playMusicFile({name = longSoundFile, key = "busted-music"})) + + local event, file, _, mediaType, key = waitForEvent("sysMediaStarted", 5000) + assert.equals("sysMediaStarted", event) + assert.equals(longSoundFile, file) + assert.equals("music", mediaType) + assert.equals("busted-music", key) + + local music = getPlayingMusic() + assert.equals(1, #music) + assert.equals(longSoundFile, music[1].name) + assert.equals("busted-music", music[1].key) + -- sounds and music are tracked separately + assert.equals(0, #getPlayingSounds()) + + assert.is_true(stopMusic()) + assert.equals(0, #getPlayingMusic()) + end) + + it("pauseMusic parks the music and playing it again resumes it", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playMusicFile({name = longSoundFile, key = "busted-music-paused"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + assert.is_true(pauseMusic()) + assert.equals(0, #getPlayingMusic()) + assert.equals(1, #getPausedMusic()) + assert.equals(longSoundFile, getPausedMusic()[1].name) + + playMusicFile({name = longSoundFile, key = "busted-music-paused"}) + assert.equals(1, #getPlayingMusic()) + assert.equals(0, #getPausedMusic()) + end) + + it("playSoundFile starts nothing for a file the media directory does not have", function() + if mediaPlaybackUnavailable() then + return + end + -- the return value only says the request was understood; with no file and + -- no download url configured there is nothing to play + assert.is_true(playSoundFile("busted-media-absent.wav")) + assert.equals(0, #getPlayingSounds()) + end) + + it("purgeMediaCache empties the profile's media directory", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + preserveMediaDirectory() + local soundPath = mediaDirectory .. "/" .. soundFile + assert.is_not_nil(lfs.attributes(soundPath, "mode")) + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-purged"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + assert.is_true(purgeMediaCache()) + -- it stops every player before removing the directory + assert.equals(0, #getPlayingSounds()) + assert.is_nil(lfs.attributes(soundPath, "mode")) + end) + + it("purgeMediaCache returns nil and a message when it cannot empty the directory", function() + if getOS() == "windows" then + pending("staging an undeletable file needs chmod") + return + end + writeSoundFiles() + preserveMediaDirectory() + + local lockedDirectory = mediaDirectory .. "/busted-media-locked" + lfs.mkdir(lockedDirectory) + local pinnedFile = lockedDirectory .. "/busted-media-pinned.wav" + local handle = io.open(pinnedFile, "wb") + assert.is_not_nil(handle, "could not write the pinned media fixture") + handle:write("pinned") + handle:close() + os.execute("chmod 500 '" .. lockedDirectory .. "'") + onCleanup(function() + os.execute("chmod 700 '" .. lockedDirectory .. "'") + os.remove(pinnedFile) + lfs.rmdir(lockedDirectory) + end) + + if os.remove(pinnedFile) then + pending("this user can delete files out of a directory it cannot write") + return + end + + local ok, err = purgeMediaCache() + assert.is_nil(ok) + assert.is_true(contains(err, mediaDirectory), tostring(err)) + -- a purge that half happened, not one that did not happen + assert.is_nil(lfs.attributes(mediaDirectory .. "/" .. soundFile, "mode")) + end) + + it("a media url that is not http(s) reports a download error", function() + local errors = {} + local handler = registerAnonymousEventHandler("sysDownloadError", function(_, message, path) + errors[#errors + 1] = {message = message, path = path} + end) + onCleanup(function() killAnonymousEventHandler(handler) end) + + -- a file the media directory does not have, so the url is the only way to get it + assert.is_true(playSoundFile({name = "busted-media-absent-scheme.wav", url = "ftp://example.invalid/sounds"})) + waitForCount("sysDownloadError", errors, 1) + assert.equals(1, #errors) + assert.is_true(contains(errors[1].message, "http"), tostring(errors[1].message)) + assert.is_true(contains(errors[1].path, "busted-media-absent-scheme.wav"), tostring(errors[1].path)) + end) + + it("loadSoundFile fetches a file the media directory does not have and keeps it", function() + if noFixtureServer() then + return + end + local downloaded = mediaDirectory .. "/" .. fixtureFile + lfs.mkdir(mediaDirectory) + -- the download has to be the only file of that name, and a reused profile + -- may well have one of its own already + preserveMediaDirectory() + os.remove(downloaded) + onCleanup(function() os.remove(downloaded) end) + + local done = {} + collect("sysDownloadDone", done) + assert.is_true(loadSoundFile({name = fixtureFile, url = fixtureUrl()})) + waitForCount("sysDownloadDone", done, 1) + + assert.equals(1, #done) + assert.equals(fixtureBody, readFile(downloaded)) + end) + + it("loadMusicFile fetches from the url given in the ordered argument form", function() + if noFixtureServer() then + return + end + -- name[,url]: the ordered form has a parser of its own + local downloaded = mediaDirectory .. "/" .. fixtureFile + lfs.mkdir(mediaDirectory) + -- the download has to be the only file of that name, and a reused profile + -- may well have one of its own already + preserveMediaDirectory() + os.remove(downloaded) + onCleanup(function() os.remove(downloaded) end) + + local done = {} + collect("sysDownloadDone", done) + assert.is_true(loadMusicFile(fixtureFile, fixtureUrl())) + waitForCount("sysDownloadDone", done, 1) + + assert.equals(1, #done) + assert.equals(fixtureBody, readFile(downloaded)) + end) + + it("loadVideoFile reports a download error for a url it cannot fetch from", function() + -- The preload reaches the same fetch as a play would, so the refusal of a + -- url that is not http(s) is where a spec can see a load act on its url + -- without a server to answer it. + local errors = {} + local handler = registerAnonymousEventHandler("sysDownloadError", function(_, message, path) + errors[#errors + 1] = {message = message, path = path} + end) + onCleanup(function() killAnonymousEventHandler(handler) end) + + assert.is_true(loadVideoFile({name = "busted-media-absent-load.mkv", url = "ftp://example.invalid/videos"})) + waitForCount("sysDownloadError", errors, 1) + + -- picked out by name rather than by position: the collector sees every + -- download error, not only this one's + local reported + for _, failure in ipairs(errors) do + if contains(failure.path, "busted-media-absent-load.mkv") then + reported = failure + end + end + assert.is_not_nil(reported, "no download error named the file the load asked for") + assert.is_true(contains(reported.message, "http"), tostring(reported.message)) + end) + + it("playMusicFile starts a track given in the ordered argument form", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + -- name[,volume][,fadein][,fadeout][,start][,loops][,key][,tag] + assert.is_true(playMusicFile(longSoundFile, 70, 0, 0, 0, 1, "busted-music-ordered", "busted-music-ordered-tag")) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + local music = getPlayingMusic() + assert.equals(1, #music) + assert.equals(longSoundFile, music[1].name) + assert.equals(70, music[1].volume) + assert.equals("busted-music-ordered", music[1].key) + assert.equals("busted-music-ordered-tag", music[1].tag) + end) + + it("getPlayingMusic filters by name in both argument forms", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playMusicFile({name = longSoundFile, key = "busted-music-filter"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + -- name[,key][,tag] as ordered arguments + assert.equals(1, #getPlayingMusic(longSoundFile)) + assert.equals(1, #getPlayingMusic(longSoundFile, "busted-music-filter")) + assert.equals(0, #getPlayingMusic(longSoundFile, "busted-music-elsewhere")) + assert.equals(0, #getPlayingMusic(otherLongSoundFile)) + -- and the same filters as a table + assert.equals(1, #getPlayingMusic({name = longSoundFile})) + assert.equals(0, #getPlayingMusic({key = "busted-music-elsewhere"})) + end) + + it("getPlayingSounds filters by name, key and tag in the ordered argument form", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-ordered-key", tag = "busted-ordered-tag"})) + assert.equals("sysMediaStarted", (waitForEvent("sysMediaStarted", 5000))) + + -- name[,key][,tag][,priority] + assert.equals(1, #getPlayingSounds(longSoundFile)) + assert.equals(1, #getPlayingSounds(longSoundFile, "busted-ordered-key", "busted-ordered-tag")) + assert.equals(0, #getPlayingSounds(longSoundFile, "busted-ordered-key", "busted-other-tag")) + assert.equals(0, #getPlayingSounds(otherLongSoundFile)) + end) + + it("stopSounds stops only the sound named in the ordered argument form", function() + if mediaPlaybackUnavailable() then + return + end + local started = {} + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-stop-named"})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(playSoundFile({name = otherLongSoundFile, key = "busted-stop-spared"})) + waitForCount("sysMediaStarted", started, 2) + assert.equals(2, #getPlayingSounds()) + + -- name[,key][,tag][,priority][,fadeaway][,fadeout] + assert.is_true(stopSounds(longSoundFile)) + local playing = getPlayingSounds() + assert.equals(1, #playing) + assert.equals(otherLongSoundFile, playing[1].name) + end) + + it("stopMusic stops only the track named in the ordered argument form", function() + if mediaPlaybackUnavailable() then + return + end + local started = {} + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playMusicFile({name = longSoundFile, key = "busted-music-stop-named"})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(playMusicFile({name = otherLongSoundFile, key = "busted-music-stop-spared"})) + waitForCount("sysMediaStarted", started, 2) + assert.equals(2, #getPlayingMusic()) + + -- name[,key][,tag][,fadeaway][,fadeout] + assert.is_true(stopMusic(longSoundFile)) + local music = getPlayingMusic() + assert.equals(1, #music) + assert.equals(otherLongSoundFile, music[1].name) + end) + + it("pauseMusic and getPausedMusic take the same key filter", function() + if mediaPlaybackUnavailable() then + return + end + local started = {} + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playMusicFile({name = longSoundFile, key = "busted-music-parked-key"})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(playMusicFile({name = otherLongSoundFile, key = "busted-music-playing-key"})) + waitForCount("sysMediaStarted", started, 2) + + assert.is_true(pauseMusic({key = "busted-music-parked-key"})) + assert.equals(1, #getPlayingMusic()) + local paused = getPausedMusic() + assert.equals(1, #paused) + assert.equals(longSoundFile, paused[1].name) + assert.equals(1, #getPausedMusic({key = "busted-music-parked-key"})) + assert.equals(0, #getPausedMusic({key = "busted-music-playing-key"})) + end) + + it("getPausedSounds takes the same key filter as the sound that was paused", function() + if mediaPlaybackUnavailable() then + return + end + local started = {} + collect("sysMediaStarted", started) + + writeSoundFiles() + assert.is_true(playSoundFile({name = longSoundFile, key = "busted-sound-parked-key"})) + waitForCount("sysMediaStarted", started, 1) + assert.is_true(pauseSounds({key = "busted-sound-parked-key"})) + + assert.equals(1, #getPausedSounds({key = "busted-sound-parked-key"})) + assert.equals(0, #getPausedSounds({key = "busted-sound-never-played"})) + assert.equals(0, #getPausedSounds({name = otherLongSoundFile})) + end) + + it("playVideoFile plays into the label its key names and the video family reports it", function() + if videoWidgetUnavailable() or mediaPlaybackUnavailable() then + return + end + -- The file is the same silent WAV the sound specs use: what makes this a + -- video request is the type it is made as, which is what decides the widget + -- setup, the list it is tracked in and the media type its events carry. A + -- decodable picture would only change what the video widget draws. + withVideoLabel() + writeSoundFiles() + assert.equals(0, #getPlayingVideos()) + + assert.is_true(playVideoFile({name = longSoundFile, key = videoLabel, tag = "busted-video-tag"})) + local event, file, _, mediaType, key, tag = waitForEvent("sysMediaStarted", 5000) + assert.equals("sysMediaStarted", event) + assert.equals(longSoundFile, file) + assert.equals("video", mediaType) + assert.equals(videoLabel, key) + assert.equals("busted-video-tag", tag) + + local playing = getPlayingVideos() + assert.equals(1, #playing) + assert.equals(longSoundFile, playing[1].name) + assert.equals(videoLabel, playing[1].key) + -- videos are tracked apart from sounds and music + assert.equals(0, #getPlayingSounds()) + assert.equals(0, #getPlayingMusic()) + assert.equals(1, #getPlayingVideos({key = videoLabel})) + assert.equals(0, #getPlayingVideos({key = "busted-video-other-key"})) + + assert.is_true(pauseVideos()) + assert.equals(0, #getPlayingVideos()) + local paused = getPausedVideos() + assert.equals(1, #paused) + assert.equals(longSoundFile, paused[1].name) + assert.equals(1, #getPausedVideos({name = longSoundFile})) + + -- resumed by playing the same file again, like sounds and music are + assert.is_true(playVideoFile({name = longSoundFile, key = videoLabel})) + assert.equals(1, #getPlayingVideos()) + assert.equals(0, #getPausedVideos()) + + assert.is_true(stopVideos()) + assert.equals(0, #getPlayingVideos()) + end) + + it("playVideoFile starts nothing when its key names no widget to draw into", function() + if mediaPlaybackUnavailable() then + return + end + writeSoundFiles() + -- The request is understood, so it reports success; the widget lookup then + -- turns up nothing and the playback never starts. Nothing but the video + -- list says so, which is why this is worth holding to. + assert.is_true(playVideoFile({name = longSoundFile, key = "busted-media-no-such-widget"})) + assert.equals(0, #getPlayingVideos()) + assert.equals(0, #getPausedVideos()) + end) +end) + +describe("receiveMSP reports MSP is not enabled while offline", function() + it("returns nil and a message when MSP has not been negotiated", function() + local ok, err = receiveMSP("!!SOUND(x.wav)") + assert.is_nil(ok) + assert.is_true(contains(err, "MSP is not currently enabled")) + end) +end) + +describe("Tests the text-to-speech Lua API", function() + describe("Tests the text-to-speech family", function() + -- Mudlet can be compiled without TTS at all, in which case Other.lua + -- installs no-op shims that return nil instead of the real functions. + -- ttsGetQueue() returning a table is the cheapest proof that the real + -- ones are in place. + local function ttsSupported() + return type(ttsGetQueue()) == "table" + end + + -- Returns true when the caller should stop because this build has no TTS + -- functions to test. + local function ttsUnsupported() + if ttsSupported() then + return false + end + pending("Mudlet was compiled without TTS support") + return true + end + + -- ttsBuild() selects Qt's deterministic mock engine under + -- MUDLET_TEST_MODE, so the effect specs below never drive a developer's + -- real speech engine and nothing is ever spoken out loud. Where the mock + -- plugin is absent Qt leaves the engine with no voices, and those specs + -- skip so a local run still passes; CI sets MUDLET_TEST_REQUIRE_TTS_MOCK + -- to turn that skip into a failure, so a broken mock selection cannot + -- hide behind a green skip. + local testMode = os.getenv("MUDLET_TEST_MODE") + local requireMock = os.getenv("MUDLET_TEST_REQUIRE_TTS_MOCK") + + local function mockEngineReady() + return testMode and ttsSupported() and #ttsGetVoices() > 0 + end + + local function noMockEngine() + if mockEngineReady() then + return false + end + if requireMock then + assert.is_true(false, "MUDLET_TEST_REQUIRE_TTS_MOCK is set but the mock TTS engine has no voices - it was not selected") + end + pending("mock TTS engine unavailable (run with MUDLET_TEST_MODE and Qt's mock plugin)") + return true + end + + -- Switching voice needs a second voice to switch to. Gated like + -- noMockEngine() so a mock engine that stopped offering two voices cannot + -- quietly turn the voice-switching specs green by pending them. + local function tooFewVoices() + if #ttsGetVoices() >= 2 then + return false + end + if requireMock then + assert.is_true(false, "MUDLET_TEST_REQUIRE_TTS_MOCK is set but the mock TTS engine offers fewer than two voices") + end + pending("the mock engine offers only one voice in this environment") + return true + end + + -- Undone at the end of the current spec, for the same reason the media + -- specs above keep a list: busted's finally() holds one function, not a + -- list, and these specs have several things to put back. + local cleanups = {} + + local function onCleanup(undo) + cleanups[#cleanups + 1] = undo + end + + -- Collects every occurrence of an event for the duration of one spec. + -- The mock engine changes state inside the ttsSpeak()/ttsSkip() call + -- itself, so the matching event is raised before a waitForEvent() could + -- be armed; a handler sees those as well as the asynchronous ones. + local function collect(eventName, into) + local handler = registerAnonymousEventHandler(eventName, function(_, first) + into[#into + 1] = first == nil and true or first + end) + onCleanup(function() killAnonymousEventHandler(handler) end) + end + + -- The mock engine speaks in real time at roughly a tenth of a second per + -- word, so every utterance in these specs is deliberately short. + after_each(function() + if ttsSupported() then + -- clear first: skipping while the queue still holds a line starts + -- speaking that line, which would run on into the next spec + ttsClearQueue() + ttsSkip() + end + for index = #cleanups, 1, -1 do + cleanups[index]() + end + cleanups = {} + end) + + it("ttsSpeak rejects whitespace-only text", function() + if ttsUnsupported() then + return + end + local ok, err = ttsSpeak(" ") + assert.is_nil(ok) + assert.is_true(err:find("skipped empty text to speak (TTS)", 1, true) ~= nil) + end) + + it("ttsQueue rejects whitespace-only text", function() + if ttsUnsupported() then + return + end + local ok, err = ttsQueue("\t \n") + assert.is_nil(ok) + assert.is_true(err:find("skipped empty text to speak (TTS)", 1, true) ~= nil) + end) + + it("ttsSpeak and ttsQueue raise a Lua error for a non-string argument", function() + if ttsUnsupported() then + return + end + assert.has_error(function() ttsSpeak({}) end) + assert.has_error(function() ttsQueue({}) end) + end) + + it("the rate, pitch and volume setters raise a Lua error for a non-number", function() + if ttsUnsupported() then + return + end + assert.has_error(function() ttsSetRate("fast") end) + assert.has_error(function() ttsSetPitch({}) end) + assert.has_error(function() ttsSetVolume(false) end) + end) + + it("ttsGetQueue returns a table and false for out-of-range indexes", function() + if ttsUnsupported() then + return + end + ttsClearQueue() + assert.is_table(ttsGetQueue()) + -- Regression #9471: on an empty queue index 1 is exactly one past the + -- end (index == size), which used to pass the bounds check and read + -- out of range. + assert.is_false(ttsGetQueue(1)) + assert.is_false(ttsGetQueue(0)) + end) + + it("ttsClearQueue reports an out-of-range index instead of removing anything", function() + if ttsUnsupported() then + return + end + ttsClearQueue() + local ok, err = ttsClearQueue(3) + assert.is_nil(ok) + assert.equals("index 3 out of bounds for queue size 0", err) + end) + + it("ttsGetState reports one of the documented states", function() + if ttsUnsupported() then + return + end + -- ttsUnknownState is deliberately not accepted: it is the fallback the + -- state switch prints for a state it does not know about, so allowing + -- it here would make this assertion impossible to fail. + local states = { + ttsSpeechReady = true, ttsSpeechPaused = true, + ttsSpeechStarted = true, ttsSpeechError = true, + } + assert.is_true(states[ttsGetState()] == true, ttsGetState()) + end) + + it("ttsGetRate, ttsGetPitch and ttsGetVolume return numbers", function() + if ttsUnsupported() then + return + end + assert.is_number(ttsGetRate()) + assert.is_number(ttsGetPitch()) + assert.is_number(ttsGetVolume()) + end) + + it("the voice setters return false for a voice that does not exist", function() + if ttsUnsupported() then + return + end + assert.is_false(ttsSetVoiceByIndex(0)) + assert.is_false(ttsSetVoiceByIndex(9999)) + assert.is_false(ttsSetVoiceByName("no such voice is installed")) + end) + + it("the voice setters raise a Lua error for a wrongly typed argument", function() + if ttsUnsupported() then + return + end + assert.has_error(function() ttsSetVoiceByIndex("first") end) + assert.has_error(function() ttsSetVoiceByName({}) end) + end) + + it("ttsGetVoices lists the mock engine's voices and ttsGetCurrentVoice names one of them", function() + if noMockEngine() then + return + end + local voices = ttsGetVoices() + assert.is_true(#voices > 0) + local current = ttsGetCurrentVoice() + assert.is_string(current) + assert.is_true(table.contains(voices, current), current) + for _, name in ipairs(voices) do + assert.is_string(name) + end + end) + + it("ttsSpeak speaks the text and reports it until the engine goes ready again", function() + if noMockEngine() then + return + end + local started, ready = {}, {} + collect("ttsSpeechStarted", started) + collect("ttsSpeechReady", ready) + + ttsSpeak("Mudlet spec one") + assert.equals("ttsSpeechStarted", ttsGetState()) + assert.equals("Mudlet spec one", ttsGetCurrentLine()) + -- the first utterance of a session used to report an empty text here, + -- see the ttsSpeechStarted spec below + assert.same({"Mudlet spec one"}, started) + + assert.equals("ttsSpeechReady", (waitForEvent("ttsSpeechReady", 5000))) + assert.equals("ttsSpeechReady", ttsGetState()) + assert.equals(1, #ready) + -- with nothing being spoken the line is no longer reported + local line, err = ttsGetCurrentLine() + assert.is_nil(line) + assert.is_true(err:find("not speaking any text", 1, true) ~= nil) + end) + + it("ttsSpeak drops angle brackets from the text it speaks", function() + if noMockEngine() then + return + end + -- discussion: https://github.com/Mudlet/Mudlet/issues/4689 + ttsSpeak("<b>bold</b>") + assert.equals("bbold/b", ttsGetCurrentLine()) + end) + + it("ttsPause holds the utterance and ttsResume runs it to the end", function() + if noMockEngine() then + return + end + local paused = {} + collect("ttsSpeechPaused", paused) + + ttsSpeak("pause this line") + assert.equals("ttsSpeechStarted", ttsGetState()) + ttsPause() + -- the engine reports the pause asynchronously + assert.equals("ttsSpeechPaused", (waitForEvent("ttsSpeechPaused", 5000))) + assert.equals("ttsSpeechPaused", ttsGetState()) + assert.equals(1, #paused) + -- the paused utterance is still the current one + assert.equals("pause this line", ttsGetCurrentLine()) + + ttsResume() + assert.equals("ttsSpeechStarted", ttsGetState()) + assert.equals("ttsSpeechReady", (waitForEvent("ttsSpeechReady", 5000))) + assert.equals("ttsSpeechReady", ttsGetState()) + end) + + it("ttsSkip ends the current utterance immediately", function() + if noMockEngine() then + return + end + local ready = {} + collect("ttsSpeechReady", ready) + + ttsSpeak("a long enough sentence that it cannot possibly finish on its own by now") + assert.equals("ttsSpeechStarted", ttsGetState()) + ttsSkip() + -- the utterance would take over a second to speak, so a ready state + -- straight after the call can only be the skip taking effect + assert.equals("ttsSpeechReady", ttsGetState()) + assert.equals(1, #ready) + end) + + it("ttsQueue holds lines while the engine is busy and ttsGetQueue reads them back", function() + if noMockEngine() then + return + end + local queued = {} + collect("ttsSpeechQueued", queued) + + ttsClearQueue() + ttsSpeak("occupying the engine with a line that takes a while to speak") + ttsQueue("queued one") + ttsQueue("queued two") + assert.equals(2, #ttsGetQueue()) + assert.equals("queued one", ttsGetQueue(1)) + assert.equals("queued two", ttsGetQueue(2)) + assert.equals(2, #queued) + assert.equals("queued one", queued[1]) + assert.equals("queued two", queued[2]) + + -- an explicit index inserts rather than appends + ttsQueue("queued zero", 1) + assert.same({"queued zero", "queued one", "queued two"}, ttsGetQueue()) + + ttsClearQueue(1) + assert.same({"queued one", "queued two"}, ttsGetQueue()) + ttsClearQueue() + assert.equals(0, #ttsGetQueue()) + end) + + it("ttsQueue speaks straight away when the engine is idle", function() + if noMockEngine() then + return + end + ttsClearQueue() + assert.equals("ttsSpeechReady", ttsGetState()) + + ttsQueue("queued while idle") + -- nothing is waiting, so the line is taken back off the queue and + -- spoken instead of being held + assert.equals(0, #ttsGetQueue()) + assert.equals("ttsSpeechStarted", ttsGetState()) + assert.equals("queued while idle", ttsGetCurrentLine()) + end) + + it("ttsQueue drops angle brackets like ttsSpeak does", function() + if noMockEngine() then + return + end + ttsClearQueue() + ttsSpeak("occupying the engine with a line that takes a while to speak") + ttsQueue("<i>queued</i>") + assert.same({"iqueued/i"}, ttsGetQueue()) + end) + + it("ttsQueue clamps an index outside the queue instead of failing", function() + if noMockEngine() then + return + end + ttsClearQueue() + ttsSpeak("occupying the engine with a line that takes a while to speak") + ttsQueue("middle") + ttsQueue("beyond the end", 99) + ttsQueue("before the start", -5) + assert.same({"before the start", "middle", "beyond the end"}, ttsGetQueue()) + end) + + it("ttsSkip moves on to the next queued line", function() + if noMockEngine() then + return + end + ttsClearQueue() + ttsSpeak("occupying the engine with a line that takes a while to speak") + ttsQueue("the line after the skip") + assert.equals(1, #ttsGetQueue()) + + ttsSkip() + assert.equals(0, #ttsGetQueue()) + assert.equals("the line after the skip", ttsGetCurrentLine()) + end) + + it("a queued line starts speaking when the current one ends", function() + if noMockEngine() then + return + end + ttsClearQueue() + ttsSpeak("first line") + ttsQueue("second line") + assert.equals(1, #ttsGetQueue()) + + assert.equals("ttsSpeechReady", (waitForEvent("ttsSpeechReady", 5000))) + -- the queue is drained by the state change that ended the first line + assert.equals(0, #ttsGetQueue()) + assert.equals("second line", ttsGetCurrentLine()) + end) + + it("ttsSetRate, ttsSetPitch and ttsSetVolume are read back and clamped", function() + if noMockEngine() then + return + end + local rates, pitches, volumes = {}, {}, {} + collect("ttsRateChanged", rates) + collect("ttsPitchChanged", pitches) + collect("ttsVolumeChanged", volumes) + local rate, pitch, volume = ttsGetRate(), ttsGetPitch(), ttsGetVolume() + onCleanup(function() + ttsSetRate(rate) + ttsSetPitch(pitch) + ttsSetVolume(volume) + end) + + ttsSetRate(0.5) + assert.equals(0.5, ttsGetRate()) + ttsSetRate(5) + assert.equals(1, ttsGetRate()) + ttsSetRate(-5) + assert.equals(-1, ttsGetRate()) + assert.same({0.5, 1, -1}, rates) + + ttsSetPitch(0.25) + assert.equals(0.25, ttsGetPitch()) + ttsSetPitch(9) + assert.equals(1, ttsGetPitch()) + ttsSetPitch(-9) + assert.equals(-1, ttsGetPitch()) + assert.same({0.25, 1, -1}, pitches) + + ttsSetVolume(0.3) + assert.equals(0.3, ttsGetVolume()) + ttsSetVolume(9) + assert.equals(1, ttsGetVolume()) + -- volume clamps to zero rather than to -1 + ttsSetVolume(-9) + assert.equals(0, ttsGetVolume()) + assert.same({0.3, 1, 0}, volumes) + end) + + it("the voice setters switch voice and report it back", function() + if noMockEngine() or tooFewVoices() then + return + end + local voices = ttsGetVoices() + local changes = {} + local originalVoice = ttsGetCurrentVoice() + collect("ttsVoiceChanged", changes) + onCleanup(function() ttsSetVoiceByName(originalVoice) end) + + assert.is_true(ttsSetVoiceByName(voices[2])) + assert.equals(voices[2], ttsGetCurrentVoice()) + assert.is_true(ttsSetVoiceByIndex(1)) + assert.equals(voices[1], ttsGetCurrentVoice()) + assert.same({voices[2], voices[1]}, changes) + end) + + it("ttsSpeechStarted carries the text that just started being spoken", function() + if noMockEngine() then + return + end + -- Regression #9591: the text used to be recorded after say() returned, + -- and the engine changes state inside say(), so the event carried the + -- previous utterance. The handler has to be armed up front because the + -- event is raised before ttsSpeak() returns. + local started = {} + collect("ttsSpeechStarted", started) + + ttsClearQueue() + ttsSpeak("first spoken line") + assert.same({"first spoken line"}, started) + + ttsSkip() + ttsSpeak("second spoken line") + assert.same({"first spoken line", "second spoken line"}, started) + end) + + it("announces an utterance spoken over one that is still running", function() + if noMockEngine() then + return + end + -- #9659: the events are raised off the engine's state edges, and an + -- engine that is already speaking has no edge to report when it is + -- handed something else - so a script tracking what is being spoken was + -- never told the text had changed, while ttsGetCurrentLine() moved on + -- underneath it. No ttsSkip() here: the interruption is the point. + local started = {} + collect("ttsSpeechStarted", started) + + ttsClearQueue() + ttsSpeak("the utterance being spoken over") + ttsSpeak("the utterance spoken over it") + assert.same({"the utterance being spoken over", "the utterance spoken over it"}, started) + assert.equals("the utterance spoken over it", ttsGetCurrentLine()) + end) + + it("ttsSpeechStarted carries the queued line the drain started speaking", function() + if noMockEngine() then + return + end + -- Regression #9591 again: the queue drain in ttsStateChanged() had the + -- same say()-before-record ordering as ttsSpeak(), so the event named + -- the utterance the skip had just ended rather than the queued one that + -- replaced it. ttsGetCurrentLine() reads correctly either way, so only + -- the event's own argument can catch this. + local started = {} + collect("ttsSpeechStarted", started) + + ttsClearQueue() + ttsSpeak("the line that gets skipped") + ttsQueue("line taken off the queue") + ttsSkip() + assert.same({"the line that gets skipped", "line taken off the queue"}, started) + end) + + it("speaking over a busy engine leaves the direct utterance current", function() + if noMockEngine() then + return + end + -- The utterance a script asks for outright has to survive the queue: + -- an engine reporting Ready for the utterance say() interrupted used to + -- be read as an idle engine, which drained the queued line straight + -- over the requested one (#9659). The mock engine reports no such Ready, + -- so what this spec can hold onto is the state ttsSpeak() leaves behind + -- - the guard itself is exercised by TtsInterruptingSpeakTest, which + -- delivers that Ready the way a real engine does. + local started = {} + collect("ttsSpeechStarted", started) + + ttsClearQueue() + ttsSpeak("the busy utterance") + ttsQueue("still queued") + ttsSpeak("the direct utterance") + assert.equals(1, #ttsGetQueue()) + assert.equals("the direct utterance", ttsGetCurrentLine()) + -- ...and the queued line was not what got announced: + assert.same({"the busy utterance", "the direct utterance"}, started) + end) + + it("ttsSetVoiceByName reports success for a voice it switched to", function() + if noMockEngine() then + return + end + local voices = ttsGetVoices() + if #voices < 2 then + pending("the mock engine offers only one voice in this environment") + return + end + -- Regression #9590: dispatching ttsVoiceChanged used to wipe this + -- lua_State's whole stack, taking the already-pushed result with it and + -- handing the caller stack garbage. A handler must be listening for the + -- event to reach Lua at all, which is what collect() arranges here. + local changes = {} + collect("ttsVoiceChanged", changes) + local originalVoice = ttsGetCurrentVoice() + onCleanup(function() ttsSetVoiceByName(originalVoice) end) + + assert.is_true(ttsSetVoiceByName(voices[2])) + assert.equals(voices[2], ttsGetCurrentVoice()) + assert.same({voices[2]}, changes) + end) + end) + end) diff --git a/src/mudlet-lua/tests/Miscallaneous_spec.lua b/src/mudlet-lua/tests/Miscallaneous_spec.lua index 3f6d414ee..cf45d219b 100644 --- a/src/mudlet-lua/tests/Miscallaneous_spec.lua +++ b/src/mudlet-lua/tests/Miscallaneous_spec.lua @@ -1,3 +1,84 @@ +-- Everything here drives the real API: no mocking, and each function gets both +-- what it answers (including when it is misused) and, where it can be reached +-- offline, what it actually did - the file it wrote, the event it raised, the +-- line it put on screen. +-- +-- Console readback goes through textFrom()/wrapped(): the main console wraps +-- long lines, and a wrap swallows the space it broke at, so the text is +-- compared with all whitespace removed rather than line by line. + +local function contains(haystack, needle) + return type(haystack) == "string" and haystack:find(needle, 1, true) ~= nil +end + +-- Asserts that calling fn raises a Lua error whose message contains needle. +-- Matching a message substring (rather than merely "did it error?") ensures the +-- function is actually registered and reached its own argument validation: an +-- unregistered/nil function would raise a different "attempt to call" error. +local function assertArgError(fn, needle) + local ok, err = pcall(fn) + assert.is_false(ok) + assert.is_true(contains(err, needle), tostring(err)) +end + +-- Everything the main console gained since it was at line `mark`, joined up. +local function textFrom(mark) + return table.concat(getLines("main", mark, getLastLineNumber("main") + 1), "") +end + +local function wrapped(text) + return (tostring(text):gsub("%s+", "")) +end + +local function containsWrapped(haystack, needle) + return contains(wrapped(haystack), wrapped(needle)) +end + +local function fileExists(path) + return lfs.attributes(path, "mode") ~= nil +end + +local function readFile(path) + local handle = io.open(path, "rb") + if not handle then + return nil + end + local contents = handle:read("*a") + handle:close() + return contents +end + +local function writeFile(path, contents) + local handle = io.open(path, "wb") + assert.is_not_nil(handle, "could not write to " .. path) + handle:write(contents) + handle:close() +end + +-- Takes a copy of the encoding in use, to be put back once a spec has changed +-- it. setServerEncoding() also writes the profile's "encoding" file, and a +-- profile that never had one must not be left with one. +local function restoreServerEncoding() + local encodingFile = getMudletHomeDir() .. "/encoding" + local hadFile = fileExists(encodingFile) + local original = getServerEncoding() + return function() + setServerEncoding(original) + if not hadFile then + os.remove(encodingFile) + end + end +end + +local specDirectory = debug.getinfo(1, "S").source:match("^@(.*)[/\\]") +assert(specDirectory, "Miscallaneous_spec.lua has to be run from a file so that it can find its fixtures") +local fixtureDirectory = specDirectory .. "/fixtures/packages" + +-- waitForEvent() and pumpEvents() answer nil and a message outside test mode, +-- so the specs that need an event to arrive say so instead of failing on a +-- developer's interactive run. +local testMode = os.getenv("MUDLET_TEST_MODE") + describe("Tests C++ functions in the Miscallaneous category", function() describe("Tests the functionality of sendMSDP", function() it("should return nil and an error message when MSDP cannot be sent", function() @@ -93,4 +174,1361 @@ describe("Tests C++ functions in the Miscallaneous category", function() assert.is_true(err:find("module doesn't exist", 1, true) ~= nil) end) end) + + describe("Tests the functionality of getCommandSeparator", function() + it("returns the separator the profile splits commands on", function() + assert.equals(";;", getCommandSeparator()) + end) + + it("returns the separator that actually splits a command", function() + local fired = {} + local first = tempAlias("^mudletSpecSeparatorA$", function() fired[#fired + 1] = "A" end) + local second = tempAlias("^mudletSpecSeparatorB$", function() fired[#fired + 1] = "B" end) + finally(function() + killAlias(tostring(first)) + killAlias(tostring(second)) + end) + + expandAlias("mudletSpecSeparatorA" .. getCommandSeparator() .. "mudletSpecSeparatorB", false) + + assert.same({"A", "B"}, fired) + end) + end) + + describe("Tests the functionality of getTime", function() + it("raises a Lua error when the first argument is not a boolean", function() + assertArgError(function() getTime("yes") end, "getTime: bad argument #1 type") + end) + + it("raises a Lua error when the format is not a string", function() + assertArgError(function() getTime(true, {}) end, "getTime: bad argument #2 type") + end) + + it("returns the time as a string in the documented default format", function() + local time = getTime(true) + assert.is_string(time) + assert.is_truthy(time:match("^%d%d%d%d%.%d%d%.%d%d %d%d:%d%d:%d%d%.%d%d%d$"), time) + end) + + it("honours a custom format", function() + assert.equals(getTime(true, "yyyy"), tostring(getTime().year)) + assert.is_truthy(getTime(true, "hh:mm"):match("^%d%d:%d%d$")) + end) + + it("returns a table of the parts when not asked for a string", function() + local time = getTime() + assert.is_table(time) + for _, field in ipairs({"year", "month", "day", "hour", "min", "sec", "msec"}) do + assert.is_number(time[field], field .. " is missing") + end + assert.is_true(time.month >= 1 and time.month <= 12) + assert.is_true(time.day >= 1 and time.day <= 31) + assert.is_true(time.hour >= 0 and time.hour <= 23) + assert.is_true(time.min >= 0 and time.min <= 59) + assert.is_true(time.sec >= 0 and time.sec <= 60) + assert.is_true(time.msec >= 0 and time.msec <= 999) + end) + + it("returns the same date in both forms", function() + -- each call reads the clock afresh, so a run that steps over midnight + -- between the two would see different dates: the table form is read + -- between two string forms and has to match one of them + local before = getTime(true, "yyyy-MM-dd") + local asTable = getTime() + local after = getTime(true, "yyyy-MM-dd") + + local asDate = string.format("%04d-%02d-%02d", asTable.year, asTable.month, asTable.day) + assert.is_true(asDate == before or asDate == after, asDate .. " is neither " .. before .. " nor " .. after) + end) + end) + + describe("Tests the functionality of getProcessID", function() + it("returns this process's own id", function() + local pid = getProcessID() + assert.is_number(pid) + assert.equals(math.floor(pid), pid) + assert.equals(pid, getProcessID()) + if getOS() == "linux" then + -- the number is only worth anything if the operating system agrees + -- that it is this process + assert.equals("directory", lfs.attributes("/proc/" .. pid, "mode")) + else + assert.is_true(pid > 0) + end + end) + end) + + describe("Tests the functionality of getServerEncodingsList", function() + it("lists ASCII first and then every encoding Mudlet can be switched to", function() + local encodings = getServerEncodingsList() + assert.is_table(encodings) + assert.equals("ASCII", encodings[1]) + assert.is_true(#encodings > 1) + + local seen = {} + for _, encoding in ipairs(encodings) do + assert.is_string(encoding) + -- the "M_" prefix marks Mudlet's own codecs and is not part of the + -- name the rest of the API uses + assert.is_nil(encoding:find("^M_"), encoding .. " leaked its internal prefix") + assert.is_nil(seen[encoding], encoding .. " is listed twice") + seen[encoding] = true + end + assert.is_true(seen[getServerEncoding()], "the encoding in use is not in the list") + end) + + it("names every encoding in the form setServerEncoding accepts", function() + finally(restoreServerEncoding()) + + for _, encoding in ipairs(getServerEncodingsList()) do + assert.is_true(setServerEncoding(encoding), "the list offered " .. encoding .. " but setServerEncoding refused it") + assert.equals(encoding, getServerEncoding()) + end + end) + end) + + describe("Tests the functionality of getMudletInfo", function() + it("returns nothing and reports the encodings on the main console", function() + local mark = getLastLineNumber("main") + + assert.equals(0, select('#', getMudletInfo())) + + local text = textFrom(mark) + -- a profile that has not been switched to a real encoding reports the + -- ASCII it falls back to in quotes + local encoding = getServerEncoding() + local reported = containsWrapped(text, "Current encoding: " .. encoding) or containsWrapped(text, 'Current encoding: "' .. encoding .. '"') + assert.is_true(reported, text) + assert.is_true(containsWrapped(text, "Available encodings:"), text) + for _, encoding in ipairs(getServerEncodingsList()) do + assert.is_true(containsWrapped(text, encoding), encoding .. " was not reported") + end + end) + end) + + describe("Tests the functionality of getWindowsCodepage", function() + it("only answers on Windows", function() + local codepage, err = getWindowsCodepage() + if getOS() == "windows" then + assert.is_string(codepage) + assert.is_nil(err) + return + end + assert.is_nil(codepage) + assert.is_true(contains(err, "only needed on Windows"), tostring(err)) + end) + end) + + describe("Tests the functionality of getCharacterName", function() + it("returns nil+msg while no character name is set", function() + local name, err = getCharacterName() + if name ~= nil then + -- a profile that has a login set answers with it instead + assert.is_string(name) + assert.is_true(#name > 0) + return + end + assert.equals("no character name set", err) + end) + end) + + describe("Tests the profile description accessors", function() + -- The description is profile data on disk, so every spec here puts back + -- what it found: the self-test profile is reused between runs. + local descriptionFile = getMudletHomeDir() .. "/description" + + local function restoreDescription() + local original = getProfileInformation() + -- a profile that has never had a description has no file for one, and + -- writing the empty string back would leave one behind + local hadFile = fileExists(descriptionFile) + return function() + setProfileInformation(original) + if not hadFile then + os.remove(descriptionFile) + end + end + end + + describe("Tests the functionality of getProfileInformation", function() + it("raises a Lua error when the profile name is not a string", function() + assertArgError(function() getProfileInformation({}) end, "getProfileInformation: bad argument #1 type") + end) + + it("returns nil+msg for an empty profile name", function() + local info, err = getProfileInformation("") + assert.is_nil(info) + assert.equals("getProfileInformation: profile name cannot be empty", err) + end) + + it("returns nil+msg for a profile that does not exist", function() + local info, err = getProfileInformation("mudlet-spec-never-a-profile") + assert.is_nil(info) + assert.is_true(contains(err, "does not exist"), tostring(err)) + end) + + it("returns a string for this profile", function() + assert.is_string(getProfileInformation()) + assert.equals(getProfileInformation(), getProfileInformation(getProfileName())) + end) + end) + + describe("Tests the functionality of setProfileInformation", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() setProfileInformation() end, "setProfileInformation: bad argument #1 type") + end) + + it("raises a Lua error when the two argument form has no text", function() + assertArgError(function() setProfileInformation(getProfileName(), {}) end, "setProfileInformation: bad argument #2 type") + end) + + it("round-trips a description through getProfileInformation", function() + finally(restoreDescription()) + + assert.is_true(setProfileInformation("set by the Miscallaneous specs")) + assert.equals("set by the Miscallaneous specs", getProfileInformation()) + assert.equals("set by the Miscallaneous specs", getProfileInformation(getProfileName())) + end) + + it("round-trips a description named by profile", function() + finally(restoreDescription()) + + assert.is_true(setProfileInformation(getProfileName(), "named form")) + assert.equals("named form", getProfileInformation()) + end) + + it("is what getProfiles reports as the description", function() + finally(restoreDescription()) + setProfileInformation("as seen by getProfiles") + + assert.equals("as seen by getProfiles", getProfiles()[getProfileName()].description) + end) + + it("refuses a profile that does not exist", function() + -- BUG: writeProfileData() creates the profile folder it is given, so + -- naming a profile that is not there makes one, description file and + -- all - a phantom that the connection dialog and getProfiles() then + -- both list. Left pending rather than pinning it as correct. + pending("setProfileInformation() creates a folder for a profile that does not exist") + local ok, err = setProfileInformation("mudlet-spec-never-a-profile", "text") + assert.is_false(ok) + assert.is_string(err) + end) + end) + + describe("Tests the functionality of clearProfileInformation", function() + it("raises a Lua error when the profile name is not a string", function() + assertArgError(function() clearProfileInformation({}) end, "clearProfileInformation: bad argument #1 type") + end) + + it("refuses a profile that does not exist", function() + -- BUG: the same as setProfileInformation's - the write creates the + -- folder it was told to write into, so clearing the description of a + -- profile that is not there conjures one up. + pending("clearProfileInformation() creates a folder for a profile that does not exist") + local ok, err = clearProfileInformation("mudlet-spec-never-a-profile") + assert.is_false(ok) + assert.is_string(err) + end) + + it("puts back the description a bundled game ships with", function() + finally(restoreDescription()) + setProfileInformation("something else entirely") + + assert.is_true(clearProfileInformation()) + + -- the self-test profile is one of Mudlet's own games, so clearing + -- restores its built-in blurb rather than emptying the description + local restored = getProfileInformation() + assert.is_string(restored) + assert.are_not.equals("something else entirely", restored) + assert.is_true(contains(restored, "Busted"), restored) + end) + end) + end) + + describe("Tests the command history saving accessors", function() + describe("Tests the functionality of getSaveCommandHistory", function() + it("returns nil+msg for a command line that does not exist", function() + local saving, err = getSaveCommandHistory("mudlet-spec-no-such-command-line") + assert.is_nil(saving) + assert.is_true(contains(err, "not found"), tostring(err)) + end) + + it("answers for the main command line when not told which one", function() + local saving, message = getSaveCommandHistory() + assert.is_boolean(saving) + assert.is_string(message) + -- the name it defaults to is what makes the two forms the same call, + -- so the state is set through the named form and read back through both + finally(function() setSaveCommandHistory("main", saving) end) + assert.is_true(setSaveCommandHistory("main", not saving)) + assert.equals(not saving, (getSaveCommandHistory())) + assert.equals(not saving, (getSaveCommandHistory("main"))) + end) + end) + + describe("Tests the functionality of setSaveCommandHistory", function() + it("raises a Lua error when the argument is neither a name nor a boolean", function() + assertArgError(function() setSaveCommandHistory(5) end, "setSaveCommandHistory: bad argument #1 type") + end) + + it("turns saving on when told which command line, or none at all, but not whether to", function() + -- BUG: both forms are meant to default to turning saving on - the + -- implementation says so, and the branch that would read a second + -- argument after a name is unreachable without one. Both count their + -- arguments one too high, so they reach the type check and raise + -- instead. Left pending rather than pinning the raise as the contract. + pending("setSaveCommandHistory() and setSaveCommandHistory(name) raise instead of turning saving on") + local original = getSaveCommandHistory() + finally(function() setSaveCommandHistory(original) end) + setSaveCommandHistory(false) + + assert.is_true(setSaveCommandHistory()) + assert.is_true((getSaveCommandHistory())) + + setSaveCommandHistory(false) + assert.is_true(setSaveCommandHistory("main")) + assert.is_true((getSaveCommandHistory())) + end) + + it("round-trips through getSaveCommandHistory", function() + local original = getSaveCommandHistory() + finally(function() setSaveCommandHistory(original) end) + + assert.is_true(setSaveCommandHistory(false)) + assert.is_false((getSaveCommandHistory())) + assert.equals("disabled", (select(2, getSaveCommandHistory()))) + + assert.is_true(setSaveCommandHistory("main", true)) + local saving, message = getSaveCommandHistory("main") + assert.is_true(saving) + assert.equals("enabled (" .. getConfig("commandLineHistorySaveSize") .. " lines will be saved)", message) + end) + + it("is refused, and the getter reports off, while the profile has history saving turned off", function() + local savedLines = getConfig("commandLineHistorySaveSize") + finally(function() setConfig("commandLineHistorySaveSize", savedLines) end) + + setConfig("commandLineHistorySaveSize", 0) + + local saving, getterMessage = getSaveCommandHistory() + assert.is_false(saving) + assert.equals("disabled by profile global preference", getterMessage) + + local ok, setterMessage = setSaveCommandHistory(true) + assert.is_nil(ok) + assert.equals("disabled by profile global preference", setterMessage) + end) + end) + end) + + describe("Tests the logging functions", function() + describe("Tests the functionality of startLogging", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() startLogging() end, "startLogging: bad argument #1 type") + end) + + it("starts and stops logging, reporting the file it uses", function() + local logPath + finally(function() + startLogging(false) + if logPath then + os.remove(logPath) + end + end) + + local started, startMessage, startPath, startState = startLogging(true) + logPath = startPath + assert.is_true(started) + assert.is_string(startPath) + assert.equals(1, startState) + assert.is_true(contains(startMessage, startPath), startMessage) + assert.is_true(fileExists(startPath), "no log file was created") + + echo("mudlet-spec-logged-line\n") + + local stopped, stopMessage, stopPath, stopState = startLogging(false) + assert.is_true(stopped) + assert.equals(startPath, stopPath) + assert.equals(0, stopState) + assert.is_true(contains(stopMessage, "stopped being logged"), stopMessage) + -- the line logged most recently is held back for duplicate detection + -- and only written out when logging stops, so read the file after + assert.is_true(contains(readFile(startPath), "mudlet-spec-logged-line"), "the console output did not reach the log") + end) + + it("reports, rather than repeats, a state it is already in", function() + local logPath + finally(function() + startLogging(false) + if logPath then + os.remove(logPath) + end + end) + + local alreadyOff, offMessage, offPath, offState = startLogging(false) + assert.is_nil(alreadyOff) + assert.equals("Main console output was already not being logged to a file.", offMessage) + assert.is_nil(offPath) + assert.equals(-2, offState) + + logPath = select(3, startLogging(true)) + local alreadyOn, onMessage, onPath, onState = startLogging(true) + assert.is_nil(alreadyOn) + assert.equals(logPath, onPath) + assert.equals(-1, onState) + assert.is_true(contains(onMessage, "already being logged"), onMessage) + end) + end) + + describe("Tests the functionality of appendLog", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() appendLog() end, "appendLog: bad argument #1 type") + end) + + it("writes the text into the log file", function() + local logPath + finally(function() + startLogging(false) + if logPath then + os.remove(logPath) + end + end) + logPath = select(3, startLogging(true)) + + appendLog("mudlet-spec-appended-line") + + -- read only once logging has stopped: the log stream is buffered + startLogging(false) + local contents = readFile(logPath) + assert.is_string(contents) + assert.is_true(contains(contents, "mudlet-spec-appended-line"), "the appended text is not in the log") + end) + + it("writes nothing while logging is off", function() + local logPath + finally(function() + startLogging(false) + if logPath then + os.remove(logPath) + end + end) + logPath = select(3, startLogging(true)) + startLogging(false) + + assert.equals(0, select('#', appendLog("mudlet-spec-never-logged"))) + + local contents = readFile(logPath) + assert.is_string(contents, "the log file that was closed is not readable") + assert.is_false(contains(contents, "mudlet-spec-never-logged"), "the text was appended to a log that was closed") + end) + end) + end) + + describe("Tests the file watching functions", function() + local watchedFile = getMudletHomeDir() .. "/mudlet-spec-watched.txt" + + describe("Tests the functionality of addFileWatch", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() addFileWatch() end, "addFileWatch: bad argument #1 type") + end) + + it("returns nil+msg for a path that is not there", function() + local ok, err = addFileWatch(getMudletHomeDir() .. "/mudlet-spec-no-such-path") + assert.is_nil(ok) + assert.is_true(contains(err, "does not exist"), tostring(err)) + end) + end) + + describe("Tests the functionality of removeFileWatch", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() removeFileWatch() end, "removeFileWatch: bad argument #1 type") + end) + + it("returns false for a path nobody is watching", function() + assert.is_false(removeFileWatch(getMudletHomeDir() .. "/mudlet-spec-no-such-path")) + end) + end) + + describe("Tests watching a file for changes", function() + it("watches a file that is there, and stops when told to", function() + -- adding and removing a watch needs no events, so unlike the spec + -- below this one runs outside test mode too + finally(function() os.remove(watchedFile) end) + writeFile(watchedFile, "first\n") + + assert.is_true(addFileWatch(watchedFile)) + + assert.is_true(removeFileWatch(watchedFile)) + assert.is_false(removeFileWatch(watchedFile), "the watch was removed twice") + end) + + it("raises sysPathChanged until the watch is removed", function() + if not testMode then + pending("waiting for sysPathChanged needs MUDLET_TEST_MODE") + return + end + finally(function() + removeFileWatch(watchedFile) + os.remove(watchedFile) + end) + writeFile(watchedFile, "first\n") + + assert.is_true(addFileWatch(watchedFile)) + -- Windows works out that a file changed by comparing its modification + -- time against the one noted when the watch was added - the contents + -- and the size are not looked at - and that stamp only moves as fast + -- as the system clock ticks, about every 16ms. Rewriting the file in + -- the same tick would be invisible there, so leave the stamp room to + -- move before writing again. + pumpEvents(250) + writeFile(watchedFile, "second\n") + local event, path = waitForEvent("sysPathChanged", 5000) + assert.equals("sysPathChanged", event) + -- the watcher can report the path with the platform's own separators + assert.equals(watchedFile, (tostring(path):gsub("\\", "/"))) + + -- one write can produce more than one notification, so let the rest of + -- them arrive before the watch goes away: a straggler would otherwise + -- look like the removed watch still reporting + pumpEvents(250) + assert.is_true(removeFileWatch(watchedFile)) + writeFile(watchedFile, "third\n") + -- a watch that has been taken away must not report anything further, + -- so this one is meant to time out + assert.is_nil((waitForEvent("sysPathChanged", 750))) + end) + end) + end) + + describe("Tests the dictionary functions", function() + -- The words go into the profile's own dictionary file, which outlives the + -- run, so every spec takes back out what it put in. + local function withWords(...) + local words = {...} + finally(function() + for _, word in ipairs(words) do + removeWordFromDictionary(word) + end + end) + for _, word in ipairs(words) do + assert.is_true(addWordToDictionary(word), "could not add " .. word) + end + end + + local function indexOf(list, word) + for index, entry in ipairs(list) do + if entry == word then + return index + end + end + end + + describe("Tests the functionality of addWordToDictionary", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() addWordToDictionary() end, "addWordToDictionary: bad argument #1 type") + end) + + it("adds a word that getDictionaryWordList then lists", function() + withWords("mudletspecwibble") + + assert.is_not_nil(indexOf(getDictionaryWordList(), "mudletspecwibble")) + end) + + it("returns nil+msg for a word that is already there", function() + withWords("mudletspecwibble") + + local ok, err = addWordToDictionary("mudletspecwibble") + assert.is_nil(ok) + assert.is_true(contains(err, "already seems to be in the user dictionary"), tostring(err)) + end) + end) + + describe("Tests the functionality of removeWordFromDictionary", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() removeWordFromDictionary() end, "removeWordFromDictionary: bad argument #1 type") + end) + + it("takes the word back out of the list", function() + withWords("mudletspecwibble") + assert.is_not_nil(indexOf(getDictionaryWordList(), "mudletspecwibble")) + + assert.is_true(removeWordFromDictionary("mudletspecwibble")) + + assert.is_nil(indexOf(getDictionaryWordList(), "mudletspecwibble")) + end) + + it("returns nil+msg for a word that was never added", function() + local ok, err = removeWordFromDictionary("mudletspecnosuchword") + assert.is_nil(ok) + assert.is_true(contains(err, "does not seem to be in the user dictionary"), tostring(err)) + end) + end) + + describe("Tests the functionality of getDictionaryWordList", function() + it("returns the words sorted", function() + withWords("mudletspeczebra", "mudletspecapple") + + local words = getDictionaryWordList() + assert.is_table(words) + local apple = indexOf(words, "mudletspecapple") + local zebra = indexOf(words, "mudletspeczebra") + assert.is_not_nil(apple) + assert.is_not_nil(zebra) + assert.is_true(apple < zebra, "the word list came back unsorted") + end) + end) + + describe("Tests the functionality of spellCheckWord", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() spellCheckWord() end, "spellCheckWord: bad argument #1 type") + end) + + it("raises a Lua error when the dictionary choice is not a boolean", function() + assertArgError(function() spellCheckWord("word", "user") end, "spellCheckWord: bad argument #2 type") + end) + + it("knows a word that was added to the profile dictionary, and not one that was not", function() + withWords("mudletspecwibble") + + assert.is_true(spellCheckWord("mudletspecwibble", true)) + assert.is_false(spellCheckWord("mudletspecwobble", true)) + end) + + it("answers from the system dictionary, or says it has none", function() + local known, err = spellCheckWord("hello") + if known == nil then + assert.is_true(contains(err, "no main dictionaries found"), tostring(err)) + return + end + assert.is_boolean(known) + -- which words a system dictionary knows depends on the language it is + -- for, so the only answer worth asserting is for something no language + -- spells that way + assert.is_false(spellCheckWord("mudletspecqqzzxxvv")) + end) + end) + + describe("Tests the functionality of spellSuggestWord", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() spellSuggestWord() end, "spellSuggestWord: bad argument #1 type") + end) + + it("raises a Lua error when the dictionary choice is not a boolean", function() + assertArgError(function() spellSuggestWord("word", "user") end, "spellSuggestWord: bad argument #2 type") + end) + + it("suggests a word the profile dictionary knows", function() + withWords("mudletspecwibble") + + local suggestions = spellSuggestWord("mudletspecwobble", true) + assert.is_table(suggestions) + assert.is_not_nil(indexOf(suggestions, "mudletspecwibble"), "the added word was not suggested") + end) + + it("returns a table from the system dictionary, or says it has none", function() + local suggestions, err = spellSuggestWord("helo") + if suggestions == nil then + assert.is_true(contains(err, "no main dictionaries found"), tostring(err)) + return + end + assert.is_table(suggestions) + for index, suggestion in ipairs(suggestions) do + assert.is_string(suggestion, "suggestion " .. index .. " is not a word") + assert.is_true(#suggestion > 0, "suggestion " .. index .. " is empty") + end + end) + end) + end) + + describe("Tests the functionality of unzipAsync", function() + local extractDirectory = getMudletHomeDir() .. "/mudlet-spec-unzipped" + + local function removeExtractDirectory() + if fileExists(extractDirectory .. "/readme.txt") then + os.remove(extractDirectory .. "/readme.txt") + end + lfs.rmdir(extractDirectory) + end + + it("raises a Lua error when called with no arguments", function() + assertArgError(function() unzipAsync() end, "unzipAsync: bad argument #1 type") + end) + + it("raises a Lua error when given no place to extract to", function() + assertArgError(function() unzipAsync("archive.zip") end, "unzipAsync: bad argument #2 type") + end) + + it("extracts the archive and raises sysUnzipDone", function() + if not testMode then + pending("waiting for sysUnzipDone needs MUDLET_TEST_MODE") + return + end + finally(removeExtractDirectory) + local archive = fixtureDirectory .. "/mudlet-spec-emptyarchive.mpackage" + + assert.is_true(unzipAsync(archive, extractDirectory)) + + local event, zipLocation, extractLocation = waitForEvent("sysUnzipDone", 10000) + assert.equals("sysUnzipDone", event) + assert.equals(archive, zipLocation) + -- the extract location comes back with the trailing separator the + -- function adds, whether or not the caller gave one + assert.equals(extractDirectory .. "/", extractLocation) + assert.is_true(fileExists(extractDirectory .. "/readme.txt"), "the archive was not unpacked") + end) + + it("raises sysUnzipError for a file that is not an archive", function() + if not testMode then + pending("waiting for sysUnzipError needs MUDLET_TEST_MODE") + return + end + finally(removeExtractDirectory) + local notAnArchive = fixtureDirectory .. "/mudlet-spec-notazip.mpackage" + + -- the call itself cannot tell: unzipping happens on another thread, so + -- it answers true and reports the failure through the event + assert.is_true(unzipAsync(notAnArchive, extractDirectory)) + + local event, zipLocation = waitForEvent("sysUnzipError", 10000) + assert.equals("sysUnzipError", event) + assert.equals(notAnArchive, zipLocation) + assert.is_false(fileExists(extractDirectory .. "/readme.txt")) + end) + end) + + describe("Tests the functionality of loadReplay", function() + -- A replay file is a run of (offset, length, bytes) records written by + -- QDataStream, which is big-endian, so one can be built here rather than + -- committed as a binary fixture. + local function bigEndian32(value) + return string.char(math.floor(value / 16777216) % 256, math.floor(value / 65536) % 256, math.floor(value / 256) % 256, value % 256) + end + + local function writeReplay(path, payload) + writeFile(path, bigEndian32(0) .. bigEndian32(#payload) .. payload) + end + + it("raises a Lua error when called with no arguments", function() + assertArgError(function() loadReplay() end, "loadReplay: bad argument #1 type") + end) + + it("returns nil+msg for a blank file name", function() + local ok, err = loadReplay("") + assert.is_nil(ok) + assert.equals("a blank string is not a valid replay file name", err) + end) + + it("returns nil+msg for a file that is not there", function() + local ok, err = loadReplay(getMudletHomeDir() .. "/mudlet-spec-no-such-replay.dat") + assert.is_nil(ok) + assert.is_true(contains(err, "Cannot read file"), tostring(err)) + end) + + it("returns nil+msg for a file that is not a replay", function() + local corrupt = getMudletHomeDir() .. "/mudlet-spec-corrupt-replay.dat" + finally(function() os.remove(corrupt) end) + writeFile(corrupt, "this is not a replay") + + local ok, err = loadReplay(corrupt) + assert.is_nil(ok) + assert.is_true(contains(err, "replay file seems to be corrupt"), tostring(err)) + end) + + it("plays the recorded bytes back into the main console", function() + if not testMode then + pending("letting the replay timer run needs MUDLET_TEST_MODE") + return + end + local replay = getMudletHomeDir() .. "/mudlet-spec-replay.dat" + finally(function() os.remove(replay) end) + writeReplay(replay, "mudlet-spec-replayed-line\r\n") + local mark = getLastLineNumber("main") + + assert.is_true(loadReplay(replay)) + + local arrived = false + for _ = 1, 40 do + pumpEvents(50) + arrived = contains(textFrom(mark), "mudlet-spec-replayed-line") + if arrived then + break + end + end + assert.is_true(arrived, "the replay did not reach the console") + -- whether a replay is running is application-wide, so let this one run + -- out before the next spec asks for one + pumpEvents(200) + end) + end) + + describe("Tests the functionality of findItems", function() + -- Named items can only be made permanently, and a permanent item cannot + -- be removed again from Lua, so the name matching below is checked + -- against the nested triggers the run-tests package (the one running + -- these specs) ships: "Test selectCaptureGroup with nested hierarchy" > + -- "Filter" > "Not Filter" > "Trigger". + local function ids(...) + local found = findItems(...) + assert.is_table(found) + table.sort(found) + return found + end + + local function joined(list) + return table.concat(list, ",") + end + + it("raises a Lua error when called with no arguments", function() + assertArgError(function() findItems() end, "findItems: bad argument #1 type") + end) + + it("raises a Lua error when given no item type", function() + assertArgError(function() findItems("name") end, "findItems: bad argument #2 type") + end) + + it("returns nil+msg for an item type it does not know", function() + local ok, err = findItems("name", "sandwich") + assert.is_nil(ok) + assert.is_true(contains(err, "invalid item type 'sandwich' given"), tostring(err)) + end) + + it("returns an empty table when nothing matches", function() + assert.same({}, findItems("mudletSpecNeverAnItem", "alias")) + assert.same({}, findItems("mudletSpecNeverAnItem", "trigger")) + end) + + it("returns the id of a temporary item, which is named after that id", function() + local aliasId = tempAlias("^mudletSpecFindAlias$", function() end) + local triggerId = tempTrigger("mudletSpecFindTrigger", function() end) + finally(function() + killAlias(tostring(aliasId)) + killTrigger(tostring(triggerId)) + end) + + assert.same({aliasId}, findItems(tostring(aliasId), "alias")) + assert.same({triggerId}, findItems(tostring(triggerId), "trigger")) + end) + + it("finds items of every kind it accepts", function() + for _, itemType in ipairs({"timer", "trigger", "alias", "keybind", "button", "script"}) do + assert.is_table(findItems("mudletSpecNeverAnItem", itemType), itemType .. " was not accepted") + end + -- the harness's own scripts are the ones that are always there + assert.is_true(#findItems("test scripts", "script") > 0, "the run-tests package's scripts are not installed") + end) + + it("matches by exactly the name it was given", function() + local exact = ids("Filter", "trigger") + assert.is_true(#exact > 0, "the run-tests package's nested triggers are not installed") + assert.same({}, findItems("ilte", "trigger")) + end) + + it("matches part of a name when not asked for an exact match", function() + local exact = ids("Filter", "trigger") + local notFilter = ids("Not Filter", "trigger") + assert.is_true(#notFilter > 0) + + local partial = ids("Filter", "trigger", false) + + -- "Not Filter" only shows up once an exact match is no longer required + assert.is_true(#partial > #exact, joined(partial)) + for _, id in ipairs(notFilter) do + assert.is_truthy(table.contains(partial, id), "the partial match missed " .. id) + end + end) + + it("ignores case when asked to", function() + local exact = ids("Filter", "trigger") + + assert.same({}, findItems("FILTER", "trigger")) + assert.same(exact, ids("FILTER", "trigger", true, false)) + end) + end) + + describe("Tests the functionality of insertHTML", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() insertHTML() end, "insertHTML: bad argument #1 type") + end) + + it("inserts the text at the cursor", function() + finally(function() moveCursorEnd() end) + echo("mudlet-spec-insert-target\n") + moveCursor(0, getLastLineNumber("main") - 1) + + assert.equals(0, select('#', insertHTML("mudlet-spec-inserted"))) + + assert.equals("mudlet-spec-insertedmudlet-spec-insert-target", getCurrentLine()) + end) + + it("renders the markup it is given", function() + -- BUG: insertHTML() hands the text straight to insertText(), so the + -- markup its name and the wiki both promise is put on the line as + -- literal characters. Left pending rather than pinning that as the + -- contract. + pending("insertHTML() does not interpret HTML, it is an alias for insertText") + finally(function() moveCursorEnd() end) + echo("mudlet-spec-html-target\n") + moveCursor(0, getLastLineNumber("main") - 1) + + insertHTML("<b>mudlet-spec-bold</b>") + + assert.equals("mudlet-spec-boldmudlet-spec-html-target", getCurrentLine()) + end) + end) + + describe("Tests the functionality of setMergeTables", function() + it("raises a Lua error when a module is not a string", function() + assertArgError(function() setMergeTables({}) end, "setMergeTables: bad argument #1 type") + end) + + it("raises a Lua error naming the argument that is wrong", function() + assertArgError(function() setMergeTables("MudletSpec.NeverAModule", {}) end, "setMergeTables: bad argument #2 type") + end) + + it("returns nothing for any number of modules, including none", function() + -- keys can be registered but never taken off again, so these are names + -- no game will ever send rather than the real Char.* ones + assert.equals(0, select('#', setMergeTables())) + assert.equals(0, select('#', setMergeTables("MudletSpec.NeverAModule"))) + assert.equals(0, select('#', setMergeTables("MudletSpec.NeverAModule", "MudletSpec.NeverAnother"))) + end) + + it("merges the keys it was given into an incoming GMCP table", function() + -- The merge only happens as GMCP or MSDP arrives from a server, and + -- the self-test profile's socket is never in the unconnected state that + -- feedTelnet() needs, so there is no way to deliver one from Lua. + pending("delivering GMCP to the profile needs a server connection") + end) + end) + + describe("Tests the functionality of send", function() + -- send() is registered from the C++ sendRaw(), which is the name its own + -- error messages use. + it("raises a Lua error when called with no arguments", function() + assertArgError(function() send() end, "sendRaw: bad argument #1 type") + end) + + it("raises a Lua error when whether to show the command is not a boolean", function() + assertArgError(function() send("mudletSpecSend", "yes") end, "sendRaw: bad argument #2 type") + end) + + it("shows the command on the main console, unless told not to", function() + -- whether the argument is listened to at all is the profile's to decide: + -- the other two modes show every command, or none + local originalMode = getConfig("showSentText", true) + finally(function() setConfig("showSentText", originalMode) end) + assert.is_true(setConfig("showSentText", "script")) + local mark = getLastLineNumber("main") + + assert.is_true(send("mudletSpecShownCommand", true)) + + assert.is_true(contains(textFrom(mark), "mudletSpecShownCommand"), textFrom(mark)) + + mark = getLastLineNumber("main") + assert.is_true(send("mudletSpecHiddenCommand", false)) + assert.is_false(contains(textFrom(mark), "mudletSpecHiddenCommand"), textFrom(mark)) + + mark = getLastLineNumber("main") + assert.is_true(send("mudletSpecDefaultCommand")) + assert.is_true(contains(textFrom(mark), "mudletSpecDefaultCommand"), textFrom(mark)) + end) + end) + + describe("Tests the functionality of denyCurrentSend", function() + it("returns nothing", function() + finally(function() + -- the flag is consumed by the next send, so hand it one rather than + -- leaving the following specs' sends blocked + send("", false) + end) + + assert.equals(0, select('#', denyCurrentSend())) + end) + + it("stops the command that follows it from being sent", function() + -- Nothing goes on the wire offline, so what makes a send observable is + -- the warning Mudlet posts when a command cannot be encoded for the + -- game: it is only reached once the send has been allowed. Switching + -- the encoding first clears the once-per-encoding warning flag. + local probeEncoding = (getServerEncoding() == "ISO 8859-1") and "ISO 8859-2" or "ISO 8859-1" + finally(restoreServerEncoding()) + assert.is_true(setServerEncoding(probeEncoding)) + -- U+4E00, which no ISO 8859 encoding can represent + local unencodable = "\228\184\128" + + denyCurrentSend() + local mark = getLastLineNumber("main") + send("mudletSpecDenied" .. unencodable, false) + local afterDeny = textFrom(mark) + + mark = getLastLineNumber("main") + send("mudletSpecAllowed" .. unencodable, false) + local afterAllow = textFrom(mark) + + -- checked first: without it a silent deny spec would pass even if the + -- warning had stopped being posted at all + assert.is_true(contains(afterAllow, "mudletSpecAllowed"), "the allowed send was not reported, so this spec cannot tell the two apart") + assert.is_false(contains(afterDeny, "mudletSpecDenied"), "the denied command was sent anyway") + end) + end) + + describe("Tests the functionality of isAncestorsActive", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() isAncestorsActive() end, "isAncestorsActive: bad argument #1 type") + end) + + it("raises a Lua error when given no item type", function() + assertArgError(function() isAncestorsActive(1) end, "isAncestorsActive: bad argument #2 type") + end) + + it("returns nil+msg for a negative item ID", function() + local ok, err = isAncestorsActive(-1, "alias") + assert.is_nil(ok) + assert.is_true(contains(err, "does not seem to be parseable as a positive integer"), tostring(err)) + end) + + it("returns nil+msg for an item that does not exist", function() + local ok, err = isAncestorsActive(9999999, "alias") + assert.is_nil(ok) + assert.is_true(contains(err, "does not exist"), tostring(err)) + end) + + it("returns nil+msg for an item type it does not know", function() + local ok, err = isAncestorsActive(1, "sandwich") + assert.is_nil(ok) + assert.is_true(contains(err, "invalid item type 'sandwich' given"), tostring(err)) + end) + + it("is true for a temporary item, which has no ancestors at all", function() + local triggerId = tempTrigger("mudletSpecAncestorTrigger", function() end) + local timerId = tempTimer(60, function() end) + finally(function() + killTrigger(tostring(triggerId)) + killTimer(timerId) + end) + + assert.is_true(isAncestorsActive(triggerId, "trigger")) + assert.is_true(isAncestorsActive(timerId, "timer")) + end) + + it("follows the state of a nested item's parent group", function() + -- Nesting needs permanent items, which cannot be removed again from + -- Lua, so this uses the hierarchy the run-tests package (the one + -- running these specs) already ships and puts its state back. + local parentGroup = "Not Filter" + local nested = findItems("Trigger", "trigger") + -- both names have to be the harness's own, or this would be toggling + -- some other package's trigger and asking about an unrelated item + assert.equals(1, #nested, "expected exactly the run-tests package's nested 'Trigger'") + assert.equals(1, #findItems(parentGroup, "trigger"), "expected exactly the run-tests package's '" .. parentGroup .. "' group") + local childId = nested[1] + finally(function() enableTrigger(parentGroup) end) + + assert.is_true(isAncestorsActive(childId, "trigger")) + + assert.is_true(disableTrigger(parentGroup)) + assert.is_false(isAncestorsActive(childId, "trigger")) + + assert.is_true(enableTrigger(parentGroup)) + assert.is_true(isAncestorsActive(childId, "trigger")) + end) + end) + + describe("Tests the functionality of getProfiles", function() + it("lists this profile as loaded, with what it was set up with", function() + local profiles = getProfiles() + assert.is_table(profiles) + + local own = profiles[getProfileName()] + assert.is_table(own, "the running profile is not in the list") + assert.is_true(own.loaded) + assert.is_boolean(own.connected) + assert.equals(getProfileInformation(), own.description) + if own.host then + assert.is_string(own.host) + assert.is_string(own.port) + end + + assert.is_nil(profiles["mudlet-spec-never-a-profile"]) + end) + + it("lists a profile that is not loaded", function() + local profilesDirectory = getMudletHomeDir():match("^(.*)[/\\]") + assert.is_string(profilesDirectory, "could not work out the profiles folder from " .. getMudletHomeDir()) + local unloaded = profilesDirectory .. "/mudlet-spec-unloaded" + -- a folder left behind would be listed as a profile by every later run, + -- and by the connection dialog + finally(function() assert.is_true(lfs.rmdir(unloaded), "could not remove " .. unloaded) end) + assert.is_true(lfs.mkdir(unloaded)) + + local entry = getProfiles()["mudlet-spec-unloaded"] + assert.is_table(entry, "a profile folder that is not open was not listed") + assert.is_false(entry.loaded) + -- only a loaded profile has a connection to report on + assert.is_nil(entry.connected) + assert.equals("", entry.description) + end) + end) + + describe("Tests the functionality of getProfileStats", function() + it("reports a count for every kind of item", function() + local stats = getProfileStats() + assert.is_table(stats) + for _, kind in ipairs({"triggers", "aliases", "timers", "keys", "scripts"}) do + assert.is_number(stats[kind].total, kind .. " has no total") + assert.is_number(stats[kind].temp, kind .. " has no temp count") + assert.is_number(stats[kind].active, kind .. " has no active count") + end + assert.is_number(stats.triggers.patterns.total) + assert.is_number(stats.triggers.patterns.active) + assert.is_number(stats.gifs.total) + end) + + it("counts a temporary item that has just been created", function() + local before = getProfileStats() + local timerId = tempTimer(60, function() end) + local triggerId = tempTrigger("mudletSpecStatsTrigger", function() end) + finally(function() + killTimer(timerId) + killTrigger(triggerId) + end) + + local after = getProfileStats() + assert.equals(before.timers.total + 1, after.timers.total) + assert.equals(before.timers.temp + 1, after.timers.temp) + assert.equals(before.triggers.total + 1, after.triggers.total) + assert.equals(before.triggers.temp + 1, after.triggers.temp) + end) + end) + + describe("Tests the profile icon functions", function() + local iconSource = getMudletHomeDir() .. "/mudlet-spec-icon.png" + local profileIcon = getMudletHomeDir() .. "/profileicon" + + -- The icon a player chose is theirs, and the profile outlives the run, so + -- the specs below take a copy of it, work from a profile with no icon, and + -- put the copy back. + local function withNoProfileIcon() + local original = readFile(profileIcon) + finally(function() + os.remove(iconSource) + resetProfileIcon() + if original then + writeFile(profileIcon, original) + end + end) + if original then + assert.is_true(resetProfileIcon()) + end + assert.is_false(fileExists(profileIcon), "the profile still has an icon") + end + + describe("Tests the functionality of setProfileIcon", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() setProfileIcon() end, "setProfileIcon: bad argument #1 type") + end) + + it("returns nil+msg for a blank path", function() + local ok, err = setProfileIcon("") + assert.is_nil(ok) + assert.equals("a blank string is not a valid icon file path", err) + end) + + it("returns nil+msg for a file that is not there", function() + local ok, err = setProfileIcon(getMudletHomeDir() .. "/mudlet-spec-no-such-icon.png") + assert.is_nil(ok) + assert.is_true(contains(err, "doesn't exist"), tostring(err)) + end) + + it("copies the icon into the profile", function() + withNoProfileIcon() + writeFile(iconSource, "mudlet-spec-icon-bytes") + + assert.is_true(setProfileIcon(iconSource)) + + assert.is_true(fileExists(profileIcon), "no icon was copied into the profile") + assert.equals("mudlet-spec-icon-bytes", readFile(profileIcon)) + end) + end) + + describe("Tests the functionality of resetProfileIcon", function() + it("takes the icon back out of the profile", function() + withNoProfileIcon() + writeFile(iconSource, "mudlet-spec-icon-bytes") + assert.is_true(setProfileIcon(iconSource)) + assert.is_true(fileExists(profileIcon)) + + assert.is_true(resetProfileIcon()) + + assert.is_false(fileExists(profileIcon), "the icon was left in the profile") + end) + + it("is happy to be asked when there is no icon to remove", function() + withNoProfileIcon() + + assert.is_true(resetProfileIcon()) + + assert.is_false(fileExists(profileIcon)) + end) + end) + end) + + describe("Tests the functionality of raiseGlobalEvent", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() raiseGlobalEvent() end, "raiseGlobalEvent: missing argument #1") + end) + + it("raises a Lua error for a first argument it cannot carry", function() + -- safe to assert, unlike the spec below: nothing has been put into the + -- event yet, so the raise has nothing to strand + assertArgError(function() raiseGlobalEvent({}) end, "raiseGlobalEvent: bad argument type #1") + end) + + it("raises a Lua error for a later argument it cannot carry", function() + -- BUG: the refusal is right, but it is raised with lua_error() after the + -- event has been built, and that longjmps past the destructor of the + -- TEvent holding the arguments read so far, which LeakSanitizer reports + -- and which would turn the leak-checking CI job red. Refusing the first + -- argument (above) is safe because nothing has been appended yet. Left + -- pending until the raise happens before the event is built. + pending("raiseGlobalEvent() leaks the event it was building when it refuses a later argument") + assertArgError(function() raiseGlobalEvent("mudletSpecGlobalEvent", {}) end, "raiseGlobalEvent: bad argument type #2") + end) + + it("does not deliver the event back to the profile that sent it", function() + -- Only the half that one profile can see: that the sender is left out. + -- Whether the other profiles receive it needs a second profile, so it + -- belongs to the functional tests rather than here. + local received = 0 + local handler = registerAnonymousEventHandler("mudletSpecGlobalEvent", function() received = received + 1 end) + finally(function() killAnonymousEventHandler(handler) end) + + assert.is_true(raiseGlobalEvent("mudletSpecGlobalEvent", 1, "two", true, nil)) + + if testMode then + pumpEvents(200) + end + assert.equals(0, received) + -- raiseEvent() is what a profile uses to talk to itself, and it proves + -- the handler the count above is being read from does work + raiseEvent("mudletSpecGlobalEvent") + assert.equals(1, received) + end) + end) + + describe("Tests the functionality of wait", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() wait() end, "Wait: wrong number of arguments") + end) + + it("raises a Lua error when the delay is not a number", function() + assertArgError(function() wait("soon") end, "Wait: bad argument #1 type") + end) + + it("returns nothing and blocks for at least as long as it was asked to", function() + local before = getEpoch() + + assert.equals(0, select('#', wait(10))) + + -- getEpoch() is in seconds; wait() blocks the whole thread, which is + -- why nothing here waits any longer than it has to + assert.is_true(getEpoch() - before >= 0.009) + end) + end) + + describe("Tests the functions whose effect needs a desktop or a person", function() + -- These reach a browser, the system tray, a modal dialog or the physical + -- keyboard, so only the refusals can be driven from here: every spec below + -- gets the call turned away before it can do anything. That the call is + -- reached at all is the point - it proves the function is registered and + -- validates what it was handed. + + describe("Tests the functionality of openWebPage", function() + it("raises a Lua error, rather than opening anything, when given no URL", function() + assertArgError(function() openWebPage() end, "openWebPage: bad argument #1 type") + end) + end) + + describe("Tests the functionality of showNotification", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() showNotification() end, "showNotification: bad argument #1 type") + end) + + it("raises a Lua error when the expiry time is not a number", function() + assertArgError(function() showNotification("title", "message", "soon") end, "showNotification: bad argument #3 type") + end) + end) + + describe("Tests the functionality of invokeFileDialog", function() + it("raises a Lua error, rather than opening a dialog, when not told what to ask for", function() + assertArgError(function() invokeFileDialog() end, "invokeFileDialog: bad argument #1 type") + end) + + it("raises a Lua error when given no title", function() + assertArgError(function() invokeFileDialog(true) end, "invokeFileDialog: bad argument #2 type") + end) + end) + + describe("Tests the functionality of holdingModifiers", function() + it("raises a Lua error when the modifier is not a number", function() + -- what it answers depends on which keys are held down as the specs + -- run, so only the refusal can be asserted on + assertArgError(function() holdingModifiers("ctrl") end, "holdingModifiers: bad argument #1 type") + end) + end) + + describe("Tests the functionality of showHandlerError", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() showHandlerError() end, "showHandlerError: bad argument #1 type") + end) + + it("raises a Lua error when given no error message", function() + -- where the message goes is the editor's error console and, only for a + -- profile that opted into echoing Lua errors, the main console; + -- neither can be turned on from Lua + assertArgError(function() showHandlerError("mudletSpecEvent") end, "showHandlerError: bad argument #2 type") + end) + end) + + describe("Tests the functionality of clearCmdLineBlacklist", function() + it("returns nil+msg for a command line that does not exist", function() + local ok, err = clearCmdLineBlacklist("mudlet-spec-no-such-command-line") + assert.is_nil(ok) + assert.is_true(contains(err, "not found"), tostring(err)) + end) + + it("returns nothing for the main command line", function() + -- what it cleared cannot be read back: there is no getter for a + -- command line's blacklist + assert.equals(0, select('#', clearCmdLineBlacklist())) + assert.equals(0, select('#', clearCmdLineBlacklist("main"))) + end) + end) + + describe("Tests the functionality of showUnzipProgress", function() + it("says it does nothing, having been removed", function() + local ok, err = showUnzipProgress() + assert.is_nil(ok) + assert.equals("removed command, this function is now inactive and does nothing", err) + end) + end) + end) + + describe("The Miscallaneous specs clean up after themselves", function() + it("leaves no file or folder of its own behind", function() + -- the specs above write into the profile, and one of them into the + -- folder profiles live in, which no other spec file watches: anything + -- left there would be listed as a profile from the next run onwards + for entry in lfs.dir(getMudletHomeDir()) do + assert.is_nil(entry:find("mudlet%-spec%-"), "left " .. entry .. " in the profile") + end + local profilesDirectory = getMudletHomeDir():match("^(.*)[/\\]") + for entry in lfs.dir(profilesDirectory) do + assert.is_nil(entry:find("mudlet%-spec%-"), "left " .. entry .. " among the profiles") + end + end) + end) + end) diff --git a/src/mudlet-lua/tests/MudletBusted_spec.lua b/src/mudlet-lua/tests/MudletBusted_spec.lua index 3dbb9d09b..b5594d6e1 100644 --- a/src/mudlet-lua/tests/MudletBusted_spec.lua +++ b/src/mudlet-lua/tests/MudletBusted_spec.lua @@ -7,3 +7,165 @@ describe("Mudlet Busted sanity check", function() end) end) +describe("waitForEvent test helper", function() + -- select('#', ...) counts embedded and trailing nils that a plain table + -- constructor would lose, so it lets a spec verify the argument count too. + local function grab(...) + return select('#', ...), ... + end + + it("observes a tempTimer firing through a raised event", function() + tempTimer(0.05, function() raiseEvent("mudletTestTimerFired", 42) end) + local name, value = waitForEvent("mudletTestTimerFired", 2000) + assert.equals("mudletTestTimerFired", name) + assert.equals(42, value) + end) + + it("round-trips raiseEvent arguments of several types", function() + tempTimer(0, function() raiseEvent("mudletTestRoundTrip", "hello", 7, true) end) + local name, str, num, boolean = waitForEvent("mudletTestRoundTrip", 2000) + assert.equals("mudletTestRoundTrip", name) + assert.equals("hello", str) + assert.equals(7, num) + assert.is_true(boolean) + end) + + it("preserves nil and false arguments in their positions", function() + tempTimer(0, function() raiseEvent("mudletTestNilArg", nil, false, "after") end) + local count, name, first, second, third = grab(waitForEvent("mudletTestNilArg", 2000)) + assert.equals(4, count) + assert.equals("mudletTestNilArg", name) + assert.is_nil(first) + assert.is_false(second) + assert.equals("after", third) + end) + + it("round-trips a table argument that outlives the event", function() + tempTimer(0, function() raiseEvent("mudletTestTableArg", {a = 1, b = "two"}) end) + local name, payload = waitForEvent("mudletTestTableArg", 2000) + assert.equals("mudletTestTableArg", name) + assert.is_table(payload) + assert.equals(1, payload.a) + assert.equals("two", payload.b) + end) + + it("returns just the event name when there is no payload", function() + tempTimer(0, function() raiseEvent("mudletTestNameOnly") end) + local count, name = grab(waitForEvent("mudletTestNameOnly", 2000)) + assert.equals(1, count) + assert.equals("mudletTestNameOnly", name) + end) + + it("uses a default timeout when none is supplied", function() + tempTimer(0, function() raiseEvent("mudletTestDefaultTimeout", "ok") end) + local name, value = waitForEvent("mudletTestDefaultTimeout") + assert.equals("mudletTestDefaultTimeout", name) + assert.equals("ok", value) + end) + + it("returns nil and a message naming the event when it never arrives", function() + local result, message = waitForEvent("mudletTestNeverRaised", 100) + assert.is_nil(result) + assert.is_string(message) + assert.truthy(message:find("timed out")) + assert.truthy(message:find("mudletTestNeverRaised")) + end) + + it("does not wake for a different event", function() + tempTimer(0, function() raiseEvent("mudletTestOtherEvent") end) + local result = waitForEvent("mudletTestWantedEvent", 200) + assert.is_nil(result) + end) + + it("does not observe an event raised before the wait began", function() + raiseEvent("mudletTestPreRaised") + local result = waitForEvent("mudletTestPreRaised", 100) + assert.is_nil(result) + end) + + it("clamps a negative timeout to zero", function() + local result, message = waitForEvent("mudletTestNeverRaised", -50) + assert.is_nil(result) + assert.truthy(message:find("0ms")) + end) + + it("returns nil and a message for an empty event name", function() + local result, message = waitForEvent("") + assert.is_nil(result) + assert.is_string(message) + assert.truthy(message:find("empty")) + end) + + it("errors when called without an event name", function() + assert.has_error(function() waitForEvent() end) + end) + + it("observes an event raised from inside another timer's callback", function() + -- The #9670 shape: the wait itself is armed from inside a timer callback. + tempTimer(0, function() + tempTimer(0.05, function() raiseEvent("mudletTestNestedTimer", "deep") end) + _G.mudletTestNestedTimerResult = {waitForEvent("mudletTestNestedTimer", 2000)} + end) + local waited = 0 + while not _G.mudletTestNestedTimerResult and waited < 5000 do + pumpEvents(50) + waited = waited + 50 + end + local result = _G.mudletTestNestedTimerResult + _G.mudletTestNestedTimerResult = nil + assert.is_table(result, "the wait inside the timer callback never returned") + assert.equals("mudletTestNestedTimer", result[1]) + assert.equals("deep", result[2]) + end) + + it("supports a nested waitForEvent while one is already blocked", function() + local innerName + tempTimer(0, function() + -- Raise the shared event only once both waits are blocked, so both + -- the outer wait and this inner one should observe it. + tempTimer(0.05, function() raiseEvent("mudletTestNested", "payload") end) + innerName = waitForEvent("mudletTestNested", 2000) + end) + local outerName, outerValue = waitForEvent("mudletTestNested", 2000) + assert.equals("mudletTestNested", outerName) + assert.equals("payload", outerValue) + assert.equals("mudletTestNested", innerName) + end) + end) + +describe("pumpEvents test helper", function() + it("returns true once the time is up", function() + assert.is_true(pumpEvents(20)) + end) + + it("accepts no argument and clamps a negative duration", function() + assert.is_true(pumpEvents()) + assert.is_true(pumpEvents(-50)) + end) + + it("runs a timer that falls due while it is pumping", function() + local fired = false + tempTimer(0.05, function() fired = true end) + pumpEvents(300) + assert.is_true(fired, "a timer that came due during the pump did not fire") + end) + + it("keeps running timers when pumping from inside a timer's callback", function() + -- The #9670 shape: on macOS this position stops Qt timers entirely, so + -- a regression hangs the spec rather than failing it. + local result = {} + tempTimer(0, function() + tempTimer(0.05, function() result.innerFired = true end) + pumpEvents(300) + result.firedDuringPump = result.innerFired == true + result.done = true + end) + local waited = 0 + while not result.done and waited < 5000 do + pumpEvents(50) + waited = waited + 50 + end + assert.is_true(result.done, "the pump inside the timer callback never returned") + assert.is_true(result.firedDuringPump, "a timer did not fire while pumping from inside a timer callback") + end) + end) diff --git a/src/mudlet-lua/tests/Networking_spec.lua b/src/mudlet-lua/tests/Networking_spec.lua new file mode 100644 index 000000000..677fe190d --- /dev/null +++ b/src/mudlet-lua/tests/Networking_spec.lua @@ -0,0 +1,1966 @@ +-- Specs for the networking, MMCP and Discord APIs. The media contracts that +-- used to be here now live in Media_spec.lua. +-- +-- Most of these functions depend on live infrastructure for their real effect +-- (a connected game server, connected MMCP peers, a running Discord client), +-- and for those what is verified here is the part that is fully deterministic +-- offline: argument validation, and the nil+message / hard-error shapes each +-- function returns when its precondition (a connection, a peer, an enabled +-- protocol, an available API) is not met. +-- +-- The download, HTTP and MMCP families are the exception: their infrastructure +-- can be stood up locally, so their real effects are checked against the +-- fixture server in CI/http-fixture-server.py (ephemeral port in +-- MUDLET_TEST_HTTP_PORT) and the scripted chat peer in CI/mmcp-peer.py +-- (handover directory in MUDLET_TEST_MMCP_DIR). Both skip cleanly when absent +-- so the suite still passes without them. Nothing here mocks a real API +-- function. + +local function contains(haystack, needle) + return type(haystack) == "string" and haystack:find(needle, 1, true) ~= nil +end + +-- Asserts that calling fn raises a Lua error whose message contains needle. +-- Matching a message substring (rather than merely "did it error?") ensures the +-- function is actually registered and reached its own argument validation: an +-- unregistered/nil function would raise a different "attempt to call" error. +local function assertArgError(fn, needle) + local ok, err = pcall(fn) + assert.is_false(ok) + assert.is_true(contains(err, needle)) +end + +describe("Networking send functions honour their disconnected/offline contracts", function() + -- Force a non-connected telnet state so the connection guards fire + -- deterministically regardless of what the self-test profile's socket is + -- doing. disconnect() only closes the socket; it issues no traffic. + before_each(function() + disconnect() + end) + + describe("sendMSDP", function() + it("raises a Lua error when called with no arguments", function() + assert.has_error(function() sendMSDP() end) + end) + + it("raises a Lua error when a value argument is not a string", function() + assert.has_error(function() sendMSDP("HEALTH", {}) end) + end) + + it("returns nil and a message while disconnected", function() + local ok, err = sendMSDP("HEALTH") + assert.is_nil(ok) + assert.is_true(contains(err, "not connected to game server")) + end) + end) + + describe("sendATCP", function() + it("names the offending value's real type when the message is not a string", function() + -- Regression #9543: the type-name placeholder must be expanded, not printed + -- as a literal "%1". lua_pushfstring only understands C-style "%s". + local ok, err = pcall(function() sendATCP({}) end) + assert.is_false(ok) + assert.is_true(contains(err, "sendATCP: bad argument #1 type (message as string expected, got table!)"), tostring(err)) + assert.is_false(contains(err, "%1"), tostring(err)) + end) + + it("names the real type when the optional second argument is not a string", function() + local ok, err = pcall(function() sendATCP("Char.Login", {}) end) + assert.is_false(ok) + assert.is_true(contains(err, "sendATCP: bad argument #2 type (what as string is optional, got table!)"), tostring(err)) + assert.is_false(contains(err, "%1"), tostring(err)) + end) + + it("returns nil and a message while disconnected", function() + local ok, err = sendATCP("Char.Login") + assert.is_nil(ok) + assert.is_true(contains(err, "not connected to game server")) + end) + end) + + describe("sendTelnetChannel102", function() + it("raises a Lua error when the payload is not a string", function() + assert.has_error(function() sendTelnetChannel102({}) end) + end) + + it("returns nil when the payload is not exactly two bytes", function() + local ok, err = sendTelnetChannel102("x") + assert.is_nil(ok) + assert.is_true(contains(err, "invalid message of length 1")) + end) + + it("returns nil when subchannel 102 has not been enabled by the server", function() + local ok, err = sendTelnetChannel102("ab") + assert.is_nil(ok) + assert.is_true(contains(err, "102 subchannel support has not been enabled")) + end) + end) + + describe("sendSocket", function() + it("raises a Lua error when the data is not a string", function() + assert.has_error(function() sendSocket({}) end) + end) + + it("returns nil and a message when the socket cannot accept the data", function() + local ok, err = sendSocket("noop") + assert.is_nil(ok) + assert.is_true(contains(err, "unable to send")) + end) + end) +end) + +describe("connectToServer validates its arguments without connecting", function() + it("raises a Lua error when the url is missing", function() + assert.has_error(function() connectToServer() end) + end) + + it("rejects an out-of-range port and returns nil plus a message", function() + local ok, err = connectToServer("example.invalid", 70000) + assert.is_nil(ok) + assert.is_true(contains(err, "invalid port number")) + end) + + it("rejects a port below 1", function() + local ok, err = connectToServer("example.invalid", 0) + assert.is_nil(ok) + assert.is_true(contains(err, "invalid port number")) + end) +end) + +describe("getConnectionInfo returns a host/port/connected triple", function() + it("returns a string, a number and a boolean", function() + local host, port, connected = getConnectionInfo() + assert.is_string(host) + assert.is_number(port) + assert.is_boolean(connected) + end) +end) + +describe("HTTP and download functions validate arguments before issuing a request", function() + -- Every case below returns (hard error, or nil+message) strictly before the + -- network call: either the url is invalid (so the request is refused locally) + -- or a valid-looking url is never contacted because a header/argument error is + -- raised first. + describe("downloadFile", function() + it("raises a Lua error when the local filename is missing", function() + assertArgError(function() downloadFile() end, "downloadFile: bad argument") + end) + + it("raises a Lua error when the url is missing", function() + assertArgError(function() downloadFile("/tmp/mudlet-contract-test") end, "downloadFile: bad argument") + end) + + it("returns nil for an invalid url without downloading", function() + local ok, err = downloadFile("/tmp/mudlet-contract-test", "") + assert.is_nil(ok) + assert.is_true(contains(err, "url is invalid")) + end) + end) + + describe("getHTTP", function() + it("raises a Lua error when the url is missing", function() + assertArgError(function() getHTTP() end, "getHTTP: bad argument") + end) + + it("returns nil for an invalid url without issuing a request", function() + local ok, err = getHTTP("") + assert.is_nil(ok) + assert.is_true(contains(err, "url is invalid")) + end) + + it("raises a Lua error when headers is not a table", function() + assertArgError(function() getHTTP("http://localhost/", 5) end, + "getHTTP: bad argument #2 type (headers as a table expected, got number!)") + end) + + it("raises a Lua error when a header value is not a string", function() + assertArgError(function() getHTTP("http://localhost/", {["X-Test"] = 5}) end, "getHTTP: bad argument") + end) + end) + + describe("deleteHTTP", function() + it("raises a Lua error when the url is missing", function() + assertArgError(function() deleteHTTP() end, "deleteHTTP: bad argument") + end) + + it("returns nil for an invalid url without issuing a request", function() + local ok, err = deleteHTTP("") + assert.is_nil(ok) + assert.is_true(contains(err, "url is invalid")) + end) + + it("raises a Lua error when headers is not a table", function() + assertArgError(function() deleteHTTP("http://localhost/", 5) end, + "deleteHTTP: bad argument #2 type (headers as a table expected, got number!)") + end) + + it("raises a Lua error when a header value is not a string", function() + assertArgError(function() deleteHTTP("http://localhost/", {["X-Test"] = 5}) end, "deleteHTTP: bad argument") + end) + end) + + describe("postHTTP", function() + it("raises a Lua error when the data argument is missing", function() + assertArgError(function() postHTTP() end, "postHTTP: bad argument") + end) + + it("raises a Lua error when the url is missing", function() + assertArgError(function() postHTTP("payload") end, "postHTTP: bad argument") + end) + + it("returns nil for an invalid url without issuing a request", function() + local ok, err = postHTTP("payload", "") + assert.is_nil(ok) + assert.is_true(contains(err, "url is invalid")) + end) + + it("raises a Lua error when headers is not a table", function() + assertArgError(function() postHTTP("payload", "http://localhost/", 5) end, + "postHTTP: bad argument #3 type (headers as a table expected, got number!)") + end) + end) + + describe("putHTTP", function() + it("raises a Lua error when the data argument is missing", function() + assertArgError(function() putHTTP() end, "putHTTP: bad argument") + end) + + it("raises a Lua error when the url is missing", function() + assertArgError(function() putHTTP("payload") end, "putHTTP: bad argument") + end) + + it("returns nil for an invalid url without issuing a request", function() + local ok, err = putHTTP("payload", "") + assert.is_nil(ok) + assert.is_true(contains(err, "url is invalid")) + end) + + it("raises a Lua error when headers is not a table", function() + assertArgError(function() putHTTP("payload", "http://localhost/", 5) end, + "putHTTP: bad argument #3 type (headers as a table expected, got number!)") + end) + end) + + describe("the optional file argument", function() + it("returns nil and a message when the file cannot be read", function() + local ok, err = postHTTP("payload", "http://localhost/", {}, getMudletHomeDir() .. "/busted-no-such-upload.txt") + assert.is_nil(ok) + assert.is_true(contains(err, "couldn't open"), tostring(err)) + assert.is_true(contains(err, "busted-no-such-upload.txt"), tostring(err)) + end) + end) + + describe("customHTTP", function() + it("raises a Lua error when the method is missing", function() + assertArgError(function() customHTTP() end, "customHTTP: bad argument") + end) + + it("raises a Lua error when the data argument is missing", function() + assertArgError(function() customHTTP("REPORT") end, "customHTTP: bad argument") + end) + + it("raises a Lua error when the url is missing", function() + assertArgError(function() customHTTP("REPORT", "payload") end, "customHTTP: bad argument") + end) + + it("reports the real type of a non-table headers argument", function() + -- Regression #9544: performHttpRequest must read the type of the offending + -- slot (pos + 3), not a hardcoded slot 3, so the headers error names the + -- number that was actually passed rather than the url's type. + local ok, err = pcall(function() customHTTP("REPORT", "payload", "http://localhost/", 5) end) + assert.is_false(ok) + assert.is_true(contains(err, "customHTTP: bad argument #4 type (headers as a table expected, got number!)"), tostring(err)) + end) + + it("reports the real type of a non-string file argument", function() + -- Regression #9544: the file error must read pos + 4, not a hardcoded slot 4, + -- so it names the boolean that was passed and not the headers table's type. + -- A boolean is used rather than a number because lua_isstring also accepts + -- numbers, so only a genuinely non-string value reaches the type error. + local ok, err = pcall(function() customHTTP("REPORT", "payload", "http://localhost/", {}, true) end) + assert.is_false(ok) + assert.is_true(contains(err, "customHTTP: bad argument #5 type (file to send as string location expected, got boolean!)"), tostring(err)) + end) + end) +end) + +describe("Downloads and HTTP verbs against the local fixture server", function() + -- CI starts CI/http-fixture-server.py before the suite and passes its + -- ephemeral port in MUDLET_TEST_HTTP_PORT. The server serves + -- CI/http-fixtures/ and answers any verb below /echo by reporting the + -- method, headers and body it received, which is how these specs prove what + -- Mudlet actually put on the wire. Every response carries the + -- X-Mudlet-Fixture header so the response table each event delivers can be + -- checked too. + -- + -- The requests are asynchronous: nothing is sent until the event loop runs, + -- which only happens inside waitForEvent() and pumpEvents(), so arming the + -- wait after issuing the request cannot miss the reply. + local httpPort = os.getenv("MUDLET_TEST_HTTP_PORT") + -- the contents of CI/http-fixtures/fixture.txt + local fixtureBody = "Mudlet self-test HTTP fixture.\n" + + local function fixtureUrl(path) + return "http://127.0.0.1:" .. httpPort .. path + end + + -- Returns true when the caller must stop because there is no server to talk + -- to. A developer's local run without the fixture server still passes; CI + -- sets MUDLET_TEST_REQUIRE_HTTP_FIXTURE so that a workflow which stops + -- handing the port over fails instead of quietly skipping the whole family. + local requireFixture = os.getenv("MUDLET_TEST_REQUIRE_HTTP_FIXTURE") + + local function noFixtureServer() + if httpPort then + return false + end + if requireFixture then + assert.is_true(false, "MUDLET_TEST_REQUIRE_HTTP_FIXTURE is set but MUDLET_TEST_HTTP_PORT is not - the fixture server did not reach the specs") + end + pending("MUDLET_TEST_HTTP_PORT is not set (fixture HTTP server not running)") + return true + end + + local function readFile(path) + local handle = io.open(path, "rb") + if not handle then + return nil + end + local body = handle:read("*a") + handle:close() + return body + end + + local function writeFile(path, body) + local handle = io.open(path, "wb") + handle:write(body) + handle:close() + end + + -- Qt normalises the header names it hands back (they arrive lower-cased from + -- Qt 6.7 on, as sent before that), so look the header up without relying on + -- its case. + local function headerValue(response, name) + for key, value in pairs(response.headers) do + if key:lower() == name:lower() then + return value + end + end + return nil + end + + -- Proves the response reached Lua as a table carrying the header and the + -- cookie that the fixture server sets on every response, rather than as some + -- other truthy value. + local function assertFixtureResponse(response) + assert.is_table(response) + assert.is_table(response.headers) + assert.is_table(response.cookies) + assert.equals("1", headerValue(response, "X-Mudlet-Fixture")) + assert.equals("1", response.cookies["mudlet-fixture"]) + end + + describe("downloadFile", function() + it("writes the fixture to disk and reports it in sysDownloadDone", function() + if noFixtureServer() then + return + end + local target = getMudletHomeDir() .. "/busted-download-done.txt" + os.remove(target) + finally(function() os.remove(target) end) + local queued, actualUrl = downloadFile(target, fixtureUrl("/fixture.txt")) + assert.is_true(queued) + assert.equals(fixtureUrl("/fixture.txt"), actualUrl) + + local event, localFile, bytesWritten, response = waitForEvent("sysDownloadDone", 2000) + assert.equals("sysDownloadDone", event) + assert.equals(target, localFile) + assert.equals(#fixtureBody, bytesWritten) + assert.equals(fixtureBody, readFile(target)) + assertFixtureResponse(response) + end) + + it("reports the download's progress while it runs", function() + if noFixtureServer() then + return + end + local target = getMudletHomeDir() .. "/busted-download-progress.txt" + os.remove(target) + -- Collected through a handler rather than a wait of its own: how many + -- progress events Qt emits for a 31 byte body is not fixed, but the last + -- one must account for the whole body. + local progress = {} + local handler = registerAnonymousEventHandler("sysDownloadFileProgress", function(_, url, downloaded, total) + progress[#progress + 1] = {url = url, downloaded = downloaded, total = total} + end) + finally(function() + killAnonymousEventHandler(handler) + os.remove(target) + end) + + assert.is_true(downloadFile(target, fixtureUrl("/fixture.txt"))) + assert.equals("sysDownloadDone", (waitForEvent("sysDownloadDone", 2000))) + + assert.is_true(#progress > 0) + local last = progress[#progress] + assert.equals(fixtureUrl("/fixture.txt"), last.url) + assert.equals(#fixtureBody, last.downloaded) + assert.equals(#fixtureBody, last.total) + end) + + it("raises sysDownloadError and writes no file when the url 404s", function() + if noFixtureServer() then + return + end + local target = getMudletHomeDir() .. "/busted-download-missing.txt" + os.remove(target) + finally(function() os.remove(target) end) + assert.is_true(downloadFile(target, fixtureUrl("/no-such-fixture.txt"))) + + local event, message, localFile, url, response = waitForEvent("sysDownloadError", 2000) + assert.equals("sysDownloadError", event) + assert.is_string(message) + assert.equals(target, localFile) + assert.equals(fixtureUrl("/no-such-fixture.txt"), url) + assertFixtureResponse(response) + assert.is_nil(readFile(target)) + end) + + it("raises sysDownloadError naming the local file when it cannot be written", function() + if noFixtureServer() then + return + end + -- the directory does not exist, so QSaveFile cannot open the target: this + -- path reports a local reason as its fourth argument where the network + -- error path reports the url + local target = getMudletHomeDir() .. "/busted-no-such-directory/download.txt" + assert.is_true(downloadFile(target, fixtureUrl("/fixture.txt"))) + + local event, message, localFile, reason = waitForEvent("sysDownloadError", 2000) + assert.equals("sysDownloadError", event) + assert.equals("Couldn't save to the destination file", message) + assert.equals(target, localFile) + assert.equals("Couldn't open the destination file for writing (permission errors?)", reason) + end) + end) + + describe("getHTTP", function() + it("delivers the fixture's body in sysGetHttpDone", function() + if noFixtureServer() then + return + end + local queued = getHTTP(fixtureUrl("/fixture.txt")) + assert.is_true(queued) + + local event, url, body, response = waitForEvent("sysGetHttpDone", 2000) + assert.equals("sysGetHttpDone", event) + assert.equals(fixtureUrl("/fixture.txt"), url) + assert.equals(fixtureBody, body) + assertFixtureResponse(response) + end) + + it("sends the custom headers it was given", function() + if noFixtureServer() then + return + end + assert.is_true(getHTTP(fixtureUrl("/echo"), {["X-Mudlet-Test"] = "get-header"})) + + local event, _, body = waitForEvent("sysGetHttpDone", 2000) + assert.equals("sysGetHttpDone", event) + assert.is_true(contains(body, "method=GET")) + assert.is_true(contains(body, "header:x-mudlet-test=get-header"), body) + -- setNetworkRequestDefaults() puts Mudlet's own user agent on the request + assert.is_true(contains(body, "header:user-agent=Mozilla/5.0 (Mudlet/"), body) + end) + + it("raises sysGetHttpError for a url that 404s", function() + if noFixtureServer() then + return + end + assert.is_true(getHTTP(fixtureUrl("/no-such-fixture.txt"))) + + local event, message, url, response = waitForEvent("sysGetHttpError", 2000) + assert.equals("sysGetHttpError", event) + assert.is_string(message) + assert.equals(fixtureUrl("/no-such-fixture.txt"), url) + assertFixtureResponse(response) + end) + end) + + describe("postHTTP", function() + it("sends its data and headers, and reports the reply in sysPostHttpDone", function() + if noFixtureServer() then + return + end + local queued = postHTTP("posted=payload", fixtureUrl("/echo"), {["X-Mudlet-Test"] = "post-header"}) + assert.is_true(queued) + + local event, url, body, response = waitForEvent("sysPostHttpDone", 2000) + assert.equals("sysPostHttpDone", event) + assert.equals(fixtureUrl("/echo"), url) + assert.is_true(contains(body, "method=POST"), body) + assert.is_true(contains(body, "header:x-mudlet-test=post-header"), body) + assert.is_true(contains(body, "body=posted=payload"), body) + assertFixtureResponse(response) + end) + + it("sends a file's contents in place of the data argument", function() + if noFixtureServer() then + return + end + local upload = getMudletHomeDir() .. "/busted-http-upload.txt" + writeFile(upload, "contents from the uploaded file") + finally(function() os.remove(upload) end) + + assert.is_true(postHTTP("data that must be ignored", fixtureUrl("/echo"), {}, upload)) + + local event, _, body = waitForEvent("sysPostHttpDone", 2000) + assert.equals("sysPostHttpDone", event) + assert.is_true(contains(body, "body=contents from the uploaded file"), body) + assert.is_false(contains(body, "data that must be ignored")) + end) + + it("accepts a nil data argument when a file is supplied", function() + if noFixtureServer() then + return + end + local upload = getMudletHomeDir() .. "/busted-http-upload-only.txt" + writeFile(upload, "file body with no data argument") + finally(function() os.remove(upload) end) + + assert.is_true(postHTTP(nil, fixtureUrl("/echo"), {}, upload)) + + local event, _, body = waitForEvent("sysPostHttpDone", 2000) + assert.equals("sysPostHttpDone", event) + assert.is_true(contains(body, "body=file body with no data argument"), body) + end) + + it("raises sysPostHttpError when the endpoint refuses the verb", function() + if noFixtureServer() then + return + end + -- Only /echo accepts a POST; the static fixture path answers 404. + assert.is_true(postHTTP("payload", fixtureUrl("/fixture.txt"))) + + local event, message, url, response = waitForEvent("sysPostHttpError", 2000) + assert.equals("sysPostHttpError", event) + assert.is_string(message) + assert.equals(fixtureUrl("/fixture.txt"), url) + assertFixtureResponse(response) + end) + end) + + describe("putHTTP", function() + it("sends its data with the PUT verb and reports sysPutHttpDone", function() + if noFixtureServer() then + return + end + local queued = putHTTP("put=payload", fixtureUrl("/echo"), {["X-Mudlet-Test"] = "put-header"}) + assert.is_true(queued) + + local event, url, body, response = waitForEvent("sysPutHttpDone", 2000) + assert.equals("sysPutHttpDone", event) + assert.equals(fixtureUrl("/echo"), url) + assert.is_true(contains(body, "method=PUT"), body) + assert.is_true(contains(body, "header:x-mudlet-test=put-header"), body) + assert.is_true(contains(body, "body=put=payload"), body) + assertFixtureResponse(response) + end) + + it("raises sysPutHttpError when the endpoint refuses the verb", function() + if noFixtureServer() then + return + end + assert.is_true(putHTTP("payload", fixtureUrl("/fixture.txt"))) + + local event, message, url = waitForEvent("sysPutHttpError", 2000) + assert.equals("sysPutHttpError", event) + assert.is_string(message) + assert.equals(fixtureUrl("/fixture.txt"), url) + end) + end) + + describe("deleteHTTP", function() + it("sends the DELETE verb and reports sysDeleteHttpDone", function() + if noFixtureServer() then + return + end + local queued = deleteHTTP(fixtureUrl("/echo"), {["X-Mudlet-Test"] = "delete-header"}) + assert.is_true(queued) + + local event, url, body, response = waitForEvent("sysDeleteHttpDone", 2000) + assert.equals("sysDeleteHttpDone", event) + assert.equals(fixtureUrl("/echo"), url) + assert.is_true(contains(body, "method=DELETE"), body) + assert.is_true(contains(body, "header:x-mudlet-test=delete-header"), body) + assertFixtureResponse(response) + end) + + it("raises sysDeleteHttpError when the endpoint refuses the verb", function() + if noFixtureServer() then + return + end + assert.is_true(deleteHTTP(fixtureUrl("/fixture.txt"))) + + local event, message, url = waitForEvent("sysDeleteHttpError", 2000) + assert.equals("sysDeleteHttpError", event) + assert.is_string(message) + assert.equals(fixtureUrl("/fixture.txt"), url) + end) + end) + + describe("customHTTP", function() + it("sends the verb it was given and echoes it back in sysCustomHttpDone", function() + if noFixtureServer() then + return + end + local queued = customHTTP("REPORT", "custom=payload", fixtureUrl("/echo"), {["X-Mudlet-Test"] = "custom-header"}) + assert.is_true(queued) + + local event, url, body, method, response = waitForEvent("sysCustomHttpDone", 2000) + assert.equals("sysCustomHttpDone", event) + assert.equals(fixtureUrl("/echo"), url) + assert.equals("REPORT", method) + assert.is_true(contains(body, "method=REPORT"), body) + assert.is_true(contains(body, "header:x-mudlet-test=custom-header"), body) + assert.is_true(contains(body, "body=custom=payload"), body) + assertFixtureResponse(response) + end) + + it("raises sysCustomHttpError naming the verb when the endpoint refuses it", function() + if noFixtureServer() then + return + end + assert.is_true(customHTTP("REPORT", "payload", fixtureUrl("/fixture.txt"))) + + local event, message, url, method = waitForEvent("sysCustomHttpError", 2000) + assert.equals("sysCustomHttpError", event) + assert.is_string(message) + assert.equals(fixtureUrl("/fixture.txt"), url) + assert.equals("REPORT", method) + end) + end) +end) + +describe("openUrl validates its argument without launching anything", function() + it("raises a Lua error when the url is missing", function() + assertArgError(function() openUrl() end, "openUrl: bad argument") + end) + + it("raises a Lua error when the url is not a string", function() + assertArgError(function() openUrl({}) end, "openUrl: bad argument") + end) +end) + +describe("MMCP chat commands report the absence of a session", function() + -- With no connected chat peers, every registered command reports its + -- no-session state. initMMCPServer() runs lazily inside these calls; it + -- constructs the server object but never calls listen(), so no socket is + -- opened (only mmcpStartServer would, and it is not registered into the Lua + -- mmcp table). + local NO_CLIENTS = "no connected clients" + local NO_SUCH = "no client by that name or id" + + it("chatAll returns nil with no peers", function() + local ok, err = mmcp.chatAll("hi") + assert.is_nil(ok) + assert.is_true(contains(err, NO_CLIENTS)) + end) + + it("emoteAll returns nil with no peers", function() + local ok, err = mmcp.emoteAll("waves") + assert.is_nil(ok) + assert.is_true(contains(err, NO_CLIENTS)) + end) + + it("chatGroup returns nil with no peers", function() + local ok, err = mmcp.chatGroup("friends", "hi") + assert.is_nil(ok) + assert.is_true(contains(err, NO_CLIENTS)) + end) + + it("getClientFlags returns nil with no peers", function() + local ok, err = mmcp.getClientFlags("someone") + assert.is_nil(ok) + assert.is_true(contains(err, NO_CLIENTS)) + end) + + it("sendSideChannel returns nil with no peers", function() + local ok, err = mmcp.sendSideChannel("Chan", "msg") + assert.is_nil(ok) + assert.is_true(contains(err, NO_CLIENTS)) + end) + + it("chatTo returns nil for an unknown target", function() + local ok, err = mmcp.chatTo("nobody", "hi") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("ping returns nil for an unknown target", function() + local ok, err = mmcp.ping("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("setPrivate returns nil for an unknown target", function() + local ok, err = mmcp.setPrivate("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("serve returns nil for an unknown target", function() + local ok, err = mmcp.serve("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("snoop returns nil for an unknown target", function() + local ok, err = mmcp.snoop("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("allowSnoop returns nil for an unknown target", function() + local ok, err = mmcp.allowSnoop("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("setGroup returns nil for an unknown target", function() + local ok, err = mmcp.setGroup("nobody", "team") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("disconnect returns nil for an unknown target", function() + local ok, err = mmcp.disconnect("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("ignore returns nil for an unknown target", function() + local ok, err = mmcp.ignore("nobody") + assert.is_nil(ok) + assert.is_true(contains(err, NO_SUCH)) + end) + + it("getClientList returns nil when there are no peers", function() + assert.is_nil(mmcp.getClientList()) + end) + + it("chatTo requires a target argument", function() + assert.has_error(function() mmcp.chatTo() end) + end) + + it("chatAll requires a message argument", function() + assert.has_error(function() mmcp.chatAll() end) + end) + + describe("mmcp.call", function() + it("raises a Lua error when the host is missing", function() + assert.has_error(function() mmcp.call() end) + end) + + it("rejects an out-of-range port without connecting", function() + local ok, err = mmcp.call("127.0.0.1", 70000) + assert.is_nil(ok) + assert.is_true(contains(err, "invalid port number")) + end) + end) + + describe("mmcp.chatName", function() + it("returns the current chat name as a string", function() + assert.is_string(mmcp.chatName()) + end) + + it("rejects names containing a tilde or comma", function() + local ok, err = mmcp.chatName("bad~name") + assert.is_nil(ok) + assert.is_true(contains(err, "tilde")) + end) + end) +end) + +-- The MMCP specs above check the no-peer contracts. These drive the real +-- protocol against the scripted peer in CI/mmcp-peer.py: it accepts the call +-- mmcp.call() places, records the bytes Mudlet sends and sends chat traffic +-- back when a spec asks it to. Nothing is mocked - each assertion is either a +-- byte the peer received, an event Mudlet raised in response to real socket +-- traffic, or a value read back through the mmcp API. +-- +-- The peer's port is ephemeral (MMCP's default 4050 would collide between CI +-- jobs and parallel worktrees) and is handed over through the directory named +-- by MUDLET_TEST_MMCP_DIR. Without a peer these specs skip, unless +-- MUDLET_TEST_REQUIRE_MMCP_PEER is set, which is how CI turns a fixture that +-- failed to start into a failure rather than a green skip. Linux and macOS +-- start one; the Windows job does not, so the block pends there. +-- +-- The specs share one connection and run in the order they are declared, after +-- the no-peer contracts above them. Anything that shuffles the suite would +-- need them made independent first. +describe("MMCP effects against a scripted chat peer", function() + -- Both of these are what CI/mmcp-peer.py calls itself + local PEER_NAME = "BustedPeer" + local PEER_VERSION = "Mudlet 0.0.0-busted-peer" + local CHAT_NAME = "MudletBustedTester" + local mmcpDir = os.getenv("MUDLET_TEST_MMCP_DIR") + local peerRequired = os.getenv("MUDLET_TEST_REQUIRE_MMCP_PEER") + local commandCounter = 0 + local originalChatName + + local function readFile(path) + local handle = io.open(path, "r") + if not handle then + return nil + end + local contents = handle:read("*a") + handle:close() + return contents + end + + -- The peer writes its port only once it is accepting, so a readable port file + -- means the fixture is up. + local function peerPort() + if not mmcpDir then + return nil + end + local raw = readFile(mmcpDir .. "/port") + return raw and tonumber(raw:match("%d+")) + end + + -- Returns true when the caller should stop because the fixture cannot be + -- talked to and skipping is allowed. + local function peerUnavailable() + local reason + if not peerPort() then + reason = "MMCP peer fixture not running (run CI/mmcp-peer.py with MUDLET_TEST_MMCP_DIR set)" + elseif type(yajl) ~= "table" then + -- yajl is loaded as an optional module, and both channels to the peer are + -- JSON, so say so rather than dying on a nil index further down. + reason = "the yajl Lua module is unavailable, so the peer's JSON channels cannot be used" + else + return false + end + if peerRequired then + assert.is_true(false, "MUDLET_TEST_REQUIRE_MMCP_PEER is set but " .. reason .. " (MUDLET_TEST_MMCP_DIR=" .. tostring(mmcpDir) .. ")") + end + pending(reason) + return true + end + + local function pump(ms) + pumpEvents(ms) + end + + local function waitUntil(predicate, timeoutMs) + local step = 20 + for _ = 1, math.ceil((timeoutMs or 2000) / step) do + if predicate() then + return true + end + pump(step) + end + return predicate() + end + + local function capture() + local raw = readFile(mmcpDir .. "/capture.json") + if not raw or raw == "" then + return nil + end + local ok, decoded = pcall(yajl.to_value, raw) + if not ok then + return nil + end + return decoded + end + + -- How far the peer's history has got, so a spec can disregard what earlier + -- specs left behind and look only at what its own action produced. An + -- unreadable capture would silently widen that to the whole history, so it + -- fails here instead. + local function captureSeq() + local decoded = capture() + assert.is_table(decoded) + assert.is_number(decoded.seq) + return decoded.seq + end + + local function waitForPeerEvent(afterSeq, matches, timeoutMs) + local found + waitUntil(function() + local decoded = capture() + found = nil + for _, event in ipairs(decoded and decoded.events or {}) do + if event.seq > afterSeq and matches(event) then + found = event + break + end + end + return found ~= nil + end, timeoutMs) + return found + end + + -- Waits for a protocol command of this name to reach the peer and returns it, + -- or nil if none arrived in time. + local function waitForCommand(name, afterSeq, timeoutMs) + return waitForPeerEvent(afterSeq, function(event) + return event.type == "command" and event.name == name + end, timeoutMs) + end + + -- Instructs the peer. Written as "<n>.json.tmp" and renamed into place so the + -- peer never picks up a half-written command. + local function tellPeer(command) + commandCounter = commandCounter + 1 + local path = mmcpDir .. "/commands/" .. commandCounter .. ".json" + local handle = assert(io.open(path .. ".tmp", "w")) + handle:write(yajl.to_string(command)) + handle:close() + assert(os.rename(path .. ".tmp", path)) + end + + local function peerSends(code, text) + tellPeer({action = "send", code = code, text = text}) + end + + -- The command channel is JSON, so bytes that are not valid UTF-8 - the 0xff + -- terminator above all - have to travel as hex. + local function peerSendsRaw(bytes) + tellPeer({action = "send_hex", hex = (bytes:gsub(".", function(char) + return string.format("%02x", char:byte()) + end))}) + end + + local function peerClient() + local clients = mmcp.getClientList() + if type(clients) ~= "table" then + return nil + end + for _, client in ipairs(clients) do + if client.name == PEER_NAME then + return client + end + end + return nil + end + + -- Set once the peer has failed to answer a call. Every spec calls ensurePeer, + -- so without this a peer that died mid-run would cost each of them the full + -- handshake wait and blow the workflow's one-minute cap before busted could + -- report anything. + local peerNotAnswering + + -- Places a call to the fixture peer unless one is already up, and returns the + -- peer's entry in mmcp.getClientList(). + local function ensurePeer() + local client = peerClient() + if client then + return client + end + if peerNotAnswering then + assert.is_true(false, peerNotAnswering) + end + -- A peer under some other name is one an earlier spec renamed and did not + -- rename back; drop it so this call is not refused as a duplicate. + local stale = mmcp.getClientList() + if type(stale) == "table" then + for _, entry in ipairs(stale) do + mmcp.disconnect(entry.name) + end + waitUntil(function() return mmcp.getClientList() == nil end, 2000) + end + originalChatName = originalChatName or mmcp.chatName() + -- A fixed name, so the bytes the peer records are predictable. + mmcp.chatName(CHAT_NAME) + assert.is_true(mmcp.call("127.0.0.1", peerPort())) + -- A peer joins the client list only once it has accepted the call, and that + -- is what raises sysMMCPPeerUpdateEvent. + if waitForEvent("sysMMCPPeerUpdateEvent", 3000) ~= "sysMMCPPeerUpdateEvent" then + peerNotAnswering = "the MMCP peer fixture never answered a call on port " .. tostring(peerPort()) + assert.is_true(false, peerNotAnswering) + end + client = peerClient() + assert.is_table(client) + return client + end + + -- Runs action with a handler armed for eventName and returns the argument + -- lists it saw. Events Mudlet raises inside an mmcp.* call are raised before + -- that call returns, so they have to be watched for, not waited on. + local function collectEvents(eventName, action) + local seen = {} + local handlerId = registerAnonymousEventHandler(eventName, function(_, ...) + seen[#seen + 1] = {...} + end) + local ok, err = pcall(action) + killAnonymousEventHandler(handlerId) + if not ok then + error(err, 0) + end + return seen + end + + -- Every spec below leaves the peer's flags as it found them, so this does + -- nothing on a passing run. It matters when one does fail: an assertion that + -- stops a spec halfway through a toggle would otherwise leave the peer + -- ignored or private and take the specs after it down as well. + after_each(function() + if not peerClient() then + return + end + local flags = mmcp.getClientFlags(PEER_NAME) + if type(flags) ~= "string" then + return + end + if flags:sub(3, 3) ~= " " then mmcp.setPrivate(PEER_NAME) end + if flags:sub(4, 4) ~= " " then mmcp.ignore(PEER_NAME) end + if flags:sub(5, 5) ~= " " then mmcp.serve(PEER_NAME) end + if flags:sub(7, 7) ~= " " then mmcp.allowSnoop(PEER_NAME) end + end) + + describe("mmcp.call", function() + it("completes the MudMaster handshake with the peer", function() + if peerUnavailable() then return end + ensurePeer() + local decoded = capture() + assert.is_table(decoded.caller) + -- "CHAT:<name>\n<address><port left aligned in 5 columns>", asserted + -- whole: the port's padding is part of the format a MudMaster peer reads + -- back, and only the exact string keeps it honest. + local port = tostring(peerPort()) + local padded = port .. string.rep(" ", math.max(0, 5 - #port)) + assert.equals("CHAT:" .. CHAT_NAME .. "\n127.0.0.1" .. padded, decoded.caller.raw) + end) + + it("announces itself as Mudlet once the call is accepted", function() + if peerUnavailable() then return end + ensurePeer() + local sent = waitForPeerEvent(0, function(event) + return event.type == "command" and event.name == "Version" + end, 2000) + assert.is_table(sent) + -- Peers switch behaviour on this string - a Mudlet peer only forwards side + -- channel data to versions saying "Mudlet", and picks the snoop colour + -- format from "MudMaster" - so the prefix is load-bearing, not cosmetic. + assert.equals("Mudlet ", sent.text:sub(1, 7)) + end) + + it("lists the accepted peer with its address, port and version", function() + if peerUnavailable() then return end + local client = ensurePeer() + assert.equals(1, client.id) + assert.equals(PEER_NAME, client.name) + assert.equals("127.0.0.1", client.host) + assert.equals(peerPort(), client.port) + -- The peer's version arrives just after its acceptance, which is what + -- releases ensurePeer, so give it its own wait rather than assuming the + -- two landed in the same read. + assert.is_true(waitUntil(function() + local entry = peerClient() + return entry ~= nil and entry.version == PEER_VERSION + end, 2000), tostring(peerClient() and peerClient().version)) + end) + + it("refuses to place a second call to a peer it is already talking to", function() + if peerUnavailable() then return end + ensurePeer() + local before = capture().connections + local ok, err = mmcp.call("127.0.0.1", peerPort()) + assert.is_nil(ok) + assert.is_true(contains(err, "already connected to that client")) + pump(200) + assert.equals(before, capture().connections) + end) + + it("leaves no client behind when nothing answers the port", function() + if peerUnavailable() then return end + ensurePeer() + -- Port 1 on loopback refuses rather than listens. The call is placed + -- (that much is asynchronous), but the client it creates has to be + -- disposed of on the error rather than lingering in the session. + assert.is_true(mmcp.call("127.0.0.1", 1)) + pump(500) + assert.equals(1, #mmcp.getClientList()) + assert.is_table(peerClient()) + end) + + it("leaves no client behind when the peer refuses the call", function() + if peerUnavailable() then return end + if peerClient() then + mmcp.disconnect(PEER_NAME) + waitUntil(function() return peerClient() == nil end, 2000) + end + tellPeer({action = "accept", accept = false}) + waitUntil(function() + local decoded = capture() + return decoded ~= nil and decoded.accepting == false + end, 1000) + + local mark = captureSeq() + assert.is_true(mmcp.call("127.0.0.1", peerPort())) + -- The peer answers "NO:<name>" and hangs up, so the call never reaches + -- the connected state and nothing is added to the client list. + assert.is_table(waitForPeerEvent(mark, function(event) + return event.type == "handshake" + end, 2000)) + pump(300) + assert.is_nil(mmcp.getClientList()) + + tellPeer({action = "accept", accept = true}) + waitUntil(function() + local decoded = capture() + return decoded ~= nil and decoded.accepting == true + end, 1000) + end) + end) + + describe("outgoing chat", function() + it("chatAll sends the message to the peer and echoes it locally", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + local echoes = collectEvents("sysMMCPChatMessage", function() + assert.is_true(mmcp.chatAll("hello everyone")) + end) + local sent = waitForCommand("TextEveryone", mark) + assert.is_table(sent) + assert.equals(CHAT_NAME .. " chats to everybody, 'hello everyone'\n", sent.text) + -- One echo, attributed to "System" because it was addressed to no-one in + -- particular. + assert.equals(1, #echoes) + assert.equals("System", echoes[1][1]) + assert.is_true(contains(echoes[1][2], "You chat to everybody, 'hello everyone'"), tostring(echoes[1][2])) + end) + + it("chatTo sends a personal message to the named peer", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + local echoes = collectEvents("sysMMCPChatMessage", function() + assert.is_true(mmcp.chatTo(PEER_NAME, "just for you")) + end) + local sent = waitForCommand("TextPersonal", mark) + assert.is_table(sent) + assert.equals(CHAT_NAME .. " chats to you, 'just for you'\n", sent.text) + -- Unlike chatAll's echo this one is attributed to the peer it was + -- addressed to, not to "System", even though we are the ones speaking. + assert.equals(1, #echoes) + assert.equals(PEER_NAME, echoes[1][1]) + assert.is_true(contains(echoes[1][2], "You chat to " .. PEER_NAME .. ", 'just for you'"), tostring(echoes[1][2])) + end) + + it("emoteAll sends an unquoted emote to everyone", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + local echoes = collectEvents("sysMMCPChatMessage", function() + assert.is_true(mmcp.emoteAll("waves at the room")) + end) + local sent = waitForCommand("TextEveryone", mark) + assert.is_table(sent) + assert.equals(CHAT_NAME .. " waves at the room\n", sent.text) + assert.equals(1, #echoes) + assert.equals("System", echoes[1][1]) + -- The profile default leaves emotes unprefixed, so the echo is the bare + -- emote and not the "You emote to everyone: '...'" wording. + assert.is_true(contains(echoes[1][2], CHAT_NAME .. " waves at the room"), tostring(echoes[1][2])) + assert.is_false(contains(echoes[1][2], "You emote to everyone"), tostring(echoes[1][2])) + end) + end) + + describe("mmcp.setGroup and mmcp.chatGroup", function() + it("reports an empty group and sends nothing until a peer is assigned", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + local ok, err = mmcp.chatGroup("testers", "nobody there") + assert.is_nil(ok) + assert.is_true(contains(err, "nobody in group 'testers' now")) + assert.is_nil(waitForCommand("TextGroup", mark, 500)) + end) + + it("reaches the peer once it has been assigned to the group", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.setGroup(PEER_NAME, "testers")) + local mark = captureSeq() + assert.is_true(mmcp.chatGroup("testers", "group hello")) + local sent = waitForCommand("TextGroup", mark) + assert.is_table(sent) + -- MudMaster's group field is a fixed 15 characters wide + assert.equals("testers ", sent.text:sub(1, 15)) + assert.is_true(contains(sent.text, " chats to the group, 'group hello'"), sent.text) + end) + + it("reads back a group chat of its own making", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.setGroup(PEER_NAME, "testers")) + local mark = captureSeq() + assert.is_true(mmcp.chatGroup("testers", "round trip")) + local sent = waitForCommand("TextGroup", mark) + assert.is_table(sent) + -- Hand Mudlet's own bytes straight back: sender and parser have to agree + -- about where the 15 character group field ends, or a Mudlet peer would + -- render another Mudlet's group chat wrongly. + local received = collectEvents("sysMMCPChatMessage", function() + peerSendsRaw(string.char(6) .. sent.text .. string.char(255)) + pump(500) + end) + assert.equals(1, #received) + assert.equals(PEER_NAME, received[1][1]) + assert.is_true(contains(received[1][2], "(testers)"), tostring(received[1][2])) + assert.is_true(contains(received[1][2], "'round trip'"), tostring(received[1][2])) + end) + + it("stops reaching the peer once it is removed from the group", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.setGroup(PEER_NAME, "none")) + local mark = captureSeq() + local ok, err = mmcp.chatGroup("testers", "still there?") + assert.is_nil(ok) + assert.is_true(contains(err, "nobody in group 'testers' now")) + assert.is_nil(waitForCommand("TextGroup", mark, 500)) + end) + end) + + describe("per-peer flags", function() + -- getClientFlags returns a fixed 8 character field: two spaces, then + -- Private, Ignored, Served, Firewalled, the snoop state and a trailing + -- space. + it("are all clear while nothing has been toggled", function() + if peerUnavailable() then return end + ensurePeer() + assert.equals(" ", mmcp.getClientFlags(PEER_NAME)) + end) + + it("setPrivate toggles the P flag", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.setPrivate(PEER_NAME)) + assert.equals(" P ", mmcp.getClientFlags(PEER_NAME)) + assert.is_true(mmcp.setPrivate(PEER_NAME)) + assert.equals(" ", mmcp.getClientFlags(PEER_NAME)) + end) + + it("ignore toggles the I flag", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.ignore(PEER_NAME)) + assert.equals(" I ", mmcp.getClientFlags(PEER_NAME)) + assert.is_true(mmcp.ignore(PEER_NAME)) + assert.equals(" ", mmcp.getClientFlags(PEER_NAME)) + end) + + it("serve toggles the S flag and tells the peer both times", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.serve(PEER_NAME)) + assert.equals(" S ", mmcp.getClientFlags(PEER_NAME)) + local told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You are now being served by " .. CHAT_NAME .. ".", told.text) + + mark = captureSeq() + assert.is_true(mmcp.serve(PEER_NAME)) + assert.equals(" ", mmcp.getClientFlags(PEER_NAME)) + told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You are no longer being served by " .. CHAT_NAME .. ".", told.text) + end) + + it("allowSnoop toggles the n flag and tells the peer both times", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.allowSnoop(PEER_NAME)) + assert.equals(" n ", mmcp.getClientFlags(PEER_NAME)) + local told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You are now allowed to snoop " .. CHAT_NAME .. ".", told.text) + + mark = captureSeq() + assert.is_true(mmcp.allowSnoop(PEER_NAME)) + assert.equals(" ", mmcp.getClientFlags(PEER_NAME)) + told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You are no longer allowed to snoop " .. CHAT_NAME .. ".", told.text) + end) + end) + + describe("incoming chat", function() + it("raises sysMMCPChatMessage for a chat to everyone", function() + if peerUnavailable() then return end + ensurePeer() + peerSends(4, PEER_NAME .. " chats to everybody, 'peer speaking'\n") + local name, from, message = waitForEvent("sysMMCPChatMessage", 2000) + assert.equals("sysMMCPChatMessage", name) + assert.equals(PEER_NAME, from) + assert.is_true(contains(message, PEER_NAME .. " chats to everybody, 'peer speaking'"), tostring(message)) + end) + + it("raises sysMMCPChatMessage for a personal chat", function() + if peerUnavailable() then return end + ensurePeer() + peerSends(5, PEER_NAME .. " chats to you, 'just between us'\n") + local _, from, message = waitForEvent("sysMMCPChatMessage", 2000) + assert.equals(PEER_NAME, from) + assert.is_true(contains(message, "chats to you, 'just between us'"), tostring(message)) + end) + + it("names the group an incoming group chat arrived on", function() + if peerUnavailable() then return end + ensurePeer() + -- MudMaster's format: a 15 character group field, then the message. + -- Mudlet's own sender adds a newline after that field, which the + -- round-trip spec above covers. + peerSends(6, "testers " .. PEER_NAME .. " chats to the group, 'group inbound'") + local _, from, message = waitForEvent("sysMMCPChatMessage", 2000) + assert.equals(PEER_NAME, from) + assert.is_true(contains(message, "(testers)"), tostring(message)) + assert.is_true(contains(message, "'group inbound'"), tostring(message)) + end) + + it("displays a plain protocol message from the peer", function() + if peerUnavailable() then return end + ensurePeer() + peerSends(7, "<CHAT> the peer has something to say") + local _, from, message = waitForEvent("sysMMCPChatMessage", 2000) + assert.equals(PEER_NAME, from) + assert.is_true(contains(message, "the peer has something to say"), tostring(message)) + end) + + it("waits for the rest of a command that arrives in two pieces", function() + if peerUnavailable() then return end + ensurePeer() + -- Commands are only complete at their 0xff terminator, and TCP is free to + -- deliver one in as many reads as it likes. Mudlet has to hold the first + -- half rather than displaying a truncated line or dropping it. + peerSendsRaw(string.char(4) .. PEER_NAME .. " chats to everybody, 'split ") + pump(150) + peerSendsRaw("message'\n" .. string.char(255)) + local _, from, message = waitForEvent("sysMMCPChatMessage", 2000) + assert.equals(PEER_NAME, from) + assert.is_true(contains(message, "'split message'"), tostring(message)) + end) + + it("handles two commands that arrive in a single write", function() + if peerUnavailable() then return end + ensurePeer() + -- The parser walks the buffer command by command, so a write carrying + -- two of them has to produce two messages rather than one or none. + local received = collectEvents("sysMMCPChatMessage", function() + peerSendsRaw(string.char(7) .. "<CHAT> first of two" .. string.char(255) + .. string.char(7) .. "<CHAT> second of two" .. string.char(255)) + pump(500) + end) + assert.equals(2, #received) + assert.is_true(contains(received[1][2], "first of two"), tostring(received[1][2])) + assert.is_true(contains(received[2][2], "second of two"), tostring(received[2][2])) + end) + + it("skips a command it does not know without losing the next one", function() + if peerUnavailable() then return end + ensurePeer() + -- An unknown command byte must be skipped up to its terminator; consuming + -- the wrong number of bytes would swallow whatever followed it. + local received = collectEvents("sysMMCPChatMessage", function() + peerSendsRaw(string.char(99) .. "nonsense" .. string.char(255) + .. string.char(7) .. "<CHAT> after the unknown" .. string.char(255)) + pump(500) + end) + assert.equals(1, #received) + assert.is_true(contains(received[1][2], "after the unknown"), tostring(received[1][2])) + end) + + it("drops chat from an ignored peer and resumes when un-ignored", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.ignore(PEER_NAME)) + peerSends(4, PEER_NAME .. " chats to everybody, 'ignored line'\n") + assert.is_nil(waitForEvent("sysMMCPChatMessage", 500)) + + assert.is_true(mmcp.ignore(PEER_NAME)) + peerSends(4, PEER_NAME .. " chats to everybody, 'heard line'\n") + local _, _, message = waitForEvent("sysMMCPChatMessage", 2000) + assert.is_true(contains(message, "'heard line'"), tostring(message)) + end) + end) + + describe("connection lists a peer sends", function() + -- A connection list makes Mudlet dial the addresses in it, so this is the + -- one incoming command that has a peer reaching outside the session. The + -- fixture's second port answers nothing and hangs up, which is enough to + -- record that Mudlet dialled it. + local function dialPort() + return capture().dial_port + end + + local function dialled(afterSeq, timeoutMs) + return waitForPeerEvent(afterSeq, function(event) + return event.type == "dialled" + end, timeoutMs) + end + + it("dials an address the peer hands over", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + peerSends(3, "127.0.0.1," .. dialPort()) + assert.is_table(dialled(mark, 2000)) + -- Nothing answered, so no peer joined the session over it. + pump(300) + assert.is_table(peerClient()) + assert.equals(1, #mmcp.getClientList()) + end) + + it("dials nothing when the list has a host without a port", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + -- An odd number of fields is rejected as badly formatted rather than + -- being half-parsed into a connection attempt. + peerSends(3, "127.0.0.1," .. dialPort() .. ",127.0.0.1") + assert.is_nil(dialled(mark, 600)) + end) + end) + + describe("mmcp.sendSideChannel", function() + it("sends channel and message to the peer as one comma separated payload", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.sendSideChannel("TestChannel", "payload here")) + local sent = waitForCommand("SideChannel", mark) + assert.is_table(sent) + assert.equals("TestChannel,payload here", sent.text) + end) + + it("raises sysMMCPSideChannelMessage for incoming side channel data", function() + if peerUnavailable() then return end + ensurePeer() + peerSends(40, "TestChannel,inbound payload") + local name, from, channel, message = waitForEvent("sysMMCPSideChannelMessage", 2000) + assert.equals("sysMMCPSideChannelMessage", name) + assert.equals(PEER_NAME, from) + assert.equals("TestChannel", channel) + assert.equals("inbound payload", message) + end) + end) + + describe("mmcp.snoop", function() + it("asks the peer for a snoop feed", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.snoop(PEER_NAME)) + local sent = waitForCommand("Snoop", mark) + assert.is_table(sent) + assert.equals("", sent.text) + -- Asking a second time is what stops a snoop, since the command is a + -- toggle at the far end. That is not asserted here: the local "am I + -- snooping them" flag is never set (nothing calls setSnooped(true)), so + -- MMCPServer::snoop's stop branch cannot be reached and asserting either + -- way would freeze the defect in place. + end) + + it("refuses a snoop from a peer that has not been allowed one", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + -- An incoming Snoop with the n flag clear: the peer is told no, and never + -- starts receiving what the game sends us. + peerSends(30, "") + local told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You do not have permission to snoop " .. CHAT_NAME .. ".", told.text) + assert.equals(" ", mmcp.getClientFlags(PEER_NAME)) + end) + + it("starts and stops snooping for a peer that has been allowed one", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.allowSnoop(PEER_NAME)) + -- Granting permission sends a Message of its own; let it land before + -- marking, so what is waited for below cannot be that one. + assert.is_table(waitForCommand("Message", mark)) + + mark = captureSeq() + peerSends(30, "") + local told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You have begun snooping " .. CHAT_NAME .. ".", told.text) + -- N, not n: the peer is snooping us now rather than merely permitted to. + assert.equals(" N ", mmcp.getClientFlags(PEER_NAME)) + + mark = captureSeq() + peerSends(30, "") + told = waitForCommand("Message", mark) + assert.is_table(told) + assert.equals("<CHAT> You have stopped snooping " .. CHAT_NAME .. ".", told.text) + assert.equals(" n ", mmcp.getClientFlags(PEER_NAME)) + assert.is_true(mmcp.allowSnoop(PEER_NAME)) + end) + + it("raises sysMMCPIncomingSnoopMessage for snooped output", function() + if peerUnavailable() then return end + ensurePeer() + peerSends(31, "You see a snooped line of game output") + local name, from, message = waitForEvent("sysMMCPIncomingSnoopMessage", 2000) + assert.equals("sysMMCPIncomingSnoopMessage", name) + assert.equals(PEER_NAME, from) + assert.is_true(contains(message, "a snooped line of game output"), tostring(message)) + end) + + it("keeps the colour of a snooped line", function() + if peerUnavailable() then return end + ensurePeer() + -- Snoop data is where the other end's colour arrives, and Mudlet tracks + -- it across lines, so the escape sequences have to survive into the event + -- rather than being stripped or reordered away from their text. + peerSendsRaw(string.char(31) .. "\27[1;32ma green snooped line\27[0m" .. string.char(255)) + local _, _, message = waitForEvent("sysMMCPIncomingSnoopMessage", 2000) + assert.is_true(contains(message, "\27[1;32ma green snooped line"), tostring(message)) + end) + end) + + describe("mmcp.ping", function() + it("sends a timestamped ping the peer can answer", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.ping(PEER_NAME)) + local sent = waitForCommand("PingRequest", mark) + assert.is_table(sent) + -- the payload is milliseconds since the epoch, which is what comes back + assert.is_number(tonumber(sent.text)) + end) + + it("answers an incoming ping with the same payload", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + peerSends(26, "1234567890123") + local answered = waitForCommand("PingResponse", mark) + assert.is_table(answered) + assert.equals("1234567890123", answered.text) + end) + end) + + describe("mmcp.chatName", function() + it("announces a new name to connected peers and reads it back", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.chatName("RenamedTester")) + local sent = waitForCommand("NameChange", mark) + assert.is_table(sent) + assert.equals("RenamedTester", sent.text) + assert.equals("RenamedTester", mmcp.chatName()) + + mark = captureSeq() + assert.is_true(mmcp.chatName(CHAT_NAME)) + local restored = waitForCommand("NameChange", mark) + assert.is_table(restored) + assert.equals(CHAT_NAME, restored.text) + end) + + it("does not announce a name that has not changed", function() + if peerUnavailable() then return end + ensurePeer() + assert.is_true(mmcp.chatName(CHAT_NAME)) + local mark = captureSeq() + assert.is_true(mmcp.chatName(CHAT_NAME)) + assert.is_nil(waitForCommand("NameChange", mark, 500)) + end) + + it("does not announce a rejected name", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + local ok, err = mmcp.chatName("bad,name") + assert.is_nil(ok) + assert.is_true(contains(err, "comma")) + assert.is_nil(waitForCommand("NameChange", mark, 500)) + assert.equals(CHAT_NAME, mmcp.chatName()) + end) + + it("follows the peer when it renames itself", function() + if peerUnavailable() then return end + ensurePeer() + peerSends(1, "RenamedPeer") + assert.is_true(waitUntil(function() + local clients = mmcp.getClientList() + return type(clients) == "table" and clients[1] ~= nil and clients[1].name == "RenamedPeer" + end, 2000)) + -- and the new name is what addresses it from then on + assert.is_true(mmcp.chatTo("RenamedPeer", "hello again")) + + peerSends(1, PEER_NAME) + assert.is_true(waitUntil(function() return peerClient() ~= nil end, 2000)) + end) + end) + + describe("mmcp.displayClientList", function() + it("prints the connected peer with its address and port", function() + if peerUnavailable() then return end + ensurePeer() + local printed = collectEvents("sysMMCPChatMessage", function() + assert.is_true(mmcp.displayClientList()) + end) + -- The whole table goes out as one message, attributed to nobody in + -- particular. + assert.equals(1, #printed) + assert.equals("System", printed[1][1]) + local text = printed[1][2] + assert.is_true(contains(text, PEER_NAME), text) + assert.is_true(contains(text, "127.0.0.1"), text) + assert.is_true(contains(text, tostring(peerPort())), text) + end) + end) + + describe("mmcp.accept and mmcp.deny", function() + -- No peer needed: this is about what the mmcp table contains. + it("are not reachable from Lua, so incoming calls cannot be covered", function() + -- Mudlet's pending-call notice tells the user to run mmcp.accept(id) or + -- mmcp.deny(id), but neither is in the mmcp table: their registration in + -- TLuaInterpreter.cpp is commented out, along with setDoNotDisturb, + -- startServer, stopServer, request and peek. Without startServer Mudlet + -- cannot listen either, so no incoming call can be staged here at all. + -- Left pending rather than asserted so the gap is not locked in place - + -- but registering them has to be noticed, hence the failure below. + if mmcp.accept ~= nil or mmcp.deny ~= nil then + assert.is_true(false, "mmcp.accept/mmcp.deny are registered now - replace this spec with real accept and deny coverage") + end + pending("mmcp.accept/mmcp.deny are not registered in the Lua mmcp table") + end) + end) + + describe("disconnection", function() + it("notices when the peer closes the connection", function() + if peerUnavailable() then return end + ensurePeer() + tellPeer({action = "close"}) + assert.equals("sysMMCPPeerUpdateEvent", waitForEvent("sysMMCPPeerUpdateEvent", 2000)) + assert.is_nil(peerClient()) + end) + + it("mmcp.disconnect closes the connection from this end", function() + if peerUnavailable() then return end + ensurePeer() + local mark = captureSeq() + assert.is_true(mmcp.disconnect(PEER_NAME)) + assert.equals("sysMMCPPeerUpdateEvent", waitForEvent("sysMMCPPeerUpdateEvent", 2000)) + assert.is_nil(mmcp.getClientList()) + assert.is_table(waitForPeerEvent(mark, function(event) + return event.type == "disconnect" + end, 2000)) + assert.is_false(capture().connected) + end) + + end) + + -- Restores whatever chat name the profile was carrying before these specs + -- ran, so nothing that follows sees a name this file chose. + teardown(function() + if originalChatName then + mmcp.chatName(originalChatName) + end + end) +end) + +describe("Discord Lua API availability contract", function() + -- Every rich-presence function is gated on the Discord API being available + -- (the discord-rpc library loaded and Discord enabled for this profile). In + -- CI the library is not on the load path, so each gated function is denied + -- with the same stable reason. On a machine where Discord is live these + -- pend instead of mutating real presence data. + local gatedFunctions = { + "usingMudletsDiscordID", "getDiscordDetail", "getDiscordLargeIcon", + "getDiscordLargeIconText", "getDiscordParty", "getDiscordSmallIcon", + "getDiscordSmallIconText", "getDiscordState", "getDiscordTimeStamps", + "resetDiscordData", "setDiscordApplicationID", "setDiscordDetail", + "setDiscordElapsedStartTime", "setDiscordGame", "setDiscordLargeIcon", + "setDiscordLargeIconText", "setDiscordParty", "setDiscordRemainingEndTime", + "setDiscordSmallIcon", "setDiscordSmallIconText", "setDiscordState", + } + + -- Probe with a read-access getter. When it returns nil+message the API is + -- denied and that message is the shared denial reason; otherwise the API is + -- usable in this environment and the contract tests pend. + local function discordDenial() + local ok, msg = getDiscordState() + if ok == nil and type(msg) == "string" then + return msg + end + return nil + end + + it("the denial reason refers to Discord", function() + local denial = discordDenial() + if not denial then + pending("Discord API is enabled in this environment") + return + end + assert.is_true(contains(denial, "Discord")) + end) + + for _, fnName in ipairs(gatedFunctions) do + it(fnName .. " returns the shared denial while the API is unavailable", function() + local denial = discordDenial() + if not denial then + pending("Discord API is enabled in this environment") + return + end + -- Called with no arguments: the availability gate is checked before any + -- argument, so nothing is read or mutated on the denied path. + local ok, msg = _G[fnName]() + assert.is_nil(ok) + assert.equals(denial, msg) + end) + end + + describe("setDiscordGameUrl (intentionally ungated)", function() + -- setDiscordGameUrl changes the profile's invite button, not rich + -- presence, so it has no availability gate. Only its argument type is a + -- deterministic offline contract; the success path is left to effect tests + -- as it mutates profile state. + it("raises a Lua error when the url argument is not a string", function() + assertArgError(function() setDiscordGameUrl({}) end, "setDiscordGameUrl: bad argument") + end) + end) +end) + +describe("The IRC configuration functions round-trip through the profile", function() + -- While a profile has no IRC dialog - none of these specs opens one - the + -- getters read the profile's own configuration off disk, which is what the + -- setters write to. So the round trip is testable with no IRC server and no + -- connection anywhere in sight. + -- + -- The profile's own IRC configuration is put back afterwards, because the + -- self-test profile is reused between runs. Two things the restore cannot + -- reach, both of which matter to a developer running the suite against a + -- config root that is not a throwaway one: + -- + -- - the IRC password. setIrcServer() writes it on every call and blanks it + -- when none is passed, and no getter reads it back, so any password the + -- profile had is gone either way. + -- - the last-used nick, which setIrcNick() also writes to a file shared by + -- every profile (mudlet's data directory, not the profile's). Putting the + -- profile's nick back writes that file again rather than restoring it. + local function restoreIrcConfiguration() + local nick = getIrcNick() + local hostName, port, secure = getIrcServer() + local channels = getIrcChannels() + finally(function() + setIrcNick(nick) + setIrcServer(hostName, port, secure) + setIrcChannels(channels) + end) + end + + describe("getIrcNick, getIrcServer and getIrcChannels", function() + it("report a nick, a server and a channel list without an IRC client", function() + -- with nothing configured each getter falls back to a built-in default + -- rather than to nil, which is what makes them safe to read before + -- anything has been set + local nick = getIrcNick() + assert.is_string(nick) + assert.is_true(#nick > 0) + + local hostName, port, secure = getIrcServer() + assert.is_string(hostName) + assert.is_true(#hostName > 0) + assert.is_number(port) + assert.is_true(port >= 1 and port <= 65535, tostring(port)) + assert.is_boolean(secure) + + local channels = getIrcChannels() + assert.is_table(channels) + assert.is_true(#channels > 0) + for _, channel in ipairs(channels) do + assert.is_string(channel) + end + end) + end) + + describe("setIrcNick", function() + it("raises a Lua error when the nick is missing or not a string", function() + assertArgError(function() setIrcNick() end, "setIrcNick: bad argument #1 type (nick as string expected") + assertArgError(function() setIrcNick({}) end, "setIrcNick: bad argument #1 type (nick as string expected, got table!)") + end) + + it("returns nil and a message for an empty nick, leaving the stored one alone", function() + restoreIrcConfiguration() + assert.is_true(setIrcNick("BustedKeptNick")) + + local ok, err = setIrcNick("") + assert.is_nil(ok) + assert.is_true(contains(err, "nick must not be empty"), tostring(err)) + assert.equals("BustedKeptNick", getIrcNick()) + end) + + it("stores the nick where getIrcNick reads it back", function() + restoreIrcConfiguration() + assert.is_true(setIrcNick("BustedNickOne")) + assert.equals("BustedNickOne", getIrcNick()) + + assert.is_true(setIrcNick("BustedNickTwo")) + assert.equals("BustedNickTwo", getIrcNick()) + end) + end) + + describe("setIrcServer", function() + it("raises a Lua error when the hostname or an optional argument is wrongly typed", function() + assertArgError(function() setIrcServer() end, "setIrcServer: bad argument #1 type (hostname as string expected") + assertArgError(function() setIrcServer({}) end, "setIrcServer: bad argument #1 type (hostname as string expected, got table!)") + assertArgError(function() setIrcServer("irc.busted.invalid", {}) end, "port number") + assertArgError(function() setIrcServer("irc.busted.invalid", 6667, "yes") end, "secure") + assertArgError(function() setIrcServer("irc.busted.invalid", 6667, false, {}) end, "server password") + end) + + it("returns nil and a message for an empty hostname or an out-of-range port", function() + restoreIrcConfiguration() + assert.is_true(setIrcServer("irc.busted-kept.invalid", 6690)) + + local ok, err = setIrcServer("") + assert.is_nil(ok) + assert.is_true(contains(err, "hostname must not be empty"), tostring(err)) + + ok, err = setIrcServer("irc.busted.invalid", 70000) + assert.is_nil(ok) + assert.is_true(contains(err, "invalid port number 70000"), tostring(err)) + + ok, err = setIrcServer("irc.busted.invalid", 0) + assert.is_nil(ok) + assert.is_true(contains(err, "invalid port number 0"), tostring(err)) + + -- a refused call stored nothing + local hostName, port = getIrcServer() + assert.equals("irc.busted-kept.invalid", hostName) + assert.equals(6690, port) + end) + + it("stores the hostname, port and secure flag where getIrcServer reads them back", function() + restoreIrcConfiguration() + -- it reports success as true plus a nil second value + local ok, extra = setIrcServer("irc.busted-one.invalid", 6697, true) + assert.is_true(ok) + assert.is_nil(extra) + + local hostName, port, secure = getIrcServer() + assert.equals("irc.busted-one.invalid", hostName) + assert.equals(6697, port) + assert.is_true(secure) + + -- the secure flag is stored, not merely defaulted: turn it back off + assert.is_true(setIrcServer("irc.busted-two.invalid", 6668, false)) + hostName, port, secure = getIrcServer() + assert.equals("irc.busted-two.invalid", hostName) + assert.equals(6668, port) + assert.is_false(secure) + end) + + it("falls back to port 6667 and an insecure connection when only a hostname is given", function() + restoreIrcConfiguration() + assert.is_true(setIrcServer("irc.busted-secure.invalid", 6697, true)) + + assert.is_true(setIrcServer("irc.busted-default.invalid")) + local hostName, port, secure = getIrcServer() + assert.equals("irc.busted-default.invalid", hostName) + assert.equals(6667, port) + assert.is_false(secure) + end) + end) + + describe("setIrcChannels", function() + it("raises a Lua error when the channels are not a table", function() + assertArgError(function() setIrcChannels("#mudlet") end, "setIrcChannels: bad argument #1 type (channels as table expected, got string!)") + assertArgError(function() setIrcChannels() end, "setIrcChannels: bad argument #1 type (channels as table expected, got no value!)") + end) + + it("returns nil and a message when no entry is a usable channel name", function() + restoreIrcConfiguration() + assert.is_true(setIrcChannels({"#busted-kept"})) + + local ok, err = setIrcChannels({}) + assert.is_nil(ok) + assert.is_true(contains(err, "no (valid) channel names provided"), tostring(err)) + + -- a channel name has to start with #, & or +, and only strings are read + ok, err = setIrcChannels({"mudlet", 42, ""}) + assert.is_nil(ok) + assert.is_true(contains(err, "no (valid) channel names provided"), tostring(err)) + assert.same({"#busted-kept"}, getIrcChannels()) + end) + + it("stores the channel list where getIrcChannels reads it back", function() + restoreIrcConfiguration() + assert.is_true(setIrcChannels({"#busted-one", "&busted-two", "+busted-three"})) + assert.same({"#busted-one", "&busted-two", "+busted-three"}, getIrcChannels()) + end) + + it("keeps the usable channel names out of a mixed list and drops the rest", function() + restoreIrcConfiguration() + assert.is_true(setIrcChannels({"#busted-good", "busted-bad", "&busted-also-good"})) + assert.same({"#busted-good", "&busted-also-good"}, getIrcChannels()) + end) + end) + + describe("getIrcConnectedHost and restartIrc without a client", function() + -- Both of these read whether the profile has an IRC dialog, and nothing in + -- the suite creates one - see the openIRC spec below for why. Should + -- something start doing so, these are where it shows up first. + it("getIrcConnectedHost returns false and says there is no client", function() + local ok, err = getIrcConnectedHost() + assert.is_false(ok) + assert.equals("no client active", err) + end) + + it("restartIrc returns false", function() + -- there is no client to restart, and it says so by returning false + -- rather than by opening one + assert.is_false(restartIrc(), "something in this run opened an IRC client") + end) + end) + + describe("sendIrc", function() + -- Both arguments are checked before the IRC dialog would be created, so + -- these calls open no client. A well-formed sendIrc() does create one, + -- which is why there is no spec here for the delivery path. + it("raises a Lua error when the target or the message is missing or wrongly typed", function() + assertArgError(function() sendIrc() end, "sendIrc: bad argument #1 type (target as string expected") + assertArgError(function() sendIrc("#mudlet") end, "sendIrc: bad argument #2 type (message as string expected") + assertArgError(function() sendIrc({}, "hello") end, "sendIrc: bad argument #1 type (target as string expected, got table!)") + assertArgError(function() sendIrc("#mudlet", {}) end, "sendIrc: bad argument #2 type (message as string expected, got table!)") + end) + end) + + describe("openIRC", function() + it("opens the IRC client window", function() + pending("openIRC creates the profile's IRC dialog and nothing in the Lua API closes it again. " + .. "From then on the getters answer out of the copy the dialog read when it was constructed - " + .. "a setIrcNick() while it is open is not seen by getIrcNick() until restartIrc() - so the " + .. "round trips above would stop working for the rest of the run, and the dialog dials the " + .. "configured server and raises a window over the specs that follow") + end) + end) +end) + +describe("getNetworkLatency", function() + it("reports zero on a profile whose game socket has never been timed", function() + -- The latency is measured between a command going out and the prompt that + -- answers it, and nothing in the suite connects the game socket - so the + -- untouched value is what this reads, which is also what pins it to the + -- right member. A meaningful reading needs a game server. + local latency = getNetworkLatency() + assert.is_number(latency) + assert.equals(0, latency) + end) +end) diff --git a/src/mudlet-lua/tests/Other_spec.lua b/src/mudlet-lua/tests/Other_spec.lua index 18d33c496..a0662a3a9 100644 --- a/src/mudlet-lua/tests/Other_spec.lua +++ b/src/mudlet-lua/tests/Other_spec.lua @@ -1,25 +1,17 @@ describe("Tests Other.lua functions", function() describe("Tests the functionality of sendAll", function() - setup(function() - _G.echo = function() end - _G.send = function() end - _G.tempTimer = function(time, code) - if type(code) == "string" then - loadstring(code)() - elseif type(code) == "function" then - code() - else - error("tempTimer: Code must be a string or a function.") - end - end - end) + -- sendAll and the speedwalk family below drive the real send() function. + -- Offline (the self-test profile is not connected) send() is a no-op on the + -- wire, so we spy on the real function (pass-through) rather than replacing + -- it with a mock, and assert the actual dispatch it performs. it("should send one command if it is only given one parameter", function() local send = spy.on(_G, "send") sendAll("look") assert.spy(send).was.called(1) assert.spy(send).was.called_with("look", true) + send:revert() end) it("should send multiple commands when given multiple string parameters", function() @@ -34,194 +26,184 @@ describe("Tests Other.lua functions", function() for _,command in ipairs(commands) do assert.spy(send).was.called_with(command, true) end + send:revert() end) - it("should pass along the final boolean argument to all sends if provided", function() + it("should pass along a final boolean argument of false to all sends", function() local send = spy.on(_G, "send") sendAll("get gold from pouch", "buy potion", "put gold in pouch", false) assert.spy(send).was.called(3) assert.spy(send).was.called_with("get gold from pouch", false) assert.spy(send).was.called_with("buy potion", false) assert.spy(send).was.called_with("put gold in pouch", false) + send:revert() end) - it("should pass along the final boolean argument to all sends if provided", function() + it("should pass along a final boolean argument of true to all sends", function() local send = spy.on(_G, "send") sendAll("get gold from pouch", "buy potion", "put gold in pouch", true) assert.spy(send).was.called(3) assert.spy(send).was.called_with("get gold from pouch", true) assert.spy(send).was.called_with("buy potion", true) assert.spy(send).was.called_with("put gold in pouch", true) + send:revert() + end) + + it("schedules a tempTimer per command instead of sending when the first argument is a delay", function() + local send = spy.on(_G, "send") + -- Wrap the real tempTimer (pass-through) purely to capture the ids it + -- returns so we can cancel the scheduled timers; sendAll discards them. + local scheduledIds = {} + local realTempTimer = _G.tempTimer + _G.tempTimer = function(...) + local id = realTempTimer(...) + scheduledIds[#scheduledIds + 1] = id + return id + end + finally(function() + _G.tempTimer = realTempTimer + for _, id in ipairs(scheduledIds) do + pcall(killTimer, id) + end + send:revert() + end) + + sendAll(5, "north", "south") + assert.spy(send).was_not_called() + assert.equals(2, #scheduledIds) + end) + end) + + describe("Tests the functionality of sendCmdLine", function() + -- sendCmdLine sets the active command line's text (no wire traffic to + -- observe offline), which is readable back with getCmdLine. We clear the + -- command line afterwards so no text is left behind. + after_each(function() + pcall(clearCmdLine) + end) + + it("sets the command line text and returns true", function() + assert.is_true(sendCmdLine("look")) + assert.equals("look", getCmdLine()) + end) + + it("errors when given no argument", function() + assert.has_error(function() sendCmdLine() end) + end) + + it("errors when the argument is not a string", function() + assert.has_error(function() sendCmdLine({}) end) end) end) describe("Tests the functionality of permGroup", function() + -- permGroup creates *permanent* items, which have no public removal API and + -- are written to disk when the profile saves. To verify the real dispatch + -- and the documented default arguments without polluting the profile we use + -- a parent that does not exist: each underlying perm* function marshals its + -- arguments and then fails at the parent lookup before creating anything. + local nonexistentParent = "permGroupSpecNonexistentParent" - describe("success", function() - setup(function() - _G.oldPermTimer = _G.permTimer - _G.oldPermSubstringTrigger = _G.permSubstringTrigger - _G.oldPermAlias = _G.permAlias - _G.oldPermKey = _G.permKey - _G.oldPermScript = _G.permScript - _G.permTimer = function() return 1 end - _G.permSubstringTrigger = function() return 1 end - _G.permAlias = function() return 1 end - _G.permKey = function() return 1 end - _G.permScript = function() return 1 end - end) - - teardown(function() - _G.permTimer = _G.oldPermTimer - _G.permSubstringTrigger = _G.oldPermSubstringTrigger - _G.permAlias = _G.oldPermAlias - _G.permKey = _G.oldPermKey - _G.permScript = _G.oldPermScript - _G.oldPermTimer = nil - _G.oldPermSubstringTrigger = nil - _G.oldPermAlias = nil - _G.oldPermKey = nil - _G.oldPermScript = nil - end) - - it("should return true if the timer group was created", function() + describe("dispatches to the correct underlying function with the documented defaults", function() + -- A nonexistent parent lets us drive the real dispatch without polluting the + -- profile: each perm* marshals its arguments and then raises at the parent + -- lookup before creating anything. The spy is reverted immediately after the + -- call (its recorded history survives revert) so cleanup happens even against + -- the old raising code. Each test also confirms the failure surfaces as a + -- false return rather than a raise. + it("uses permTimer(name, parent, 0, '') for timers", function() local permTimer = spy.on(_G, "permTimer") - local name = "TestTimer" - local parent = "Parent" - local successful = permGroup(name, "timer", parent) - assert.spy(permTimer).was.called_with(name, parent, 0, "") - assert.is_true(successful) + local ok, created = pcall(permGroup, "permGroupSpecTimer", "timer", nonexistentParent) + permTimer:revert() + assert.spy(permTimer).was.called_with("permGroupSpecTimer", nonexistentParent, 0, "") + assert.is_true(ok) + assert.is_false(created) end) - it("should return true if the alias group was created", function() + it("uses permSubstringTrigger(name, parent, {}, '') for triggers", function() + local permSubstringTrigger = spy.on(_G, "permSubstringTrigger") + local ok, created = pcall(permGroup, "permGroupSpecTrigger", "trigger", nonexistentParent) + permSubstringTrigger:revert() + assert.spy(permSubstringTrigger).was.called_with("permGroupSpecTrigger", nonexistentParent, {}, "") + assert.is_true(ok) + assert.is_false(created) + end) + + it("uses permAlias(name, parent, '', '') for aliases", function() local permAlias = spy.on(_G, "permAlias") - local name = "TestAlias" - local parent = "Parent" - local successful = permGroup(name, "alias", parent) - assert.spy(permAlias).was.called_with(name, parent, "", "") - assert.is_true(successful) + local ok, created = pcall(permGroup, "permGroupSpecAlias", "alias", nonexistentParent) + permAlias:revert() + assert.spy(permAlias).was.called_with("permGroupSpecAlias", nonexistentParent, "", "") + assert.is_true(ok) + assert.is_false(created) end) - it("should return true if the trigger group was created", function() - local permTrigger = spy.on(_G, "permSubstringTrigger") - local name = "TestTrigger" - local parent = "Parent" - local successful = permGroup(name, "trigger", parent) - assert.spy(permTrigger).was.called_with(name, parent, {}, "") - assert.is_true(successful) - end) - - it("should return true if the key group was created", function() + it("uses permKey(name, parent, -1, '') for keys", function() local permKey = spy.on(_G, "permKey") - local name = "TestKey" - local parent = "Parent" - local successful = permGroup(name, "key", parent) - assert.spy(permKey).was.called_with(name, parent, -1, "") - assert.is_true(successful) + local ok, created = pcall(permGroup, "permGroupSpecKey", "key", nonexistentParent) + permKey:revert() + assert.spy(permKey).was.called_with("permGroupSpecKey", nonexistentParent, -1, "") + assert.is_true(ok) + assert.is_false(created) end) - it("should return true if the script group was created", function() + it("uses permScript(name, parent, '', '') for scripts", function() local permScript = spy.on(_G, "permScript") - local name = "TestScript" - local parent = "Parent" - local successful = permGroup(name, "script", parent) - assert.spy(permScript).was.called_with(name, parent, "", "") - assert.is_true(successful) + local ok, created = pcall(permGroup, "permGroupSpecScript", "script", nonexistentParent) + permScript:revert() + assert.spy(permScript).was.called_with("permGroupSpecScript", nonexistentParent, "", "") + assert.is_true(ok) + assert.is_false(created) end) - it("should use empty string as default parent when parent is not provided", function() - local permTimer = spy.on(_G, "permTimer") - local name = "TestTimer" - local successful = permGroup(name, "timer") - assert.spy(permTimer).was.called_with(name, "", 0, "") - assert.is_true(successful) + it("defaults a missing parent to the top level", function() + -- The documented two-argument form permGroup(name, type) turns a missing + -- parent into "", which makes the underlying perm* succeed and create a + -- real top-level item; stub it so nothing is written to the profile. + local permTimer = stub(_G, "permTimer", 42) + local created = permGroup("permGroupSpecDefaultParent", "timer") + permTimer:revert() + assert.stub(permTimer).was.called_with("permGroupSpecDefaultParent", "", 0, "") + assert.is_true(created) end) end) - describe("failure", function() - setup(function() - _G.oldPermTimer = _G.permTimer - _G.oldPermSubstringTrigger = _G.permSubstringTrigger - _G.oldPermAlias = _G.permAlias - _G.oldPermKey = _G.permKey - _G.oldPermScript = _G.permScript - _G.permTimer = function() return -1 end - _G.permSubstringTrigger = function() return -1 end - _G.permAlias = function() return -1 end - _G.permKey = function() return -1 end - _G.permScript = function() return -1 end + describe("reports failure instead of raising when creation fails", function() + -- #9545: group_creation_functions checked `perm*(...) == -1`, but the perm* + -- bindings raise a Lua error on failure (for example a missing parent) + -- rather than returning -1, so permGroup could never honour its documented + -- false-on-failure contract. It now pcalls the creation and returns false + -- plus the underlying error message. + it("returns false when the parent group does not exist", function() + local created = permGroup("permGroupSpecOrphan", "timer", nonexistentParent) + assert.is_false(created) end) - teardown(function() - _G.permTimer = _G.oldPermTimer - _G.permSubstringTrigger = _G.oldPermSubstringTrigger - _G.permAlias = _G.oldPermAlias - _G.permKey = _G.oldPermKey - _G.permScript = _G.oldPermScript - _G.oldPermTimer = nil - _G.oldPermSubstringTrigger = nil - _G.oldPermAlias = nil - _G.oldPermKey = nil - _G.oldPermScript = nil + it("returns the underlying error message alongside false", function() + local created, err = permGroup("permGroupSpecOrphan", "timer", nonexistentParent) + assert.is_false(created) + assert.is_string(err) + -- pin that the real underlying error (which names the missing parent) + -- propagated, rather than coupling to any particular phrasing + assert.is_truthy(err:find(nonexistentParent, 1, true)) end) + end) - it("should return false if the timer group was not created", function() - local permTimer = spy.on(_G, "permTimer") - local name = "TestTimer" - local parent = "Parent" - local successful = permGroup(name, "timer", parent) - assert.spy(permTimer).was.called_with(name, parent, 0, "") - assert.is_false(successful) - end) - - it("should return false if the alias group was not created", function() - local permAlias = spy.on(_G, "permAlias") - local name = "TestAlias" - local parent = "Parent" - local successful = permGroup(name, "alias", parent) - assert.spy(permAlias).was.called_with(name, parent, "", "") - assert.is_false(successful) - end) - - it("should return false if the trigger group was not created", function() - local permTrigger = spy.on(_G, "permSubstringTrigger") - local name = "TestTrigger" - local parent = "Parent" - local successful = permGroup(name, "trigger", parent) - assert.spy(permTrigger).was.called_with(name, parent, {}, "") - assert.is_false(successful) - end) - - it("should return false if the key group was not created", function() - local permKey = spy.on(_G, "permKey") - local name = "TestKey" - local parent = "Parent" - local successful = permGroup(name, "key", parent) - assert.spy(permKey).was.called_with(name, parent, -1, "") - assert.is_false(successful) - end) - - it("should return false if the script group was not created", function() - local permScript = spy.on(_G, "permScript") - local name = "TestScript" - local parent = "Parent" - local successful = permGroup(name, "script", parent) - assert.spy(permScript).was.called_with(name, parent, "", "") - assert.is_false(successful) + describe("reports success", function() + -- Stub the underlying binding so no real permanent item is created (which + -- would pollute the profile). A successful perm* returns an item id, and + -- permGroup must surface that as a boolean true. Revert before asserting so + -- the stub can never leak into later tests. + it("returns true when the underlying creation succeeds", function() + local permTimer = stub(_G, "permTimer", 42) + local created = permGroup("permGroupSpecSuccess", "timer", "irrelevantParent") + permTimer:revert() + assert.stub(permTimer).was.called_with("permGroupSpecSuccess", "irrelevantParent", 0, "") + assert.is_true(created) end) end) describe("error handling", function() - setup(function() - _G.oldPermTimer = _G.permTimer - _G.permTimer = function() return 1 end - end) - - teardown(function() - _G.permTimer = _G.oldPermTimer - _G.oldPermTimer = nil - end) - it("should raise an error if name is not a string", function() assert.has_error(function() permGroup(123, "timer") @@ -268,7 +250,7 @@ describe("Tests Other.lua functions", function() it("should return true if a is true and b is false", function() assert.is_true(xor(false,true)) end) - + it("should return false if a is true and b is true", function() assert.is_false(xor(true, true)) end) @@ -279,20 +261,15 @@ describe("Tests Other.lua functions", function() end) describe("Tests the functionality of speedwalking", function() - -- Note that busted insulates changes in each test file, so - -- these changes won't escape outside this file. - setup(function() - _G.echo = function() end - _G.send = function() end - _G.tempTimer = function(time, code) - if type(code) == "string" then - loadstring(code)() - elseif type(code) == "function" then - code() - else - error("tempTimer: Code must be a string or a function.") - end - end + -- speedwalk with no delay dispatches synchronously through the real send(); + -- the delayed form is covered by an immediate-contract test below and the + -- speedwalk state machine (stop/pause/resume) has its own describe block. + -- Real timer firing is intentionally out of scope here (no sleeps). + + after_each(function() + -- if the delayed-walk test's assertion fails before it cleans up, cancel + -- the scheduled timer chain so it cannot fire during later tests + pcall(stopSpeedwalk) end) it("Tests basic speedwalk() with chained directions", function() @@ -305,43 +282,133 @@ describe("Tests Other.lua functions", function() assert.spy(send).was.called_with("se", true) assert.spy(send).was.called_with("u", true) assert.spy(send).was_not_called_with("e", true) + send:revert() end) it("Tests basic speedwalk() with commas as separators", function() local send = spy.on(_G, "send") - -- Will walk twice northeast, thrice east, twice north, once east. All in immediate succession.") + -- Will walk twice northeast, thrice east, twice north, once east. All in immediate succession. speedwalk('2ne,3e,2n,e') assert.spy(send).was.called(8) assert.spy(send).was.called_with("ne", true) assert.spy(send).was.called_with("e", true) assert.spy(send).was.called_with("n", true) + send:revert() end) it("tests reverse speedwalk", function() local send = spy.on(_G, "send") speedwalk("5sw - 3s - 2n - w", true) - -- Will walk backwards: east, twice south, thrice, north, five times northeast. All in immediate succession. + -- Will walk backwards: east, twice south, thrice north, five times northeast. All in immediate succession. assert.spy(send).was.called(11) assert.spy(send).was.called_with("ne", true) assert.spy(send).was.called_with("n", true) assert.spy(send).was.called_with("s", true) assert.spy(send).was.called_with("e", true) - assert.spy(send).was.was_not_called_with("w", true) + assert.spy(send).was_not_called_with("w", true) + send:revert() end) - it("tests reverse speedwalk with a delay", function() - local send = spy.on(_G, "send") - local speedwalktimer = spy.on(_G, "speedwalktimer") - local tempTimer = spy.on(_G, "tempTimer") + it("dispatches only the first step synchronously when a delay is given", function() + local send = spy.on(_G, "send") + -- With a delay the remaining steps are scheduled on real tempTimers + -- (Wave 2 territory); only the first step happens synchronously. speedwalk("3w, 2ne, w, u", true, 1.25) - -- Will walk backwards: down, east, twice southwest, thrice east, with 1.25 seconds delay between every move. + assert.spy(send).was.called(1) - assert.spy(speedwalktimer).was.called() - assert.spy(send).was.called(7) - assert.spy(tempTimer).was.called(6) + -- Cancel the scheduled continuation so it does not fire during later tests. + local stopped = stopSpeedwalk() + assert.is_true(stopped) + send:revert() + end) + end) + + describe("Tests the speedwalk state machine", function() + -- stopSpeedwalk/pauseSpeedwalk/resumeSpeedwalk drive a shared upvalue state + -- machine and raise sys* events (raiseEvent dispatches synchronously in + -- process). We start a delayed speedwalk to enter the running state, then + -- assert the control functions' return contracts and events without sleeps. + + after_each(function() + -- Fully clear any active OR paused speedwalk so no timer chain or leftover + -- walklist survives into the next test. resumeSpeedwalk re-arms a paused + -- walk so the subsequent stopSpeedwalk can clear its list. + pcall(resumeSpeedwalk) + pcall(stopSpeedwalk) + end) + + it("stopSpeedwalk returns nil and a message when nothing is walking", function() + local ok, err = stopSpeedwalk() + assert.is_nil(ok) + assert.equals("stopSpeedwalk(): no active speedwalk found", err) + end) + + it("pauseSpeedwalk returns nil and a message when nothing is walking", function() + local ok, err = pauseSpeedwalk() + assert.is_nil(ok) + assert.equals("pauseSpeedwalk(): no active speedwalk found", err) + end) + + it("resumeSpeedwalk refuses to resume when there is no walklist", function() + local ok, err = resumeSpeedwalk() + assert.is_nil(ok) + assert.equals("resumeSpeedwalk(): attempted to resume a speedwalk but no active speedwalk found", err) + end) + + it("raises sysSpeedwalkStarted when a walk begins", function() + local started = false + local handler = registerAnonymousEventHandler("sysSpeedwalkStarted", function() started = true end) + finally(function() killAnonymousEventHandler(handler) end) + speedwalk("2n", false, 1) + assert.is_true(started) + end) + + it("raises sysSpeedwalkStopped when a running walk is stopped", function() + local stopped = false + local handler = registerAnonymousEventHandler("sysSpeedwalkStopped", function() stopped = true end) + finally(function() killAnonymousEventHandler(handler) end) + speedwalk("2n1e", false, 1) + assert.is_true(stopSpeedwalk()) + assert.is_true(stopped) + end) + + it("pause then resume raises the paused and resumed events, resuming dispatches the next step", function() + local paused, resumed = false, false + local hPause = registerAnonymousEventHandler("sysSpeedwalkPaused", function() paused = true end) + local hResume = registerAnonymousEventHandler("sysSpeedwalkResumed", function() resumed = true end) + finally(function() + killAnonymousEventHandler(hPause) + killAnonymousEventHandler(hResume) + end) + + speedwalk("2n1e", false, 1) + assert.is_true(pauseSpeedwalk()) + assert.is_true(paused) + + -- resuming sends the next queued step synchronously before re-scheduling + local send = spy.on(_G, "send") + finally(function() send:revert() end) + assert.is_true(resumeSpeedwalk()) + assert.is_true(resumed) + assert.spy(send).was.called(1) + end) + + it("pauseSpeedwalk a second time returns nil and a message", function() + speedwalk("2n1e", false, 1) + assert.is_true(pauseSpeedwalk()) + local ok, err = pauseSpeedwalk() + assert.is_nil(ok) + assert.equals("pauseSpeedwalk(): no active speedwalk found", err) + end) + + it("resumeSpeedwalk refuses to resume an already running speedwalk", function() + speedwalk("2n1e", false, 1) + local ok, err = resumeSpeedwalk() + assert.is_nil(ok) + assert.equals("resumeSpeedwalk(): attempted to resume an already running speedwalk", err) end) end) @@ -374,6 +441,13 @@ describe("Tests Other.lua functions", function() assert.is_false(_comp(true,false)) end) + it("compares tables holding false like tables holding any other value", function() + assert.is_true(_comp({ key = false }, { key = false })) + assert.is_false(_comp({ key = false }, { key = true })) + assert.is_false(_comp({ key = false }, {})) + assert.is_true(_comp({ outer = { inner = false } }, { outer = { inner = false } })) + end) + it("returns true if table B has the same value for every key which table A contains.", function() local tableA = { "One", "Two" } local tableB = { "One", "Two" } @@ -465,12 +539,16 @@ describe("Tests Other.lua functions", function() local lastLine = getCurrentLine() assert.equal("This line should not be deleted", lastLine) _G.multimatches = {} + s:revert() end) end) describe("Tests timeframe", function() teardown(function() + -- timeframe schedules a real cleanup tempTimer; cancel any pending ones + -- and clear the test variable. + killtimeframe("TIMEFRAME_TEST_VARIABLE") TIMEFRAME_TEST_VARIABLE = nil end) @@ -493,7 +571,391 @@ describe("Tests Other.lua functions", function() end) end) - --[[ + describe("Tests the stopwatch family", function() + -- Stopwatches are non-persistent by default, so they are not written to the + -- profile, but every stopwatch created here is deleted in teardown anyway. + -- A stopped stopwatch does not advance, so adjustStopWatch on one gives an + -- exact, deterministic elapsed time with no sleeping. Only the running case + -- (stopStopWatch below) needs a tolerance for wall-clock drift. + local createdIds = {} + + local function track(id) + table.insert(createdIds, id) + return id + end + + local function assertClose(expected, actual, tolerance) + tolerance = tolerance or 0.5 + assert.is_true(math.abs(expected - actual) <= tolerance, + string.format("expected roughly %s but got %s", tostring(expected), tostring(actual))) + end + + teardown(function() + for _, id in ipairs(createdIds) do + pcall(deleteStopWatch, id) + end + createdIds = {} + end) + + describe("createStopWatch", function() + it("returns a numeric id and autostarts by default", function() + local id = track(createStopWatch()) + assert.is_number(id) + local watches = getStopWatches() + assert.is_table(watches[id]) + assert.is_true(watches[id].isRunning) + end) + + it("does not autostart when given a name (string form)", function() + local id = track(createStopWatch("stopwatchSpecNamed")) + assert.is_number(id) + assert.is_false(getStopWatches()[id].isRunning) + assert.equals("stopwatchSpecNamed", getStopWatches()[id].name) + end) + + it("honours an explicit autostart boolean", function() + local id = track(createStopWatch(false)) + assert.is_false(getStopWatches()[id].isRunning) + end) + + it("errors on an unsupported first argument type", function() + assert.has_error(function() createStopWatch({}) end) + end) + + it("refuses to create a second stopwatch with an existing name", function() + track(createStopWatch("stopwatchSpecDuplicate")) + local ok, err = createStopWatch("stopwatchSpecDuplicate") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("getStopWatchTime and adjustStopWatch", function() + it("adjustStopWatch shifts the elapsed time of a stopped stopwatch deterministically", function() + local id = track(createStopWatch(false)) + -- adjusting an as-yet-unstarted stopwatch initialises it at that value + assert.is_true(adjustStopWatch(id, 12.5)) + assert.equals(12.5, getStopWatchTime(id)) + assert.is_true(adjustStopWatch(id, -2.5)) + assert.equals(10.0, getStopWatchTime(id)) + end) + + it("getStopWatchTime resolves a stopwatch by its name", function() + local id = track(createStopWatch("stopwatchSpecByName")) + adjustStopWatch(id, 5) + assert.equals(5, getStopWatchTime("stopwatchSpecByName")) + end) + + it("returns nil and a message for an unknown numeric id", function() + local ok, err = getStopWatchTime(999999) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("returns nil and a message for an unknown name", function() + local ok, err = getStopWatchTime("stopwatchSpecNoSuchName") + assert.is_nil(ok) + assert.is_string(err) + end) + + it("errors when the first argument is neither number nor string", function() + assert.has_error(function() getStopWatchTime({}) end) + end) + end) + + describe("start, stop and reset", function() + it("stopStopWatch returns the elapsed time and freezes it", function() + local id = track(createStopWatch(false)) + startStopWatch(id) + adjustStopWatch(id, 7) + local elapsed = stopStopWatch(id) + assertClose(7, elapsed) + assert.is_false(getStopWatches()[id].isRunning) + end) + + it("resetStopWatch zeroes a stopped, initialised stopwatch", function() + local id = track(createStopWatch(false)) + adjustStopWatch(id, 30) + assert.is_true(resetStopWatch(id)) + assert.equals(0, getStopWatchTime(id)) + end) + + it("startStopWatch on an unknown id returns nil and a message", function() + local ok, err = startStopWatch(888888) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("setStopWatchName", function() + it("renames a stopwatch identified by id", function() + local id = track(createStopWatch(false)) + assert.is_true(setStopWatchName(id, "stopwatchSpecRenamed")) + assert.equals("stopwatchSpecRenamed", getStopWatches()[id].name) + -- and it is now resolvable by the new name + assert.is_number(getStopWatchTime("stopwatchSpecRenamed")) + end) + + it("returns nil and a message when renaming an unknown id", function() + local ok, err = setStopWatchName(777777, "stopwatchSpecNope") + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("setStopWatchPersistence", function() + it("marks a stopwatch persistent and this is reflected in getStopWatches", function() + local id = track(createStopWatch(false)) + assert.is_false(getStopWatches()[id].isPersistent) + assert.is_true(setStopWatchPersistence(id, true)) + assert.is_true(getStopWatches()[id].isPersistent) + -- reset persistence so the stopwatch is never written to the profile + assert.is_true(setStopWatchPersistence(id, false)) + end) + + it("returns nil and a message for an unknown id", function() + local ok, err = setStopWatchPersistence(666666, true) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("getStopWatchBrokenDownTime", function() + it("returns a table broken down into days/hours/minutes/seconds", function() + local id = track(createStopWatch(false)) + -- 1 day, 2 hours, 3 minutes, 4 seconds + adjustStopWatch(id, 4 + 3*60 + 2*3600 + 1*86400) + local t = getStopWatchBrokenDownTime(id) + assert.is_table(t) + assert.equals(1, t.days) + assert.equals(2, t.hours) + assert.equals(3, t.minutes) + assert.equals(4, t.seconds) + assert.is_boolean(t.negative) + end) + + it("flags negative elapsed time with the negative field", function() + local id = track(createStopWatch(false)) + adjustStopWatch(id, -90) -- one minute thirty seconds in the past + local t = getStopWatchBrokenDownTime(id) + assert.is_true(t.negative) + assert.equals(1, t.minutes) + assert.equals(30, t.seconds) + end) + end) + + describe("deleteStopWatch", function() + it("removes a stopwatch so it can no longer be read back", function() + local id = createStopWatch(false) + adjustStopWatch(id, 1) + assert.is_table(getStopWatches()[id]) + assert.is_true(deleteStopWatch(id)) + assert.is_nil(getStopWatches()[id]) + local ok = getStopWatchTime(id) + assert.is_nil(ok) + end) + + it("returns nil and a message for an unknown id", function() + local ok, err = deleteStopWatch(555555) + assert.is_nil(ok) + assert.is_string(err) + end) + end) + + describe("getStopWatches", function() + it("reports name, running, persistent and elapsed time for each stopwatch", function() + local id = track(createStopWatch("stopwatchSpecReport")) + adjustStopWatch(id, 3) + local entry = getStopWatches()[id] + assert.equals("stopwatchSpecReport", entry.name) + assert.is_boolean(entry.isRunning) + assert.is_boolean(entry.isPersistent) + assert.is_table(entry.elapsedTime) + assert.equals(3, entry.elapsedTime.decimalSeconds) + end) + end) + end) + + describe("Tests getConfig and setConfig round-trips", function() + -- The supported option list is discovered from getConfig() at runtime rather + -- than hard-coded, so this adapts to whichever build runs it. Every value + -- changed here is restored (config is written to the profile on close). Keys + -- with lossy or multi-valued representations (showSentText, the string enums + -- and the numeric/table keys) are handled in dedicated tests below; map keys + -- (which need an open mapper to set) are exercised only when settable. + local originalValues = {} + -- show3dMapView is skipped because flipping it to true opens the 3D OpenGL + -- map view. Initialising the software GL stack under headless CI leaks + -- one-time allocations in a GL driver module that is unloaded before exit, + -- so leak detection flags it and it cannot be name-suppressed. The 3D view + -- is not what this config round-trip is meant to exercise. + local skipInGenericLoop = { showSentText = true, show3dMapView = true } + + local function snapshot(key) + if originalValues[key] == nil then + originalValues[key] = getConfig(key) + end + end + + local function restore(key) + if originalValues[key] ~= nil then + setConfig(key, originalValues[key]) + originalValues[key] = nil + end + end + + teardown(function() + -- safety net for anything a failing assertion left changed + for key, value in pairs(originalValues) do + pcall(setConfig, key, value) + end + originalValues = {} + end) + + it("returns a table of the current configuration when called with no arguments", function() + local cfg = getConfig() + assert.is_table(cfg) + assert.is_boolean(cfg.enableGMCP) + assert.is_boolean(cfg.editorAutoComplete) + end) + + it("round-trips every boolean configuration option", function() + local settable = 0 + for key, value in pairs(getConfig()) do + if type(value) == "boolean" and not skipInGenericLoop[key] then + snapshot(key) + local ok = setConfig(key, not value) + if ok then + assert.equals(not value, getConfig(key), "round-trip failed for boolean config key: " .. key) + settable = settable + 1 + else + -- not settable in this environment (e.g. a map option with the + -- mapper closed); the getter still returns a boolean + assert.is_boolean(getConfig(key), "expected boolean for config key: " .. key) + end + restore(key) + end + end + assert.is_true(settable > 0, "expected at least one settable boolean config option") + end) + + it("round-trips the showSentText enum without losing the mode", function() + -- getConfig(key, true) returns the string form; the boolean form collapses + -- 'always' and 'script' both onto true, so restore using the string form. + -- Register the string original in originalValues so the teardown safety net + -- can restore it if an assertion below fails partway through. + local original = getConfig("showSentText", true) + assert.is_string(original) + originalValues.showSentText = original + for _, mode in ipairs({"never", "always", "script"}) do + assert.is_true(setConfig("showSentText", mode)) + assert.equals(mode, getConfig("showSentText", true)) + end + -- the legacy boolean form reads false only for 'never' + assert.is_true(setConfig("showSentText", "never")) + assert.is_false(getConfig("showSentText")) + assert.is_true(setConfig("showSentText", "always")) + assert.is_true(getConfig("showSentText")) + setConfig("showSentText", original) + assert.equals(original, getConfig("showSentText", true)) + originalValues.showSentText = nil + end) + + it("round-trips the string enum options", function() + local enums = { + caretShortcut = {"none", "tab", "ctrltab", "f6"}, + blankLinesBehaviour = {"show", "hide", "replacewithspace"}, + controlCharacterHandling = {"asis", "oem", "picture"}, + ambiguousEAsianWidthCharacters = {"narrow", "wide", "auto"}, + } + local exercised = 0 + for key, values in pairs(enums) do + if getConfig(key) ~= nil then + snapshot(key) + for _, value in ipairs(values) do + assert.is_true(setConfig(key, value), "could not set " .. key .. " to " .. value) + assert.equals(value, getConfig(key)) + end + restore(key) + exercised = exercised + 1 + end + end + assert.is_true(exercised > 0, "expected at least one string enum config option") + end) + + it("errors on a wrongly typed value for a boolean option", function() + -- setConfig defers to getVerifiedBool, which raises rather than silently + -- coercing; the flag is never assigned so there is nothing to restore. + assert.has_error(function() setConfig("enableGMCP", "not a boolean") end) + end) + + it("rejects an invalid string for an enum option", function() + snapshot("caretShortcut") + local ok, err = setConfig("caretShortcut", "definitelyNotAValidShortcut") + assert.is_nil(ok) + assert.is_string(err) + restore("caretShortcut") + end) + + it("round-trips commandLineHistorySaveSize (numeric option)", function() + snapshot("commandLineHistorySaveSize") + assert.is_true(setConfig("commandLineHistorySaveSize", 42)) + assert.equals(42, getConfig("commandLineHistorySaveSize")) + restore("commandLineHistorySaveSize") + end) + + it("validates the undoServerWrapWidth range when the option exists", function() + if getConfig("undoServerWrapWidth") == nil then + -- option not present in this build; setting it is rejected as unknown + assert.is_nil((setConfig("undoServerWrapWidth", 42))) + return + end + snapshot("undoServerWrapWidth") + assert.is_true(setConfig("undoServerWrapWidth", 42)) + assert.equals(42, getConfig("undoServerWrapWidth")) + assert.is_nil((setConfig("undoServerWrapWidth", 10))) -- below the minimum of 20 + assert.is_nil((setConfig("undoServerWrapWidth", 600))) -- above the maximum of 500 + restore("undoServerWrapWidth") + end) + + it("returns nil and a message for an unknown key", function() + local value, message = getConfig("totallyBogusConfigKey") + assert.is_nil(value) + assert.is_string(message) + end) + + it("setConfig returns nil and a message for an unknown key", function() + local ok, message = setConfig("totallyBogusConfigKey", true) + assert.is_nil(ok) + assert.is_string(message) + end) + + it("getConfig and setConfig reject an empty key", function() + assert.is_nil((getConfig(""))) + assert.is_nil((setConfig("", true))) + end) + + it("setConfig applies a table of options in one call", function() + snapshot("enableGMCP") + snapshot("editorAutoComplete") + local target = not getConfig("enableGMCP") + local target2 = not getConfig("editorAutoComplete") + setConfig({ enableGMCP = target, editorAutoComplete = target2 }) + assert.equals(target, getConfig("enableGMCP")) + assert.equals(target2, getConfig("editorAutoComplete")) + restore("enableGMCP") + restore("editorAutoComplete") + end) + + it("getConfig returns a keyed table when given a list of keys", function() + local result = getConfig({ "enableGMCP", "editorAutoComplete" }) + assert.is_table(result) + assert.equals(getConfig("enableGMCP"), result.enableGMCP) + assert.equals(getConfig("editorAutoComplete"), result.editorAutoComplete) + end) + end) + + --[[ TODO: remember() loadVars() @@ -509,8 +971,983 @@ describe("Tests Other.lua functions", function() registerAnonymousEventHandler() killAnonymousEventHandler() dispatchEventToFunctions() - timeframe() killtimeframe() translateTable() ]] end) + +describe("Tests the timer API", function() + -- These drive the real timer engine: every effect assertion is made after the + -- timer has actually fired, observed through the waitForEvent test helper which + -- pumps the Qt event loop rather than sleeping. + + -- Temporary timers are killed and permanent ones disabled after every spec, so + -- no timer created here can still fire during a later spec, even if one of the + -- assertions above the clean-up code fails. + -- + -- Permanent timers cannot be deleted, only disabled, and the profile is written + -- out when Mudlet closes: running the suite twice against the same profile + -- starts the second run with the first run's (disabled) permanent timers still + -- in place. Specs below therefore count relative to what already exists rather + -- than assuming the timer they just made is the only one of its name. + local temporaryTimerIds = {} + local permanentTimerNames = {} + local settleCounter = 0 + + local function trackTemp(id) + if type(id) == "number" and id > 0 then + table.insert(temporaryTimerIds, id) + end + return id + end + + local function trackPerm(name) + table.insert(permanentTimerNames, name) + return name + end + + -- Waits for one of this block's own events, reporting the timeout message + -- instead of a bare nil when the event never arrives. + local function waitFor(eventName) + local name, message = waitForEvent(eventName, 5000) + assert.equals(eventName, name, "waiting for " .. eventName .. ": " .. tostring(message)) + return name + end + + -- Returns once `seconds` of real time have passed, by waiting for an event + -- raised from a real timer. Lua runs to completion between event loop turns, so + -- the timer cannot have fired before the wait below is in place. Each call gets + -- its own event name so that a settling timer which outlived a failing spec can + -- never satisfy a later one. + local function settle(seconds) + settleCounter = settleCounter + 1 + local eventName = "w2aTimerSpecSettled" .. settleCounter + trackTemp(tempTimer(seconds, function() raiseEvent(eventName) end)) + waitFor(eventName) + end + + before_each(function() + _G.W2aTimerSpec = {fired = 0, order = {}} + end) + + after_each(function() + for _, id in ipairs(temporaryTimerIds) do + pcall(killTimer, id) + end + temporaryTimerIds = {} + for _, name in ipairs(permanentTimerNames) do + pcall(disableTimer, name) + end + permanentTimerNames = {} + _G.W2aTimerSpec = nil + _G.W2aPermTimerFires = nil + end) + + describe("Tests the functionality of tempTimer", function() + it("errors when called without a delay", function() + assert.has_error(function() tempTimer() end) + end) + + it("errors when the delay is not a number", function() + assert.has_error(function() tempTimer({}, [[]]) end) + end) + + it("errors when the body is neither a string nor a function", function() + assert.has_error(function() tempTimer(0.1, {}) end) + end) + + it("returns -1 and a message when the code does not compile", function() + -- the delay is distinctive, and the compiled chunk is named after the + -- timer's numeric id, so nothing but a leak can put it in the message + local id, message = tempTimer(0.125, "this is ( not lua") + assert.equals(-1, id) + assert.is_string(message, "the failure should come with a message") + assert.is_truthy(message:find("compile", 1, true), + "the failure should say the code could not be compiled, got: " .. tostring(message)) + -- and the reason has to be what Lua said about the code + assert.is_truthy(message:find("near", 1, true), + "the reason should be the Lua error, got: " .. tostring(message)) + assert.is_falsy(message:find("0.125", 1, true), + "the delay must not be reported as the Lua error, got: " .. tostring(message)) + end) + + it("errors for a negative delay", function() + -- an unguarded negative delay wraps around the 24 hour clock into a timer + -- that fires almost a day later, so it has to be rejected outright + assert.has_error(function() tempTimer(-1, [[]]) end) + assert.has_error(function() tempTimer(-1, function() end) end) + end) + + it("errors for a delay of a day or more, which wraps around to zero", function() + assert.has_error(function() tempTimer(86400, [[]]) end) + end) + + it("errors for a delay that only rounds up onto the day", function() + -- the delay becomes the interval through qRound(time * 1000), so a delay + -- of under 86400 seconds can still reach 86400000ms and wrap around to no + -- interval at all: it is the rounded milliseconds that have to be bounded + local ok, err = pcall(tempTimer, 86399.9999, [[]]) + assert.is_false(ok, + "a delay rounding up to a whole day wraps to a zero interval and must be rejected") + assert.is_truthy(tostring(err):find("bad argument #1", 1, true), + "the delay should be reported as the offending argument, got: " .. tostring(err)) + local id = trackTemp(tempTimer(86399.4, [[]])) + assert.is_true(id > 0, "a delay under the day once rounded should still be accepted") + assert.is_true(killTimer(id)) + end) + + it("fires a code-string body in the global environment", function() + trackTemp(tempTimer(0.05, [[ + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + raiseEvent("w2aTempTimerFired") + ]])) + waitFor("w2aTempTimerFired") + assert.equals(1, _G.W2aTimerSpec.fired) + end) + + it("fires a function body", function() + trackTemp(tempTimer(0.05, function() + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + raiseEvent("w2aTempTimerFunctionFired") + end)) + waitFor("w2aTempTimerFunctionFired") + assert.equals(1, _G.W2aTimerSpec.fired) + end) + + it("fires a zero delay timer", function() + trackTemp(tempTimer(0, function() raiseEvent("w2aZeroDelayTimerFired") end)) + waitFor("w2aZeroDelayTimerFired") + end) + + it("does not repeat by default", function() + -- a repeating 50ms timer would have fired several times over the 150ms below + trackTemp(tempTimer(0.05, function() + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + end)) + settle(0.15) + assert.equals(1, _G.W2aTimerSpec.fired) + end) + + it("errors when the repeating argument is not a boolean", function() + assert.has_error(function() tempTimer(0.1, [[]], "w2aNotABoolean") end) + end) + + it("repeats until killed when the repeating argument is true", function() + local id = trackTemp(tempTimer(0.02, [[ + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + raiseEvent("w2aRepeatingTimerFired") + ]], true)) + assert.is_true(id > 0) + -- three separate waits, so each firing has to happen for itself + waitFor("w2aRepeatingTimerFired") + waitFor("w2aRepeatingTimerFired") + waitFor("w2aRepeatingTimerFired") + assert.is_true(killTimer(id)) + assert.is_true(_G.W2aTimerSpec.fired >= 3, + "a repeating timer should fire more than once, fired " .. tostring(_G.W2aTimerSpec.fired) .. " times") + end) + + it("runs a timer scheduled from inside another timer", function() + trackTemp(tempTimer(0, function() + table.insert(_G.W2aTimerSpec.order, "outer") + trackTemp(tempTimer(0, function() + table.insert(_G.W2aTimerSpec.order, "inner") + raiseEvent("w2aNestedTimerFired") + end)) + end)) + waitFor("w2aNestedTimerFired") + assert.are.same({"outer", "inner"}, _G.W2aTimerSpec.order) + end) + end) + + describe("Tests the functionality of killTimer", function() + -- killTimer looks its argument up by name; temporary timers are simply named + -- after the id it returns, which is why passing that id works. + it("errors when called without an argument", function() + assert.has_error(function() killTimer() end) + end) + + it("returns false when nothing of that name exists", function() + assert.is_false(killTimer("w2aNoSuchTimerName")) + end) + + it("returns false for a permanent timer, which cannot be killed", function() + local before = exists("W2aPermTimerUnkillable", "timer") + assert.is_true(permTimer(trackPerm("W2aPermTimerUnkillable"), "", 30, [[]]) > 0) + assert.equals(before + 1, exists("W2aPermTimerUnkillable", "timer")) + assert.is_false(killTimer("W2aPermTimerUnkillable")) + assert.equals(before + 1, exists("W2aPermTimerUnkillable", "timer"), + "a permanent timer survives killTimer") + end) + + it("returns false the second time, as the timer is already dead", function() + local id = trackTemp(tempTimer(10, [[]])) + assert.is_true(killTimer(id)) + assert.is_false(killTimer(id), + "killing an already killed timer achieves nothing and has to say so") + -- the object itself is only freed by the timer unit's deferred cleanup, + -- so check the state a user can see straight away instead + assert.equals(0, isActive(id, "timer"), "a killed timer is no longer active") + local left, message = remainingTime(id) + assert.is_nil(left, "a killed timer is no longer counting down") + -- "inactive" rather than "not a valid timerID" pins that the timer is + -- still present and merely stopped, which is what the second kill saw + assert.is_truthy(tostring(message):find("inactive", 1, true), + "the killed timer should still be present but stopped, got: " .. tostring(message)) + end) + + it("returns false for a one-shot timer that has already fired", function() + -- a fired one-shot temporary timer is queued for the same deferred cleanup + -- a killed one is, so it is just as dead - which is what the manual has + -- always said killTimer reports + local id = trackTemp(tempTimer(0, function() raiseEvent("w2aOneShotFinished") end)) + waitFor("w2aOneShotFinished") + assert.is_false(killTimer(id), + "a one-shot timer that has already fired cannot be killed again") + end) + + it("stops a pending timer from ever firing and deactivates it", function() + local id = trackTemp(tempTimer(0.05, function() + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + end)) + assert.equals(1, isActive(id, "timer")) + assert.is_true(killTimer(id)) + -- the killed timer object itself is only freed by the timer unit's deferred + -- cleanup, so check the state a user can see straight away instead + assert.equals(0, isActive(id, "timer"), "a killed timer is no longer active") + assert.is_nil((remainingTime(id)), "a killed timer is no longer counting down") + settle(0.15) + assert.equals(0, _G.W2aTimerSpec.fired, "a killed timer must never fire") + end) + + it("stops a repeating timer", function() + local id = trackTemp(tempTimer(0.02, function() + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + if _G.W2aTimerSpec.fired == 2 then raiseEvent("w2aRepeatingTimerToKill") end + end, true)) + waitFor("w2aRepeatingTimerToKill") + assert.is_true(killTimer(id)) + local firedWhenKilled = _G.W2aTimerSpec.fired + settle(0.15) + assert.equals(firedWhenKilled, _G.W2aTimerSpec.fired, + "a killed repeating timer must not fire again") + end) + + it("kills a repeating timer from inside its own callback", function() + -- killing a timer while its own body is running is the deferred-delete + -- path: the timer unit may only free the object once the callback has + -- returned, but the kill still has to stop it firing again + local id + id = trackTemp(tempTimer(0.02, function() + _G.W2aTimerSpec.fired = _G.W2aTimerSpec.fired + 1 + _G.W2aTimerSpec.killed = killTimer(id) + _G.W2aTimerSpec.killedAgain = killTimer(id) + raiseEvent("w2aSelfKillingTimerFired") + end, true)) + waitFor("w2aSelfKillingTimerFired") + assert.is_true(_G.W2aTimerSpec.killed, + "killTimer should report success from inside the timer's own callback") + assert.is_false(_G.W2aTimerSpec.killedAgain, + "killing the same timer twice from inside its own callback must fail the second time") + local firedWhenKilled = _G.W2aTimerSpec.fired + settle(0.15) + assert.equals(firedWhenKilled, _G.W2aTimerSpec.fired, + "a self-killed timer must not fire again") + end) + end) + + describe("Tests the functionality of remainingTime", function() + it("errors when given something that is neither a number nor a string", function() + assert.has_error(function() remainingTime({}) end) + end) + + it("returns nil and a message for a number that is not a timer id", function() + local left, message = remainingTime(999999) + assert.is_nil(left) + assert.is_string(message) + assert.is_truthy(message:find("999999", 1, true)) + end) + + it("returns nil and a message for a name that is not a timer", function() + local left, message = remainingTime("w2aNoSuchTimerName") + assert.is_nil(left) + assert.is_string(message) + assert.is_truthy(message:find("w2aNoSuchTimerName", 1, true)) + end) + + it("returns nil and a message for an inactive timer", function() + -- permanent timers are created inactive, so their QTimer is not running + assert.is_true(permTimer(trackPerm("W2aPermTimerIdle"), "", 30, [[]]) > 0) + local left, message = remainingTime("W2aPermTimerIdle") + assert.is_nil(left) + assert.is_string(message) + assert.is_truthy(message:find("inactive", 1, true)) + end) + + it("reports the time left on a pending temporary timer in seconds", function() + local id = trackTemp(tempTimer(10, [[]])) + local left = remainingTime(id) + assert.is_number(left) + assert.is_true(left > 9 and left <= 10, + "a 10 second timer should have just under 10 seconds left, got " .. tostring(left)) + end) + + it("resolves a running temporary timer by its name, which is its id", function() + local id = trackTemp(tempTimer(10, [[]])) + local left = remainingTime(tostring(id)) + assert.is_number(left) + assert.is_true(left > 9 and left <= 10, + "a 10 second timer should have just under 10 seconds left, got " .. tostring(left)) + end) + + it("resolves a running permanent timer by name", function() + assert.is_true(permTimer(trackPerm("W2aPermTimerCountdown"), "", 30, [[]]) > 0) + assert.is_true(enableTimer("W2aPermTimerCountdown")) + local left = remainingTime("W2aPermTimerCountdown") + assert.is_number(left) + assert.is_true(left > 29 and left <= 30, + "a 30 second timer should have just under 30 seconds left, got " .. tostring(left)) + end) + end) + + describe("Tests the functionality of permTimer with enableTimer and disableTimer", function() + it("errors when the parent group does not exist", function() + assert.has_error(function() + permTimer("W2aPermTimerOrphan", "w2aNoSuchTimerGroup", 1, [[]]) + end) + end) + + it("errors when the code does not compile", function() + local ok, err = pcall(permTimer, trackPerm("W2aPermTimerBadCode"), "", 1, "this is ( not lua") + assert.is_false(ok, "code that does not parse must not create a timer") + assert.is_truthy(tostring(err):find("near", 1, true), + "the reason should be the Lua error, not the timer's name, got: " .. tostring(err)) + end) + + it("errors when the interval is missing", function() + assert.has_error(function() permTimer("W2aPermTimerNoInterval", "") end) + end) + + it("errors for a negative interval, creating nothing", function() + -- counted rather than compared with zero: permanent timers survive into a + -- second run of the suite against the same profile + local before = exists("W2aPermTimerNegative", "timer") + local ok, err = pcall(permTimer, trackPerm("W2aPermTimerNegative"), "", -1, [[]]) + assert.is_false(ok, + "a negative interval must be rejected rather than wrapped around the 24 hour clock") + assert.equals(before, exists("W2aPermTimerNegative", "timer"), + "a rejected interval must not leave a timer behind") + assert.is_truthy(tostring(err):find("bad argument #3", 1, true), + "the interval should be reported as the offending argument, got: " .. tostring(err)) + end) + + it("errors for an interval that only rounds up onto the day, creating nothing", function() + -- as with tempTimer, it is the rounded milliseconds that wrap: 86399.9999 + -- seconds is under the day but reaches it once rounded + local before = exists("W2aPermTimerRounding", "timer") + local ok, err = pcall(permTimer, trackPerm("W2aPermTimerRounding"), "", 86399.9999, [[]]) + assert.is_false(ok, + "an interval rounding up to a whole day wraps to a zero interval and must be rejected") + assert.equals(before, exists("W2aPermTimerRounding", "timer"), + "a rejected interval must not leave a timer behind") + assert.is_truthy(tostring(err):find("bad argument #3", 1, true), + "the interval should be reported as the offending argument, got: " .. tostring(err)) + end) + + it("enableTimer and disableTimer error when called without a name", function() + assert.has_error(function() enableTimer() end) + assert.has_error(function() disableTimer() end) + end) + + it("reports whether a timer of that name was found", function() + assert.is_true(permTimer(trackPerm("W2aPermTimerToggle"), "", 30, [[]]) > 0) + assert.is_true(enableTimer("W2aPermTimerToggle")) + assert.is_true(disableTimer("W2aPermTimerToggle")) + assert.is_false(enableTimer("w2aNoSuchTimerName")) + assert.is_false(disableTimer("w2aNoSuchTimerName")) + end) + + it("does not fire while it is disabled", function() + -- permanent timers start out inactive and must be enabled before they run + assert.is_true(permTimer(trackPerm("W2aPermTimerDisabled"), "", 0.05, + [[_G.W2aPermTimerFires = (_G.W2aPermTimerFires or 0) + 1]]) > 0) + assert.equals(0, isActive("W2aPermTimerDisabled", "timer")) + settle(0.15) + assert.is_nil(_G.W2aPermTimerFires, "a disabled permanent timer must not fire") + end) + + it("fires once enabled", function() + assert.is_true(permTimer(trackPerm("W2aPermTimerEnabled"), "", 0.05, [[ + _G.W2aPermTimerFires = (_G.W2aPermTimerFires or 0) + 1 + raiseEvent("w2aPermTimerFired") + ]]) > 0) + assert.is_true(enableTimer("W2aPermTimerEnabled")) + waitFor("w2aPermTimerFired") + disableTimer("W2aPermTimerEnabled") + assert.is_true((_G.W2aPermTimerFires or 0) >= 1) + end) + + it("stops firing again once disabled", function() + assert.is_true(permTimer(trackPerm("W2aPermTimerStopped"), "", 0.05, [[ + _G.W2aPermTimerFires = (_G.W2aPermTimerFires or 0) + 1 + raiseEvent("w2aPermTimerStoppedFired") + ]]) > 0) + assert.is_true(enableTimer("W2aPermTimerStopped")) + waitFor("w2aPermTimerStoppedFired") + assert.is_true(disableTimer("W2aPermTimerStopped")) + local firedWhenDisabled = _G.W2aPermTimerFires + settle(0.15) + assert.equals(firedWhenDisabled, _G.W2aPermTimerFires, + "a disabled permanent timer must not fire again") + end) + end) + + describe("Tests exists and isActive for timers", function() + it("both return nil and a message for an unknown item type", function() + -- exists lowercases the type before quoting it back, so use a type that is + -- lowercase to begin with and both messages can be checked the same way + local existing, existsMessage = exists("W2aWhatever", "w2anotanitemtype") + assert.is_nil(existing) + assert.is_string(existsMessage) + assert.is_truthy(existsMessage:find("w2anotanitemtype", 1, true)) + local active, isActiveMessage = isActive("W2aWhatever", "w2anotanitemtype") + assert.is_nil(active) + assert.is_string(isActiveMessage) + assert.is_truthy(isActiveMessage:find("w2anotanitemtype", 1, true)) + end) + + it("both return nil and a message for a negative id", function() + local existing, existsMessage = exists(-1, "timer") + assert.is_nil(existing) + assert.is_string(existsMessage) + assert.is_truthy(existsMessage:find("-1", 1, true)) + local active, isActiveMessage = isActive(-1, "timer") + assert.is_nil(active) + assert.is_string(isActiveMessage) + assert.is_truthy(isActiveMessage:find("-1", 1, true)) + end) + + it("exists counts a temporary timer by id and by name", function() + local id = trackTemp(tempTimer(10, [[]])) + -- a temporary timer is named after its own id + assert.equals(1, exists(id, "timer")) + assert.equals(1, exists(tostring(id), "timer")) + assert.equals(0, exists(id + 100000, "timer")) + assert.equals(0, exists("w2aNoSuchTimerName", "timer")) + end) + + it("exists counts every permanent timer sharing a name", function() + local before = exists("W2aPermTimerTwins", "timer") + assert.is_true(permTimer(trackPerm("W2aPermTimerTwins"), "", 30, [[]]) > 0) + assert.equals(before + 1, exists("W2aPermTimerTwins", "timer")) + assert.is_true(permTimer(trackPerm("W2aPermTimerTwins"), "", 30, [[]]) > 0) + assert.equals(before + 2, exists("W2aPermTimerTwins", "timer")) + end) + + it("isActive follows the enabled state of a permanent timer", function() + assert.is_true(permTimer(trackPerm("W2aPermTimerActivity"), "", 30, [[]]) > 0) + -- enabling and disabling by name acts on every timer of that name + local named = exists("W2aPermTimerActivity", "timer") + assert.equals(0, isActive("W2aPermTimerActivity", "timer"), + "a newly created permanent timer is inactive") + assert.is_true(enableTimer("W2aPermTimerActivity")) + assert.equals(named, isActive("W2aPermTimerActivity", "timer")) + assert.is_true(disableTimer("W2aPermTimerActivity")) + assert.equals(0, isActive("W2aPermTimerActivity", "timer")) + end) + + it("isActive only reports a timer inside a disabled group as active when ancestors are not checked", function() + -- a permTimer with no interval and no code is a group/folder + assert.is_true(permTimer(trackPerm("W2aTimerGroup"), "", 0, "") > 0) + assert.is_true(permTimer(trackPerm("W2aTimerInGroup"), "W2aTimerGroup", 30, + [[_G.W2aPermTimerFires = (_G.W2aPermTimerFires or 0) + 1]]) > 0) + local named = exists("W2aTimerInGroup", "timer") + assert.is_true(enableTimer("W2aTimerInGroup")) + assert.equals(named, isActive("W2aTimerInGroup", "timer")) + assert.equals(0, isActive("W2aTimerInGroup", "timer", true), + "the enclosing group is still disabled") + -- and the flag is not the whole story: a timer whose group is disabled is + -- not counting down, whatever isActive says without checkAncestors + assert.is_nil((remainingTime("W2aTimerInGroup")), + "a timer in a disabled group should not be running") + assert.is_true(enableTimer("W2aTimerGroup")) + assert.equals(named, isActive("W2aTimerInGroup", "timer", true)) + assert.is_number(remainingTime("W2aTimerInGroup"), + "enabling the group should start the timers inside it") + end) + end) +end) + +describe("Tests the script API", function() + -- Permanent scripts cannot be removed from Lua, only blanked and disabled, and + -- the profile is written out when Mudlet closes: running the suite twice against + -- the same profile starts the second run with the first run's (empty, inactive) + -- scripts still present. Specs below therefore work out the position of the + -- script they just created instead of assuming it is the first of its name, and + -- count relative to what was already there. Script bodies create their own table + -- rather than assuming one exists, so a body that is recompiled later - when a + -- saved profile is loaded again, say - can never raise. + local createdScriptNames = {} + + -- Creates a permanent script, returning its id and its position among the + -- scripts of that name, which is what getScript and setScript index by. New + -- scripts get the highest id, so they come last. + local function makeScript(name, parent, code) + table.insert(createdScriptNames, name) + local position = exists(name, "script") + 1 + return permScript(name, parent, code), position + end + + before_each(function() + _G.W2aScriptSpec = {} + end) + + teardown(function() + for _, name in ipairs(createdScriptNames) do + pcall(disableScript, name) + -- blank every script of that name, duplicates included, so that nothing + -- created here can run again if the profile is saved and reloaded + for position = 1, exists(name, "script") do + pcall(setScript, name, "", position) + end + end + createdScriptNames = {} + _G.W2aScriptSpec = nil + end) + + describe("Tests the functionality of permScript", function() + it("errors when the name is missing", function() + assert.has_error(function() permScript() end) + end) + + it("errors when the parent group does not exist", function() + assert.has_error(function() + permScript("W2aScriptOrphan", "w2aNoSuchScriptGroup", [[]]) + end) + end) + + it("errors when the code does not parse", function() + assert.has_error(function() + makeScript("W2aScriptBadCode", "", "this is ( not lua") + end) + end) + + it("creates nothing when the body parses but raises when it is run", function() + -- a script's body runs as it is compiled, so a body that raises fails + -- creation just like one that does not parse + local before = exists("W2aScriptRaises", "script") + -- the failure quotes the code it was given, so the message is built at run + -- time: finding it whole proves the Lua error was reported, not the code + -- that was handed in and not the script's name + local ok, err = pcall(makeScript, "W2aScriptRaises", "", [[error("w2a script" .. " boom")]]) + assert.is_false(ok, "a body that raises must not create a script") + assert.equals(before, exists("W2aScriptRaises", "script")) + assert.is_truthy(tostring(err):find("w2a script boom", 1, true), + "permScript should report the Lua error, got: " .. tostring(err)) + end) + + it("reports the type when the body raises something other than a string", function() + -- the error object is not a string, so there is no message to quote - the + -- reason still has to say what came back rather than name the script + local before = exists("W2aScriptObjectError", "script") + local ok, err = pcall(makeScript, "W2aScriptObjectError", "", [[error({w2a = true})]]) + assert.is_false(ok, "a body that raises must not create a script") + assert.equals(before, exists("W2aScriptObjectError", "script")) + assert.is_truthy(tostring(err):find("error object is a table", 1, true), + "the reason should describe the error object, got: " .. tostring(err)) + end) + + it("creates a script whose body runs immediately", function() + local before = exists("W2aScriptCreated", "script") + local id = makeScript("W2aScriptCreated", "", [[ + _G.W2aScriptSpec = _G.W2aScriptSpec or {} + _G.W2aScriptSpec.created = true + ]]) + assert.is_number(id) + assert.is_true(id > 0) + assert.is_true(_G.W2aScriptSpec.created, "a script's body runs when it is compiled") + assert.equals(before + 1, exists("W2aScriptCreated", "script")) + end) + + it("creates a script inside a group", function() + -- a permScript with no code is a group/folder + assert.is_true(makeScript("W2aScriptGroup", "", "") > 0) + assert.is_true(makeScript("W2aScriptInGroup", "W2aScriptGroup", [[ + _G.W2aScriptSpec = _G.W2aScriptSpec or {} + _G.W2aScriptSpec.inGroup = true + ]]) > 0) + assert.is_true(_G.W2aScriptSpec.inGroup) + local named = exists("W2aScriptInGroup", "script") + assert.is_true(enableScript("W2aScriptInGroup")) + assert.equals(named, isActive("W2aScriptInGroup", "script")) + assert.equals(0, isActive("W2aScriptInGroup", "script", true), + "the enclosing group is still disabled") + assert.is_true(enableScript("W2aScriptGroup")) + assert.equals(named, isActive("W2aScriptInGroup", "script", true)) + end) + end) + + describe("Tests the functionality of getScript", function() + it("errors when called without a name", function() + assert.has_error(function() getScript() end) + end) + + it("returns -1 and a message for a script that does not exist", function() + local code, message = getScript("w2aNoSuchScriptName") + assert.equals(-1, code) + assert.is_string(message) + assert.is_truthy(message:find("w2aNoSuchScriptName", 1, true)) + end) + + it("returns -1 and a message for a position that does not exist", function() + local _, position = makeScript("W2aScriptOnePosition", "", [[local w2aOnly = 1]]) + local beyondTheLast = position + 1 + local code, message = getScript("W2aScriptOnePosition", beyondTheLast) + assert.equals(-1, code) + assert.is_string(message) + assert.is_truthy(message:find("position " .. beyondTheLast, 1, true)) + end) + + it("returns -1 and a message for position zero, as positions start at one", function() + makeScript("W2aScriptPositionZero", "", [[local w2aOnly = 1]]) + local code, message = getScript("W2aScriptPositionZero", 0) + assert.equals(-1, code) + assert.is_string(message) + assert.is_truthy(message:find("position 0", 1, true)) + end) + + it("returns the code and the id of the script", function() + local body = [[local w2aReadBack = "getScript round trip"]] + local id, position = makeScript("W2aScriptReadBack", "", body) + local code, readId = getScript("W2aScriptReadBack", position) + assert.equals(body, code) + assert.equals(id, readId) + end) + + it("reads the script at the requested position when several share a name", function() + local firstId, firstPosition = makeScript("W2aScriptDuplicate", "", [[local w2aFirst = 1]]) + local secondId, secondPosition = makeScript("W2aScriptDuplicate", "", [[local w2aSecond = 2]]) + assert.equals(firstPosition + 1, secondPosition) + local firstCode, firstReadId = getScript("W2aScriptDuplicate", firstPosition) + local secondCode, secondReadId = getScript("W2aScriptDuplicate", secondPosition) + assert.equals([[local w2aFirst = 1]], firstCode) + assert.equals(firstId, firstReadId) + assert.equals([[local w2aSecond = 2]], secondCode) + assert.equals(secondId, secondReadId) + end) + end) + + describe("Tests the functionality of setScript", function() + it("errors for a script name that does not exist", function() + assert.has_error(function() setScript("w2aNoSuchScriptName", [[]]) end) + end) + + it("errors for an empty name", function() + assert.has_error(function() setScript("", [[]]) end) + end) + + it("errors for position zero, as positions start at one", function() + makeScript("W2aScriptSetPositionZero", "", [[local w2aOnly = 1]]) + assert.has_error(function() + setScript("W2aScriptSetPositionZero", [[local w2aChanged = 1]], 0) + end) + end) + + it("errors when the code is not a string", function() + local _, position = makeScript("W2aScriptBadNewCode", "", [[local w2aOriginal = 1]]) + assert.has_error(function() setScript("W2aScriptBadNewCode", {}, position) end) + end) + + it("replaces the code, returns the id and runs the new body", function() + local id, position = makeScript("W2aScriptReplaced", "", [[local w2aOriginal = 1]]) + local newBody = [[ + _G.W2aScriptSpec = _G.W2aScriptSpec or {} + _G.W2aScriptSpec.replaced = true + ]] + assert.equals(id, setScript("W2aScriptReplaced", newBody, position)) + assert.equals(newBody, (getScript("W2aScriptReplaced", position))) + assert.is_true(_G.W2aScriptSpec.replaced, "the replacement body should have run") + end) + + it("rejects code that does not parse before touching the script", function() + -- this one never reaches the script: setScript syntax checks its code + -- argument first + local body = [[local w2aKept = 1]] + local _, position = makeScript("W2aScriptKeptCode", "", body) + assert.has_error(function() setScript("W2aScriptKeptCode", "this is ( not lua", position) end) + assert.equals(body, (getScript("W2aScriptKeptCode", position))) + end) + + it("puts the previous code back when the new body raises as it is run", function() + -- code that parses gets past the argument check and is then run as it is + -- compiled into the script, so this is the path that has to roll back + local body = [[local w2aRolledBack = 1]] + local _, position = makeScript("W2aScriptRollback", "", body) + -- as in the permScript spec above, the raised message is assembled at run + -- time so that only the real Lua error can contain it + local ok, err = pcall(setScript, "W2aScriptRollback", [[error("w2a setScript" .. " boom")]], position) + assert.is_false(ok, "a body that raises must not be kept") + assert.equals(body, (getScript("W2aScriptRollback", position))) + assert.is_truthy(tostring(err):find("w2a setScript boom", 1, true), + "setScript should report the Lua error, got: " .. tostring(err)) + end) + + it("sets the script at the requested position when several share a name", function() + local _, firstPosition = makeScript("W2aScriptSetPosition", "", [[local w2aFirst = 1]]) + local secondId, secondPosition = makeScript("W2aScriptSetPosition", "", [[local w2aSecond = 2]]) + assert.equals(secondId, setScript("W2aScriptSetPosition", [[local w2aSecondChanged = 2]], secondPosition)) + assert.equals([[local w2aFirst = 1]], (getScript("W2aScriptSetPosition", firstPosition))) + assert.equals([[local w2aSecondChanged = 2]], (getScript("W2aScriptSetPosition", secondPosition))) + end) + end) + + describe("Tests the functionality of appendScript", function() + it("errors when the name is not a string", function() + assert.has_error(function() appendScript(42, [[]]) end, + "appendScript: bad argument #1 type (script name as string expected, got number!)") + end) + + it("errors when the code is not a string", function() + assert.has_error(function() appendScript("W2aScriptAppended", 42) end, + "appendScript: bad argument #2 type (lua code as string expected, got number!)") + end) + + it("errors instead of creating anything when the script does not exist", function() + -- appendScript does not check getScript's -1 sentinel, so what actually + -- reports the missing script is the setScript underneath it; either way + -- nothing may be created + assert.has_error(function() appendScript("w2aNoSuchScriptName", [[local w2aNew = 1]]) end) + assert.equals(0, exists("w2aNoSuchScriptName", "script")) + end) + + it("adds the new code on a line of its own after the existing code", function() + local body = [[local w2aOriginal = 1]] + local _, position = makeScript("W2aScriptAppended", "", body) + appendScript("W2aScriptAppended", [[local w2aAppended = 2]], position) + assert.equals(body .. "\n" .. [[local w2aAppended = 2]], + (getScript("W2aScriptAppended", position))) + end) + + it("appends to the first script of that name when no position is given", function() + makeScript("W2aScriptAppendDefault", "", [[local w2aOriginal = 1]]) + -- whatever is at position 1 is what the default has to append to + local firstBefore = (getScript("W2aScriptAppendDefault", 1)) + assert.is_string(firstBefore) + appendScript("W2aScriptAppendDefault", [[local w2aDefaultAppended = 2]]) + assert.equals(firstBefore .. "\n" .. [[local w2aDefaultAppended = 2]], + (getScript("W2aScriptAppendDefault", 1))) + end) + + it("runs the appended code", function() + local _, position = makeScript("W2aScriptAppendRuns", "", [[local w2aOriginal = 1]]) + appendScript("W2aScriptAppendRuns", [[ + _G.W2aScriptSpec = _G.W2aScriptSpec or {} + _G.W2aScriptSpec.appended = true + ]], position) + assert.is_true(_G.W2aScriptSpec.appended) + end) + end) + + describe("Tests the functionality of enableScript and disableScript", function() + it("error when called without a name", function() + assert.has_error(function() enableScript() end) + assert.has_error(function() disableScript() end) + end) + + it("return nil and a message when no script of that name exists", function() + local enabled, enableMessage = enableScript("w2aNoSuchScriptName") + assert.is_nil(enabled) + assert.is_string(enableMessage) + assert.is_truthy(enableMessage:find("w2aNoSuchScriptName", 1, true)) + local disabled, disableMessage = disableScript("w2aNoSuchScriptName") + assert.is_nil(disabled) + assert.is_string(disableMessage) + assert.is_truthy(disableMessage:find("w2aNoSuchScriptName", 1, true)) + end) + + it("toggle the active state a script reports through isActive", function() + assert.is_true(makeScript("W2aScriptToggled", "", [[local w2aOnly = 1]]) > 0) + -- enabling and disabling by name acts on every script of that name + local named = exists("W2aScriptToggled", "script") + assert.equals(0, isActive("W2aScriptToggled", "script"), + "a newly created script is inactive") + assert.is_true(enableScript("W2aScriptToggled")) + assert.equals(named, isActive("W2aScriptToggled", "script")) + assert.is_true(disableScript("W2aScriptToggled")) + assert.equals(0, isActive("W2aScriptToggled", "script")) + end) + + it("toggle every script sharing a name, not just the first", function() + assert.is_true(makeScript("W2aScriptToggledTwice", "", [[local w2aFirst = 1]]) > 0) + assert.is_true(makeScript("W2aScriptToggledTwice", "", [[local w2aSecond = 2]]) > 0) + local named = exists("W2aScriptToggledTwice", "script") + assert.is_true(named >= 2) + assert.is_true(enableScript("W2aScriptToggledTwice")) + assert.equals(named, isActive("W2aScriptToggledTwice", "script")) + assert.is_true(disableScript("W2aScriptToggledTwice")) + assert.equals(0, isActive("W2aScriptToggledTwice", "script")) + end) + + it("do not disturb a differently named script", function() + assert.is_true(makeScript("W2aScriptUntouched", "", [[local w2aOne = 1]]) > 0) + assert.is_true(makeScript("W2aScriptSwitched", "", [[local w2aTwo = 2]]) > 0) + local untouched = exists("W2aScriptUntouched", "script") + assert.is_true(enableScript("W2aScriptUntouched")) + assert.is_true(enableScript("W2aScriptSwitched")) + assert.is_true(disableScript("W2aScriptSwitched")) + assert.equals(untouched, isActive("W2aScriptUntouched", "script")) + assert.equals(0, isActive("W2aScriptSwitched", "script")) + end) + end) + + describe("Tests the functionality of speedwalktimer", function() + -- resume first so a paused walk is re-armed and stopSpeedwalk can then + -- clear its walklist; both are shared upvalues of Other.lua + after_each(function() + pcall(resumeSpeedwalk) + pcall(stopSpeedwalk) + end) + + it("Should send the head of the walklist and shorten it", function() + local list = {"n", "e"} + local send = spy.on(_G, "send") + finally(function() send:revert() end) + speedwalktimer(list, 100, false) + assert.spy(send).was.called(1) + assert.spy(send).was.called_with("n", false) + assert.are.same({"e"}, list) + end) + + it("Should arm a timer for the rest of the walklist", function() + local list = {"n", "e"} + local send = spy.on(_G, "send") + finally(function() send:revert() end) + speedwalktimer(list, 100, false) + -- pauseSpeedwalk only succeeds while a step timer is armed + assert.is_true(pauseSpeedwalk()) + end) + + it("Should raise sysSpeedwalkFinished on the last step", function() + local finished = false + local handler = registerAnonymousEventHandler("sysSpeedwalkFinished", function() finished = true end) + finally(function() killAnonymousEventHandler(handler) end) + local send = spy.on(_G, "send") + finally(function() send:revert() end) + -- clear any step timer an earlier test armed so the pause check below + -- can only be answering for this walklist + pcall(pauseSpeedwalk) + local list = {"n"} + speedwalktimer(list, 100, false) + assert.spy(send).was.called_with("n", false) + assert.are.same({}, list) + assert.is_true(finished) + -- nothing was queued, so there is no timer left to pause + assert.is_nil((pauseSpeedwalk())) + end) + end) + + describe("Tests the functionality of deleteFull", function() + after_each(function() + -- deleteFull leaves a one line trigger behind; flush it so it cannot + -- gag a line belonging to a later spec + feedTriggers("deleteFullFlush\n") + end) + + it("Should delete the line it runs on", function() + local id = tempTrigger("deleteFullMarker", function() deleteFull() end) + feedTriggers("deleteFullMarker line\n") + killTrigger(id) + moveCursorEnd() + moveCursorUp() + assert.are_not.equal("deleteFullMarker line", getCurrentLine()) + end) + + it("Should arm a one line trigger that gags a following prompt", function() + local lineTrigger = spy.on(_G, "tempLineTrigger") + finally(function() lineTrigger:revert() end) + local id = tempTrigger("deleteFullArmMarker", function() deleteFull() end) + feedTriggers("deleteFullArmMarker line\n") + killTrigger(id) + assert.spy(lineTrigger).was.called(1) + assert.spy(lineTrigger).was.called_with(1, 1, [[if isPrompt() then deleteLine() end]]) + end) + end) + + describe("Tests the functionality of condenseMapLoad", function() + before_each(function() + clearWindow() + moveCursorEnd() + end) + + it("Should delete the map loading block and return the time it took", function() + echo("[ INFO ] - Reading map. Please wait...\n") + echo("[ INFO ] - Map read in 1.5s.\n") + echo("[ INFO ] - Map deserialised in 0.25s.\n") + local loadTime = condenseMapLoad() + assert.are.equal(1.75, loadTime) + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_falsy(text:find("Reading map", 1, true)) + assert.is_falsy(text:find("deserialised", 1, true)) + end) + + it("Should refuse to condense when the user must see an alert", function() + echo("[ INFO ] - Reading map. Please wait...\n") + echo("[ ALERT ] - something the user has to read\n") + local loadTime, err = condenseMapLoad() + assert.is_nil(loadTime) + assert.are.equal("an alert, warning, or error that the user must see is present", err) + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_truthy(text:find("something the user has to read", 1, true)) + end) + + it("Should report when there is no map load output to condense", function() + echo("nothing to do with maps at all\n") + local loadTime, err = condenseMapLoad() + assert.is_nil(loadTime) + assert.are.equal("couldn't find the starting line for map load output", err) + end) + end) + + describe("Tests the functionality of loadTranslations", function() + it("Should return the strings of the package it is asked for", function() + local translations = loadTranslations("AdjustableContainer") + assert.is_table(translations) + assert.is_table(translations.attach) + assert.is_string(translations.attach.message) + assert.is_truthy(translations.top and translations.bottom and translations.left and translations.right) + end) + + it("Should strip the package prefix off every key", function() + local translations = loadTranslations("AdjustableContainer") + for key in pairs(translations) do + assert.is_falsy(key:find("AdjustableContainer.", 1, true)) + end + end) + + it("Should report a package the translation file has no strings for", function() + local translations, err = loadTranslations("NoSuchPackageInTheTranslationFile") + assert.is_nil(translations) + assert.are.equal("couldn't find translations for 'NoSuchPackageInTheTranslationFile'", err) + end) + + it("Should report a translation file it cannot find", function() + local translations, err = loadTranslations("AdjustableContainer", "noSuchTranslationFile") + assert.is_nil(translations) + assert.is_truthy(err:find("unable to find 'noSuchTranslationFile.json'", 1, true)) + end) + + it("Should reject arguments of the wrong type", function() + assert.has_error(function() loadTranslations(5) end) + assert.has_error(function() loadTranslations("AdjustableContainer", 5) end) + assert.has_error(function() loadTranslations("AdjustableContainer", "mudlet-lua", 5) end) + end) + end) + + describe("Tests the functionality of onConnect", function() + -- defined in LuaGlobal.lua as an empty default users may override + it("Should exist and do nothing", function() + assert.are.equal("function", type(onConnect)) + assert.are.same({}, {onConnect()}) + end) + end) +end) diff --git a/src/mudlet-lua/tests/Package_spec.lua b/src/mudlet-lua/tests/Package_spec.lua new file mode 100644 index 000000000..4020e8909 --- /dev/null +++ b/src/mudlet-lua/tests/Package_spec.lua @@ -0,0 +1,1215 @@ +-- Specs for the package and module lifecycle Lua APIs. +-- +-- Every spec here drives the real API against the fixture kit committed in +-- fixtures/packages/ - nothing is mocked. Alongside each contract (what a call +-- returns, and what it returns when it is misused) the effect is checked too: +-- the package's files actually land under getMudletHomeDir(), its aliases and +-- scripts actually exist, its script body actually ran, and getPackages() / +-- getModules() actually list it. +-- +-- Installing or uninstalling anything costs a full profile save, so the specs +-- that only read share one installed fixture through setup()/teardown() rather +-- than each installing their own - a file that saved the profile fifty times +-- took longer than the whole rest of the suite. +-- +-- Everything these specs install is uninstalled again when the spec (or its +-- block) ends, and the last spec in the file asserts that nothing was left +-- behind: the self-test profile is reused between runs, so a leak here would +-- break the next run. + +-- pumpEvents() is inert outside test mode, and without it the profile save +-- never finishes: the uninstalls fail and strand fixture packages in the +-- profile, so say so rather than make that mess. +if not os.getenv("MUDLET_TEST_MODE") then + describe("Tests the package and module lifecycle", function() + it("needs test mode", function() + pending("the package specs need MUDLET_TEST_MODE (pumpEvents() does nothing without it)") + end) + end) + return +end + +local specDirectory = debug.getinfo(1, "S").source:match("^@(.*)[/\\]") +assert(specDirectory, "Package_spec.lua has to be run from a file so that it can find its fixtures") +local fixtureDirectory = specDirectory .. "/fixtures/packages" + +-- Where the module fixtures are copied to before being installed - see +-- installFixtureModule() for why they cannot be installed from the repository. +local scratchDirectory = getMudletHomeDir() .. "/busted-package-fixtures" + +local minimalPackage = "mudlet-spec-minimal" +local resourcesPackage = "mudlet-spec-resources" +local moduleName = "mudlet-spec-module" + +-- busted keeps only the last function handed to finally(), so everything that +-- has to be undone at the end of a spec goes through one registration here - +-- otherwise a spec that cleans up both a fixture and an event handler silently +-- loses one of them. +local cleanups +local function defer(cleanup) + if not cleanups then + cleanups = {} + finally(function() + local queued = cleanups + cleanups = nil + -- one clean-up giving up (an uninstall that never took, say) must not + -- strand the rest, so run them all and report the first failure after + local firstFailure + for index = #queued, 1, -1 do + local ok, err = pcall(queued[index]) + if not ok and not firstFailure then + firstFailure = err + end + end + if firstFailure then + error(firstFailure, 0) + end + end) + end + cleanups[#cleanups + 1] = cleanup +end + +local function contains(haystack, needle) + return type(haystack) == "string" and haystack:find(needle, 1, true) ~= nil +end + +-- Asserts that calling fn raises a Lua error whose message contains needle. +-- Matching a message substring (rather than merely "did it error?") ensures the +-- function is actually registered and reached its own argument validation: an +-- unregistered/nil function would raise a different "attempt to call" error. +local function assertArgError(fn, needle) + local ok, err = pcall(fn) + assert.is_false(ok) + assert.is_true(contains(err, needle), tostring(err)) +end + +-- The install events, and the profile save uninstallPackage() schedules, are +-- all raised from a zero-timer, so none of them happen unless a spec pumps. +local function waitUntil(condition, timeoutMilliseconds) + local waited = 0 + while waited < timeoutMilliseconds do + if condition() then + return true + end + pumpEvents(50) + waited = waited + 50 + end + return condition() and true or false +end + +local function listContains(list, name) + for _, entry in ipairs(list) do + if entry == name then + return true + end + end + return false +end + +local function packageInstalled(name) + return listContains(getPackages(), name) +end + +local function moduleInstalled(name) + return listContains(getModules(), name) +end + +local function fileExists(path) + return lfs.attributes(path, "mode") ~= nil +end + +-- Everything the main console gained since it was at line `mark`, joined up. +-- The console wraps long lines and a wrap swallows the space it broke at, so +-- the announcements below are matched with all whitespace removed. +local function textFrom(mark) + return table.concat(getLines("main", mark, getLastLineNumber("main") + 1), "") +end + +local function containsWrapped(haystack, needle) + return contains((tostring(haystack):gsub("%s+", "")), (needle:gsub("%s+", ""))) +end + +-- A file: URL for a local path, in the three-slash form that keeps a Windows +-- drive letter from being read as the host name. The checkout these fixtures +-- live in can sit anywhere, so the characters that would otherwise end the path +-- early - a space, a fragment, a query, a half-written escape - are encoded. +local function fileUrl(path) + local normalised = path:gsub("\\", "/"):gsub("[%%#%?%s]", function(character) return string.format("%%%02X", character:byte()) end) + if normalised:sub(1, 1) ~= "/" then + normalised = "/" .. normalised + end + return "file://" .. normalised +end + +local function copyFile(from, to) + local source = io.open(from, "rb") + assert.is_not_nil(source, "could not read the fixture " .. from) + local contents = source:read("*a") + source:close() + local destination = io.open(to, "wb") + assert.is_not_nil(destination, "could not write to " .. to) + assert.is_not_nil(destination:write(contents), "could not write to " .. to) + destination:close() +end + +-- Every install and uninstall here starts an asynchronous profile save, and +-- while one is running the package API stops doing what it is told: an install +-- is postponed and answered with a bare true (see the pending spec at the end +-- of this file), an uninstall is refused, and a module reload is dropped. Lua +-- cannot ask whether a save is running, so each of the helpers below asks again +-- until what it wanted has actually happened. They wait longer between tries +-- than they need to on a fast machine on purpose: each postponed call queues +-- another attempt for whenever the save does finish, and a pile of those all +-- arriving at once starts a pile of saves. +-- Lua has no direct way to ask whether a profile save is running, but +-- installPackage() gives it away: while one is in progress it postpones +-- whatever it was asked to do and answers true, even for an empty path it would +-- otherwise refuse outright. Waiting for the refusal to come back is what keeps +-- the installs below from being postponed - a postponed install is carried out +-- later, and can put a package back after a spec has taken it away again. +local function waitForProfileSaveToPass() + return waitUntil(function() return installPackage("") == nil end, 5000) +end + +local function installUntilConfirmed(install, path, isInstalled, what) + for attempt = 1, 3 do + if isInstalled() then + return + end + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running after 5s, so this install would be postponed") + local ok, err = install(path) + -- a postponed install can still be carried out while the pump below runs + -- the event loop, so a repeat may legitimately come back "already installed" + if ok ~= true and not contains(err, "already installed") then + assert.is_true(false, tostring(err)) + end + -- an install that is carried out is carried out there and then, so if it is + -- not listed by the time the call returns it was postponed + if isInstalled() then + return + end + pumpEvents(400 * attempt) + end + assert.is_true(false, "could not install " .. what) +end + +-- The same postponement answers a bad install path with true as well, so a spec +-- about the refusal waits for the save to pass and asks again. +local function installUntilRefused(install, path) + for attempt = 1, 3 do + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running after 5s, so this install would be postponed") + local ok, err = install(path) + if ok == nil then + return err + end + pumpEvents(400 * attempt) + end + assert.is_true(false, "the install was postponed instead of being answered") +end + +-- reloadModule() is postponed the same way and then quietly dropped, so ask +-- until the reload is observable. +local function reloadModuleUntil(name, reloaded) + for attempt = 1, 3 do + reloadModule(name) + if waitUntil(reloaded, 300) then + return + end + pumpEvents(400 * attempt) + end + assert.is_true(false, "the module was never reloaded") +end + +-- Uninstalls and then waits, twice over if it has to: an install this file +-- postponed earlier can be carried out while the wait runs the event loop, and +-- would otherwise reinstall the package behind the spec's back. +local function removeFixturePackage(name) + for _ = 1, 3 do + if not packageInstalled(name) then + return + end + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running after 5s, so this uninstall would be refused") + -- uninstallPackage() refuses while a profile save is in progress, and the + -- installs here start one, so keep asking until it takes + assert.is_true(waitUntil(function() return uninstallPackage(name) == true end, 5000), + "could not uninstall the fixture package " .. name) + -- let the profile save that uninstallPackage() queues run now, rather than + -- during Mudlet's shutdown + pumpEvents(200) + end + assert.is_false(packageInstalled(name), "the fixture package " .. name .. " reinstalled itself") +end + +local function installFixturePackage(name) + installUntilConfirmed(installPackage, fixtureDirectory .. "/" .. name .. ".mpackage", + function() return packageInstalled(name) end, "the fixture package " .. name) +end + +local function withFixturePackage(name) + defer(function() removeFixturePackage(name) end) + installFixturePackage(name) +end + +local function removeFixtureModule(name) + for _ = 1, 3 do + if not moduleInstalled(name) then + break + end + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running after 5s, so this uninstall would be refused") + assert.is_true(waitUntil(function() return uninstallModule(name) == true end, 5000), + "could not uninstall the fixture module " .. name) + pumpEvents(200) + end + assert.is_false(moduleInstalled(name), "the fixture module " .. name .. " reinstalled itself") + os.remove(scratchDirectory .. "/" .. name .. ".mpackage") + lfs.rmdir(scratchDirectory) +end + +-- A module is installed from a copy inside the profile, never from the +-- repository: with sync enabled a profile save rewrites the module's own +-- .mpackage in place, which would corrupt the committed fixture. +local function installFixtureModule(name) + lfs.mkdir(scratchDirectory) + local path = scratchDirectory .. "/" .. name .. ".mpackage" + copyFile(fixtureDirectory .. "/" .. name .. ".mpackage", path) + installUntilConfirmed(installModule, path, function() return moduleInstalled(name) end, "the fixture module " .. name) + return path +end + +-- The clean-up is registered before the install so a fixture that only got +-- half-way in still leaves nothing behind. +local function withFixtureModule(name) + defer(function() removeFixtureModule(name) end) + return installFixtureModule(name) +end + +-- Collects every occurrence of an event until stopCollecting() is called. The +-- uninstall events are raised inside uninstallPackage() itself, before a +-- waitForEvent() could be armed, so a pre-armed handler is what sees them. +-- Returns the list the events land in and the handler id to kill. +local function collectEvents(eventName) + local events = {} + local handler = registerAnonymousEventHandler(eventName, function(_, ...) + events[#events + 1] = {...} + end) + return events, handler +end + +-- The same, for a spec that can register its own clean-up. +local function collectEventsForSpec(eventName) + local events, handler = collectEvents(eventName) + defer(function() killAnonymousEventHandler(handler) end) + return events +end + +describe("Tests the functionality of installPackage", function() + it("raises a Lua error when called with no arguments", function() + -- the Lua wrapper that lets installPackage() take a URL indexes its + -- argument before the C++ side gets to report a "bad argument #1", so the + -- call fails less clearly than its siblings do + assert.has_error(function() installPackage() end) + end) + + it("returns nil+msg when given an empty path", function() + local err = installUntilRefused(installPackage, "") + assert.is_true(contains(err, "no package file was actually given"), tostring(err)) + end) + + it("returns nil+msg for a file that is not there", function() + local err = installUntilRefused(installPackage, fixtureDirectory .. "/mudlet-spec-there-is-no-such-package.mpackage") + assert.is_true(contains(err, "could not open file"), tostring(err)) + end) + + it("returns nil+msg for a file that is not a zip archive", function() + -- the failed unpacking still creates the destination folder; drop it so the + -- profile is left exactly as it was found + defer(function() lfs.rmdir(getMudletHomeDir() .. "/mudlet-spec-notazip") end) + + local err = installUntilRefused(installPackage, fixtureDirectory .. "/mudlet-spec-notazip.mpackage") + assert.is_true(contains(err, "could not unzip package"), tostring(err)) + assert.is_false(packageInstalled("mudlet-spec-notazip")) + end) + + describe("with the fixture package installed", function() + local runsBefore, installEvents, packageEvents, handlers + + setup(function() + runsBefore = mudletSpecMinimalRuns or 0 + local genericHandler, detailedHandler + installEvents, genericHandler = collectEvents("sysInstall") + packageEvents, detailedHandler = collectEvents("sysInstallPackage") + handlers = {genericHandler, detailedHandler} + installFixturePackage(minimalPackage) + -- the install events are raised from a zero-timer once the install is done + waitUntil(function() return #packageEvents > 0 end, 2000) + end) + + teardown(function() + for _, handler in ipairs(handlers) do + killAnonymousEventHandler(handler) + end + removeFixturePackage(minimalPackage) + end) + + it("unpacks the package into the profile and runs its contents", function() + local packageDirectory = getMudletHomeDir() .. "/" .. minimalPackage + assert.is_true(fileExists(packageDirectory), "the package folder was not created") + assert.is_true(fileExists(packageDirectory .. "/config.lua")) + assert.is_true(fileExists(packageDirectory .. "/" .. minimalPackage .. ".xml")) + assert.equals(1, exists(minimalPackage .. " alias", "alias")) + assert.equals(1, exists("mudletSpecMinimalScript", "script")) + assert.is_true(mudletSpecMinimalRuns > runsBefore, "the package's script did not run") + end) + + it("raises sysInstall and sysInstallPackage once the install is complete", function() + assert.equals(1, #installEvents) + assert.equals(minimalPackage, installEvents[1][1]) + assert.equals(1, #packageEvents) + assert.equals(minimalPackage, packageEvents[1][1]) + assert.is_true(contains(packageEvents[1][2], minimalPackage .. ".mpackage"), tostring(packageEvents[1][2])) + end) + + it("refuses to install a package that is already installed", function() + local err = installUntilRefused(installPackage, fixtureDirectory .. "/" .. minimalPackage .. ".mpackage") + assert.is_true(contains(err, "package " .. minimalPackage .. " is already installed"), tostring(err)) + end) + end) + + it("unpacks a folder of resources that ships with a package", function() + withFixturePackage(resourcesPackage) + + local packageDirectory = getMudletHomeDir() .. "/" .. resourcesPackage + assert.is_true(fileExists(packageDirectory .. "/resources/spec-note.txt")) + assert.is_true(fileExists(packageDirectory .. "/resources/nested/spec-nested.txt")) + local handle = io.open(packageDirectory .. "/resources/spec-note.txt", "rb") + assert.is_not_nil(handle) + local contents = handle:read("*a") + handle:close() + assert.is_true(contains(contents, "mudlet-spec-resources fixture resource")) + -- the resources package declares its own version, unlike the minimal one + assert.equals("2.5", getPackageInfo(resourcesPackage, "version")) + end) + + it("names a package after its file when the archive has no config.lua", function() + withFixturePackage("mudlet-spec-noconfig") + + assert.equals(1, exists("mudlet-spec-noconfig alias", "alias")) + assert.same({}, getPackageInfo("mudlet-spec-noconfig")) + end) + + it("installs a package from a plain XML file", function() + local path = fixtureDirectory .. "/sources/mudlet-spec-xmlonly/mudlet-spec-xmlonly.xml" + defer(function() removeFixturePackage("mudlet-spec-xmlonly") end) + installUntilConfirmed(installPackage, path, function() return packageInstalled("mudlet-spec-xmlonly") end, + "the XML fixture package") + + assert.equals(1, exists("mudlet-spec-xmlonly alias", "alias")) + -- nothing is unpacked for a bare XML: the file stays where it is + assert.is_false(fileExists(getMudletHomeDir() .. "/mudlet-spec-xmlonly")) + assert.is_true(fileExists(path), "the package XML must not be moved out of the fixtures") + end) +end) + +describe("Tests the functionality of uninstallPackage", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() uninstallPackage() end, "uninstallPackage: bad argument #1 type") + end) + + -- uninstallPackage() answers nil with no message where uninstallModule() + -- answers false: two conventions for the same case, pinned as they are + -- because packages published today read one or the other. + it("returns nil and no message for a package that is not installed", function() + local ok, err = uninstallPackage("mudlet-spec-never-installed") + assert.is_nil(ok) + assert.is_nil(err) + end) + + it("removes the package, its items and its folder, and raises the uninstall events", function() + withFixturePackage(minimalPackage) + local packageDirectory = getMudletHomeDir() .. "/" .. minimalPackage + assert.is_true(fileExists(packageDirectory)) + assert.is_true(packageInstalled(minimalPackage)) + local generic = collectEventsForSpec("sysUninstall") + local detailed = collectEventsForSpec("sysUninstallPackage") + + removeFixturePackage(minimalPackage) + + assert.is_false(packageInstalled(minimalPackage)) + assert.equals(0, exists(minimalPackage .. " alias", "alias")) + assert.equals(0, exists("mudletSpecMinimalScript", "script")) + assert.is_false(fileExists(packageDirectory), "the package folder was left behind") + assert.same({}, getPackageInfo(minimalPackage)) + assert.equals(1, #generic) + assert.equals(minimalPackage, generic[1][1]) + assert.equals(1, #detailed) + assert.equals(minimalPackage, detailed[1][1]) + end) +end) + +describe("Tests the functionality of getPackages", function() + it("returns a table of the installed packages", function() + local packages = getPackages() + assert.is_table(packages) + -- run-tests is the package running these specs, so it is always installed + assert.is_true(listContains(packages, "run-tests")) + assert.is_false(listContains(packages, "mudlet-spec-never-installed")) + end) +end) + +describe("Tests the package info accessors", function() + setup(function() + installFixturePackage(minimalPackage) + end) + teardown(function() + removeFixturePackage(minimalPackage) + end) + + describe("Tests the functionality of getPackageInfo", function() + it("raises a Lua error when the package name is not a string", function() + assertArgError(function() getPackageInfo({}) end, "getPackageInfo: bad argument #1 type") + end) + + it("raises a Lua error when the requested field is not a string", function() + assertArgError(function() getPackageInfo(minimalPackage, {}) end, "getPackageInfo: bad argument #2 type") + end) + + it("returns everything the package's config.lua declared", function() + assert.same({ + mpackage = minimalPackage, + author = "Mudlet test suite", + title = "Minimal fixture package for Package_spec.lua", + version = "1.0", + description = "One alias and one script, just enough to prove a package installed.", + }, getPackageInfo(minimalPackage)) + end) + + it("returns a single field when one is named", function() + assert.equals("1.0", getPackageInfo(minimalPackage, "version")) + assert.equals("Mudlet test suite", getPackageInfo(minimalPackage, "author")) + end) + + it("returns an empty string for a field the package does not have", function() + assert.equals("", getPackageInfo(minimalPackage, "no-such-field")) + end) + + it("returns an empty table for a package that is not installed", function() + assert.same({}, getPackageInfo("mudlet-spec-never-installed")) + end) + end) + + describe("Tests the functionality of setPackageInfo", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() setPackageInfo() end, "setPackageInfo: bad argument #1 type") + end) + + it("raises a Lua error when the value is missing", function() + assertArgError(function() setPackageInfo(minimalPackage, "version") end, "setPackageInfo: bad argument #3 type") + end) + + it("round-trips a value through getPackageInfo", function() + defer(function() setPackageInfo(minimalPackage, "version", "1.0") end) + + assert.is_true(setPackageInfo(minimalPackage, "version", "9.9")) + assert.equals("9.9", getPackageInfo(minimalPackage, "version")) + assert.equals("9.9", getPackageInfo(minimalPackage).version) + end) + + it("adds a field the package did not declare", function() + assert.is_true(setPackageInfo(minimalPackage, "spec-added", "yes")) + assert.equals("yes", getPackageInfo(minimalPackage, "spec-added")) + end) + end) +end) + +describe("Tests the functionality of installModule", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() installModule() end, "installModule: bad argument #1 type") + end) + + it("returns nil+msg for a file that is not there", function() + local err = installUntilRefused(installModule, fixtureDirectory .. "/mudlet-spec-there-is-no-such-module.mpackage") + assert.is_true(contains(err, "could not open file"), tostring(err)) + end) + + describe("with the fixture module installed", function() + local runsBefore, installEvents, moduleEvents, handlers, modulePath + + setup(function() + runsBefore = mudletSpecModuleRuns or 0 + local genericHandler, detailedHandler + installEvents, genericHandler = collectEvents("sysInstall") + moduleEvents, detailedHandler = collectEvents("sysLuaInstallModule") + handlers = {genericHandler, detailedHandler} + modulePath = installFixtureModule(moduleName) + waitUntil(function() return #moduleEvents > 0 end, 2000) + end) + + teardown(function() + for _, handler in ipairs(handlers) do + killAnonymousEventHandler(handler) + end + removeFixtureModule(moduleName) + end) + + it("installs the module, unpacks it and runs its contents", function() + assert.is_true(moduleInstalled(moduleName)) + -- a module is not a package: it must not turn up in getPackages() + assert.is_false(packageInstalled(moduleName)) + assert.is_true(fileExists(getMudletHomeDir() .. "/" .. moduleName)) + assert.equals(1, exists(moduleName .. " alias", "alias")) + assert.is_true(mudletSpecModuleRuns > runsBefore, "the module's script did not run") + end) + + it("raises sysInstall and sysLuaInstallModule", function() + assert.equals(1, #installEvents) + assert.equals(moduleName, installEvents[1][1]) + assert.equals(1, #moduleEvents) + assert.equals(moduleName, moduleEvents[1][1]) + assert.is_true(contains(moduleEvents[1][2], moduleName .. ".mpackage"), tostring(moduleEvents[1][2])) + end) + + it("refuses to install a module that is already installed", function() + local err = installUntilRefused(installModule, modulePath) + assert.is_true(contains(err, "module " .. moduleName .. " is already installed"), tostring(err)) + end) + end) +end) + +describe("Tests the functionality of uninstallModule", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() uninstallModule() end, "uninstallModule: bad argument #1 type") + end) + + it("returns false for a module that is not installed", function() + assert.is_false(uninstallModule("mudlet-spec-never-installed")) + end) + + it("removes the module, its items and its folder, and raises the uninstall events", function() + withFixtureModule(moduleName) + local moduleDirectory = getMudletHomeDir() .. "/" .. moduleName + assert.is_true(fileExists(moduleDirectory)) + local generic = collectEventsForSpec("sysUninstall") + local detailed = collectEventsForSpec("sysLuaUninstallModule") + + removeFixtureModule(moduleName) + + assert.is_false(moduleInstalled(moduleName)) + assert.equals(0, exists(moduleName .. " alias", "alias")) + assert.is_false(fileExists(moduleDirectory), "the module folder was left behind") + assert.same({}, getModuleInfo(moduleName)) + assert.equals(1, #generic) + assert.equals(moduleName, generic[1][1]) + assert.equals(1, #detailed) + assert.equals(moduleName, detailed[1][1]) + end) +end) + +describe("Tests the functionality of getModules", function() + it("returns a table of the installed modules", function() + local modules = getModules() + assert.is_table(modules) + assert.is_false(listContains(modules, "mudlet-spec-never-installed")) + end) +end) + +describe("Tests the module accessors", function() + local modulePath + + setup(function() + modulePath = installFixtureModule(moduleName) + end) + teardown(function() + removeFixtureModule(moduleName) + end) + + describe("Tests the functionality of getModuleInfo", function() + it("raises a Lua error when the module name is not a string", function() + assertArgError(function() getModuleInfo({}) end, "getModuleInfo: bad argument #1 type") + end) + + it("returns everything the module's config.lua declared", function() + assert.same({ + mpackage = moduleName, + author = "Mudlet test suite", + title = "Module fixture for Package_spec.lua", + version = "3.1", + description = "Counts how often its script has been compiled, so a reload is observable.", + }, getModuleInfo(moduleName)) + end) + + it("returns a single field when one is named", function() + assert.equals("3.1", getModuleInfo(moduleName, "version")) + assert.equals("", getModuleInfo(moduleName, "no-such-field")) + end) + + it("returns an empty table for a module that is not installed", function() + assert.same({}, getModuleInfo("mudlet-spec-never-installed")) + end) + end) + + describe("Tests the functionality of setModuleInfo", function() + it("raises a Lua error when the value is missing", function() + assertArgError(function() setModuleInfo(moduleName, "version") end, "setModuleInfo: bad argument #3 type") + end) + + it("round-trips a value through getModuleInfo", function() + defer(function() setModuleInfo(moduleName, "version", "3.1") end) + + assert.is_true(setModuleInfo(moduleName, "version", "8.8")) + assert.equals("8.8", getModuleInfo(moduleName, "version")) + assert.equals("8.8", getModuleInfo(moduleName).version) + end) + end) + + describe("Tests the functionality of getModulePath", function() + it("returns the file the module was installed from", function() + assert.equals(modulePath, getModulePath(moduleName)) + end) + end) + + describe("Tests the functionality of getModulePriority", function() + -- Runs before setModulePriority's specs on purpose: a priority outlives the + -- module it was set on (Host::uninstallPackage() leaves mModulePriorities + -- alone), so once one has been set for this module the default this spec is + -- about can never be observed again. + it("reports the default priority of a freshly installed module", function() + -- Installing a module seeds no priority for it, so this is the default the + -- module manager displays and the profile exporter writes out, rather than + -- the "module doesn't exist" that reading the priority map as an existence + -- check used to answer here. + assert.equals(0, getModulePriority(moduleName)) + end) + + it("returns nil+msg for a module that is not installed", function() + local ok, err = getModulePriority("mudlet-spec-never-installed") + assert.is_nil(ok) + assert.is_true(contains(err, "module doesn't exist"), tostring(err)) + end) + end) + + describe("Tests the functionality of setModulePriority", function() + it("raises a Lua error when the priority is missing", function() + assertArgError(function() setModulePriority(moduleName) end, "setModulePriority: bad argument #2 type") + end) + + it("returns nil+msg for a module that is not installed", function() + local ok, err = setModulePriority("mudlet-spec-never-installed", 3) + assert.is_nil(ok) + assert.is_true(contains(err, "module doesn't exist"), tostring(err)) + end) + + it("returns no values and is read back by getModulePriority", function() + assert.equals(0, select('#', setModulePriority(moduleName, 7))) + assert.equals(7, getModulePriority(moduleName)) + setModulePriority(moduleName, -2) + assert.equals(-2, getModulePriority(moduleName)) + end) + end) + + describe("Tests the functionality of enableModuleSync", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() enableModuleSync() end, "enableModuleSync: bad argument #1 type") + end) + + it("returns nil+msg for an empty module name", function() + local ok, err = enableModuleSync("") + assert.is_nil(ok) + assert.is_true(contains(err, "module name cannot be an empty string"), tostring(err)) + end) + + it("returns nil+msg for a module that is not installed", function() + local ok, err = enableModuleSync("mudlet-spec-never-installed") + assert.is_nil(ok) + assert.is_true(contains(err, "not found"), tostring(err)) + end) + + it("turns syncing on for an installed module", function() + -- leave the module unsynced: a profile save rewrites a synced module's + -- own .mpackage, and the fixture copy is thrown away when this block ends + defer(function() disableModuleSync(moduleName) end) + assert.is_false(getModuleSync(moduleName)) + + assert.is_true(enableModuleSync(moduleName)) + assert.is_true(getModuleSync(moduleName)) + end) + end) + + describe("Tests the functionality of disableModuleSync", function() + it("returns nil+msg for a module that is not installed", function() + local ok, err = disableModuleSync("mudlet-spec-never-installed") + assert.is_nil(ok) + assert.is_true(contains(err, "not found"), tostring(err)) + end) + + it("turns syncing back off", function() + defer(function() disableModuleSync(moduleName) end) + assert.is_true(enableModuleSync(moduleName)) + + assert.is_true(disableModuleSync(moduleName)) + assert.is_false(getModuleSync(moduleName)) + end) + end) + + describe("Tests the functionality of getModuleSync", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() getModuleSync() end, "getModuleSync: bad argument #1 type") + end) + + it("returns nil+msg for a module that is not installed", function() + local ok, err = getModuleSync("mudlet-spec-never-installed") + assert.is_nil(ok) + assert.is_true(contains(err, "not found"), tostring(err)) + end) + + it("is false for a module that nobody has turned syncing on for", function() + assert.is_false(getModuleSync(moduleName)) + end) + end) + + -- A synced module is the only thing that makes a profile save do any module + -- work at all: with none installed the save's module list comes out empty and + -- the background write returns at its first line. The write itself and + -- everything it touches - the module documents, the backup, the archive + -- rewrite - therefore go unseen by the sanitizers this suite runs under + -- unless a spec puts a synced module in the profile first. + describe("Tests saving a profile that has a module to write", function() + it("writes the synced module out again", function() + -- rewriting this module's own .mpackage is only safe because + -- installFixtureModule() installed a scratch copy, not the committed one + defer(function() disableModuleSync(moduleName) end) + assert.is_true(enableModuleSync(moduleName)) + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + + -- taking the unpacked XML away is what makes "the save wrote the module + -- out" a plain yes or no rather than a guess about file timestamps + local moduleXml = getMudletHomeDir() .. "/" .. moduleName .. "/" .. moduleName .. ".xml" + os.remove(moduleXml) + assert.is_nil(lfs.attributes(moduleXml), "the module's unpacked XML could not be cleared") + + assert.is_true(saveProfile()) + assert.is_true(waitUntil(function() return lfs.attributes(moduleXml) ~= nil end, 10000), "the profile save never wrote the synced module out") + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + + -- a file that merely exists could be an empty or half-written one + local written = io.open(moduleXml, "rb") + assert.is_not_nil(written, "the module's XML could not be read back") + local contents = written:read("*a") + written:close() + assert.is_true(contains(contents, "<MudletPackage"), "the module's XML was written without a package in it") + end) + end) +end) + +describe("Tests the functionality of reloadModule", function() + it("raises a Lua error when called with no arguments", function() + assertArgError(function() reloadModule() end, "reloadModule: bad argument #1 type") + end) + + it("returns no values and does nothing for a module that is not installed", function() + assert.equals(0, select('#', reloadModule("mudlet-spec-never-installed"))) + assert.is_false(moduleInstalled("mudlet-spec-never-installed")) + end) + + describe("with the fixture module installed", function() + setup(function() + installFixtureModule(moduleName) + end) + teardown(function() + removeFixtureModule(moduleName) + end) + + it("runs the module's scripts again", function() + local runsBefore = mudletSpecModuleRuns + + reloadModuleUntil(moduleName, function() return mudletSpecModuleRuns > runsBefore end) + + assert.is_true(moduleInstalled(moduleName)) + assert.equals(1, exists(moduleName .. " alias", "alias")) + end) + + it("re-reads the module's info from its config.lua", function() + setModuleInfo(moduleName, "title", "changed by the spec") + assert.equals("changed by the spec", getModuleInfo(moduleName, "title")) + + reloadModuleUntil(moduleName, function() + return getModuleInfo(moduleName, "title") == "Module fixture for Package_spec.lua" + end) + end) + + it("keeps the module's priority and sync setting", function() + defer(function() disableModuleSync(moduleName) end) + setModulePriority(moduleName, 4) + assert.is_true(enableModuleSync(moduleName)) + local runsBefore = mudletSpecModuleRuns + + reloadModuleUntil(moduleName, function() return mudletSpecModuleRuns > runsBefore end) + + assert.equals(4, getModulePriority(moduleName)) + assert.is_true(getModuleSync(moduleName)) + end) + end) +end) + +-- Runs once the block above has uninstalled the module it shares, which is what +-- this is about - it installs nothing of its own. +describe("Tests reading the priority of a module that has been uninstalled", function() + it("stops answering for it, even though the priority it was given is remembered", function() + assert.is_false(moduleInstalled(moduleName), "the module accessor specs left their module installed") + -- Uninstalling leaves the module's entry in the priority map behind, so + -- reading that map is not a way to tell whether the module is there. + local ok, err = getModulePriority(moduleName) + assert.is_nil(ok) + assert.is_true(contains(err, "module doesn't exist"), tostring(err)) + end) +end) + +describe("Tests a package that uninstalls itself", function() + -- Regression #9557: a package whose event handler uninstalls its own package + -- used to free the TScript objects that Host::raiseEvent() was still + -- iterating over. Package auto-updaters do exactly this. + it("survives a package uninstalling itself from its own event handler", function() + defer(function() + removeFixturePackage("mudlet-spec-selfuninstall") + mudletSpecSelfUninstallHandler = nil + mudletSpecSelfUninstallSecondHandler = nil + mudletSpecSelfUninstallRan = nil + mudletSpecSelfUninstallSecondRan = nil + end) + installFixturePackage("mudlet-spec-selfuninstall") + assert.equals(1, exists("mudletSpecSelfUninstallHandler", "script")) + + -- the handler's uninstallPackage() declines while the save the install + -- started is still running, so raise until the package is really gone + assert.is_true(waitUntil(function() + mudletSpecSelfUninstallRan = nil + mudletSpecSelfUninstallSecondRan = nil + raiseEvent("mudletSpecSelfUninstall") + return not packageInstalled("mudlet-spec-selfuninstall") + end, 5000), "the package did not uninstall itself") + + assert.is_true(mudletSpecSelfUninstallRan, "the package's own handler did not run") + -- the package's second handler for this event is the one the pre-fix code + -- would have called through a freed script; whether it is reached at all + -- depends on where in the dispatch the uninstall landed, so what is checked + -- here is that both scripts are gone afterwards and nothing crashed + assert.equals(0, exists("mudletSpecSelfUninstallHandler", "script")) + assert.equals(0, exists("mudletSpecSelfUninstallSecondHandler", "script")) + -- raising the event again must not reach the removed scripts + raiseEvent("mudletSpecSelfUninstall") + pumpEvents(100) + assert.is_false(packageInstalled("mudlet-spec-selfuninstall")) + end) +end) + +describe("Tests installing a package while the profile is being saved", function() + it("installs a package that is asked for while an earlier install is still saving", function() + -- BUG: installing a package starts an asynchronous profile save, and an + -- install that arrives during one is postponed until profileSaveFinished(). + -- That signal is only emitted while the profile writer is being retired, so + -- an install asked for after the writers are gone but before the save has + -- finished is never carried out - and installPackage() has already answered + -- true, so a script has no way to notice. Left pending rather than pinning + -- a silently dropped install as correct. + pending("installPackage() answers true but drops the install when a save is in progress") + defer(function() + removeFixturePackage(minimalPackage) + removeFixturePackage("mudlet-spec-noconfig") + end) + installFixturePackage(minimalPackage) + + assert.is_true(installPackage(fixtureDirectory .. "/mudlet-spec-noconfig.mpackage")) + assert.is_true(waitUntil(function() return packageInstalled("mudlet-spec-noconfig") end, 5000)) + end) +end) + +describe("Tests installing an archive with nothing in it for Mudlet", function() + it("refuses an archive that holds neither a config.lua nor a package XML", function() + -- Nothing in such an archive registers the package, so answering true would + -- leave a name that getPackages() does not list, that uninstallPackage() + -- refuses, and a folder in the profile that only a file manager can take + -- away. If the refusal ever regresses, this puts the folder back by hand. + defer(function() + if fileExists(getMudletHomeDir() .. "/mudlet-spec-emptyarchive") then + os.remove(getMudletHomeDir() .. "/mudlet-spec-emptyarchive/readme.txt") + lfs.rmdir(getMudletHomeDir() .. "/mudlet-spec-emptyarchive") + end + end) + + -- an install asked for while a save is running is postponed and answered + -- with a bare true, which would read here as the refusal not happening + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + + local ok, err = installPackage(fixtureDirectory .. "/mudlet-spec-emptyarchive.mpackage") + assert.is_nil(ok) + -- the message matters: "could not unzip package" here would mean the fixture + -- has rotted and the spec is passing for the wrong reason + assert.is_true(contains(err, "no package found in"), tostring(err)) + assert.is_false(packageInstalled("mudlet-spec-emptyarchive")) + assert.is_false(fileExists(getMudletHomeDir() .. "/mudlet-spec-emptyarchive")) + end) +end) + +describe("Tests the functionality of verbosePackageInstall", function() + it("installs the package and says so on the main console", function() + defer(function() removeFixturePackage(minimalPackage) end) + local path = fixtureDirectory .. "/" .. minimalPackage .. ".mpackage" + -- an install asked for while a save is running is postponed, and would be + -- announced as a success without anything being installed + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + verbosePackageInstall(path) + + assert.is_true(packageInstalled(minimalPackage), "the package was not installed") + assert.is_true(containsWrapped(textFrom(mark), "Package '" .. path .. "' installed successfully."), textFrom(mark)) + end) + + it("says why an install failed", function() + -- a path that is not there fails without installing anything, so this spec + -- costs none of the profile saves an install-then-reinstall would + local path = fixtureDirectory .. "/mudlet-spec-there-is-no-such-package.mpackage" + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + verbosePackageInstall(path) + + local text = textFrom(mark) + assert.is_true(containsWrapped(text, "Installing '" .. path .. "' failed:"), text) + assert.is_true(containsWrapped(text, "could not open file"), text) + assert.is_false(packageInstalled("mudlet-spec-there-is-no-such-package")) + end) +end) + +describe("Tests the functionality of verboseModuleInstall", function() + -- A module is installed from a copy inside the profile for the same reason + -- installFixtureModule() does it: a save rewrites a synced module's own + -- .mpackage, which must not be the committed fixture. + local function stageModule() + lfs.mkdir(scratchDirectory) + local path = scratchDirectory .. "/" .. moduleName .. ".mpackage" + copyFile(fixtureDirectory .. "/" .. moduleName .. ".mpackage", path) + return path + end + + it("installs the module and says so on the main console", function() + defer(function() removeFixtureModule(moduleName) end) + local path = stageModule() + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + verboseModuleInstall(path) + + assert.is_true(moduleInstalled(moduleName), "the module was not installed") + assert.is_true(containsWrapped(textFrom(mark), "Module '" .. path .. "' installed successfully."), textFrom(mark)) + end) + + it("says why an install failed", function() + local path = fixtureDirectory .. "/mudlet-spec-there-is-no-such-module.mpackage" + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + verboseModuleInstall(path) + + local text = textFrom(mark) + -- the module and package failures are announced in the same words, so it is + -- the spec above, not this one, that tells the two functions apart + assert.is_true(containsWrapped(text, "Installing '" .. path .. "' failed:"), text) + assert.is_true(containsWrapped(text, "could not open file"), text) + assert.is_false(moduleInstalled("mudlet-spec-there-is-no-such-module")) + end) +end) + +describe("Tests the functionality of installPackageFromUrl", function() + local downloadedName = minimalPackage .. ".mpackage" + + it("downloads the package, installs it and tidies the download away", function() + defer(function() + removeFixturePackage(minimalPackage) + os.remove(getMudletHomeDir() .. "/" .. downloadedName) + end) + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + -- a file: URL keeps this off the network while still going through + -- downloadFile() and the sysDownloadDone handler the function registers + local url = fileUrl(fixtureDirectory .. "/" .. downloadedName) + + installPackageFromUrl(downloadedName, url) + + local event, installedName = waitForEvent("sysInstallPackage", 10000) + assert.equals("sysInstallPackage", event) + assert.equals(minimalPackage, installedName) + assert.is_true(packageInstalled(minimalPackage)) + local text = textFrom(mark) + assert.is_true(containsWrapped(text, "Downloading package from " .. url), text) + assert.is_true(containsWrapped(text, "installed successfully."), text) + assert.is_false(fileExists(getMudletHomeDir() .. "/" .. downloadedName), "the downloaded copy was left in the profile") + end) + + it("names the file, not the whole path, in the announcement", function() + -- BUG: verbosePackageInstall() strips the profile folder off the name it + -- announces, but uses that folder as a Lua pattern - a profile path holding + -- a "-" (a home folder with one will do it) never matches, so the whole + -- path is announced instead of the file. + pending("verbosePackageInstall() strips the profile folder with an unescaped Lua pattern") + defer(function() + removeFixturePackage(minimalPackage) + os.remove(getMudletHomeDir() .. "/" .. downloadedName) + end) + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + installPackageFromUrl(downloadedName, fileUrl(fixtureDirectory .. "/" .. downloadedName)) + + waitForEvent("sysInstallPackage", 10000) + assert.is_true(containsWrapped(textFrom(mark), "Package '" .. downloadedName .. "' installed successfully."), textFrom(mark)) + end) + + it("reports a download that failed and installs nothing", function() + local missingName = "mudlet-spec-never-downloadable.mpackage" + defer(function() os.remove(getMudletHomeDir() .. "/" .. missingName) end) + local mark = getLastLineNumber("main") + + installPackageFromUrl(missingName, fileUrl(fixtureDirectory .. "/" .. missingName)) + + local event = waitForEvent("sysDownloadError", 10000) + assert.equals("sysDownloadError", event) + pumpEvents(200) + assert.is_false(packageInstalled("mudlet-spec-never-downloadable")) + local text = textFrom(mark) + -- the warning only means something paired with the download it reports on + assert.is_true(containsWrapped(text, "Downloading package from"), text) + assert.is_true(containsWrapped(text, "[ WARN ]"), text) + end) +end) + +describe("Tests the functionality of packageDrop", function() + it("hands a dropped package file to the installer", function() + -- The file is one that is not there: what this is about is that dropping + -- reaches the installer with the path that was dropped, and installing for + -- real costs two profile saves that verbosePackageInstall's own spec has + -- already paid for. + local path = fixtureDirectory .. "/mudlet-spec-there-is-no-such-drop.mpackage" + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + -- raised rather than called so that the handler registration in Other.lua + -- is what is being tested as well + raiseEvent("sysDropEvent", path, "mpackage", 10, 10, "main") + + local text = textFrom(mark) + assert.is_true(containsWrapped(text, "Installing '" .. path .. "' failed:"), text) + assert.is_false(packageInstalled("mudlet-spec-there-is-no-such-drop")) + end) + + it("hands on every kind of file Mudlet installs", function() + -- same trick as above, so that narrowing the list of suffixes Mudlet + -- accepts cannot go unnoticed + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + + for _, suffix in ipairs({"xml", "zip", "trigger"}) do + local path = fixtureDirectory .. "/mudlet-spec-there-is-no-such-drop." .. suffix + local mark = getLastLineNumber("main") + + packageDrop("sysDropEvent", path, suffix) + + local text = textFrom(mark) + assert.is_true(containsWrapped(text, "Installing '" .. path .. "' failed:"), suffix .. ": " .. text) + end + end) + + it("ignores a file whose type Mudlet does not install", function() + -- an install that arrives while a save is running is postponed and would + -- land after this spec rather than in it + assert.is_true(waitForProfileSaveToPass(), "a profile save was still running") + local mark = getLastLineNumber("main") + + assert.equals(0, select('#', packageDrop("sysDropEvent", fixtureDirectory .. "/" .. minimalPackage .. ".mpackage", "exe"))) + + assert.is_false(packageInstalled(minimalPackage)) + -- an install that was attempted says so either way round, so neither + -- announcement having been made is what proves the drop was turned away + local text = textFrom(mark) + assert.is_false(containsWrapped(text, "installed successfully."), text) + assert.is_false(containsWrapped(text, "failed:"), text) + end) +end) + +describe("Tests the functionality of packageUrlDrop", function() + -- installPackageFromUrl() announces the download while the call is still on + -- the stack, so that line on the console separates a drop that was passed on + -- from one that was turned away without any waiting. Nothing listens on the + -- port below, so the download a passed-on drop starts cannot leave the + -- machine. + local droppedUrl = "http://127.0.0.1:1/mudlet-spec-dropped.mpackage" + + local function announcedADownload(mark) + return containsWrapped(textFrom(mark), "Downloading package from") + end + + it("hands a dropped package URL to the downloader", function() + defer(function() os.remove(getMudletHomeDir() .. "/mudlet-spec-dropped.mpackage") end) + local mark = getLastLineNumber("main") + + packageUrlDrop("sysDropUrlEvent", droppedUrl, "http") + + assert.is_true(containsWrapped(textFrom(mark), "Downloading package from " .. droppedUrl), textFrom(mark)) + -- let the refused connection be reported here rather than in a later spec + waitForEvent("sysDownloadError", 5000) + assert.is_false(packageInstalled("mudlet-spec-dropped")) + end) + + it("ignores a URL whose scheme it does not handle", function() + -- the scheme is a separate argument from the URL, so the URL is one the + -- spec above proved would otherwise be downloaded + local mark = getLastLineNumber("main") + + assert.equals(0, select('#', packageUrlDrop("sysDropUrlEvent", droppedUrl, "ftp"))) + + assert.is_false(announcedADownload(mark)) + end) + + it("does not download a URL that is not a package file", function() + -- no save to wait for: a URL with the wrong suffix is handed to the plain + -- installer, which gives up on opening it as a file before installing + -- anything + local mark = getLastLineNumber("main") + + packageUrlDrop("sysDropUrlEvent", "http://127.0.0.1:1/mudlet-spec-not-a-package.txt", "http") + + assert.is_false(announcedADownload(mark)) + end) +end) + +describe("The package specs clean up after themselves", function() + it("leaves no fixture package, module or folder behind", function() + for _, name in ipairs(getPackages()) do + assert.is_nil(name:find("mudlet%-spec%-"), "left the package " .. name .. " installed") + end + for _, name in ipairs(getModules()) do + assert.is_nil(name:find("mudlet%-spec%-"), "left the module " .. name .. " installed") + end + for entry in lfs.dir(getMudletHomeDir()) do + assert.is_nil(entry:find("mudlet%-spec%-"), "left the folder " .. entry .. " behind") + end + assert.is_false(fileExists(scratchDirectory), "left the fixture scratch folder behind") + + -- A profile save that catches the sync spec's module while syncing is on + -- copies it into the shared module backup folder. That is Mudlet working as + -- intended rather than a leak to fail the run over, but the copy is this + -- file's to take away again. + local configurationDirectory = getMudletHomeDir():match("^(.*)[/\\]profiles[/\\]") + assert.is_string(configurationDirectory, "could not work out the configuration folder from " .. getMudletHomeDir()) + local backups = configurationDirectory .. "/moduleBackups" + if fileExists(backups) then + for entry in lfs.dir(backups) do + if entry:find("mudlet%-spec%-") then + os.remove(backups .. "/" .. entry) + end + end + end + + -- Let the profile save that the last uninstall queued run while the profile + -- is still up, rather than leaving it to be stopped by the profile close. + pumpEvents(1500) + end) +end) diff --git a/src/mudlet-lua/tests/README.md b/src/mudlet-lua/tests/README.md index a3207b826..ed12ab0ea 100644 --- a/src/mudlet-lua/tests/README.md +++ b/src/mudlet-lua/tests/README.md @@ -76,15 +76,17 @@ run is not cleaned up. To give a run its own pristine, isolated config root: - `XDG_CONFIG_HOME` - Mudlet uses `$XDG_CONFIG_HOME/mudlet` as its config root (profiles, sqlite databases, settings, and password storage). Because an - existing `~/.config/mudlet` otherwise wins (so a system-wide `XDG_CONFIG_HOME` - export never strands real profiles), a test harness must **pre-create** - `$XDG_CONFIG_HOME/mudlet` to opt in. + existing `~/.config/mudlet` holding profiles otherwise wins (so a system-wide + `XDG_CONFIG_HOME` export never strands real profiles), a test harness must + **pre-create** `$XDG_CONFIG_HOME/mudlet/profiles` to opt in. The `mudlet` + directory on its own is not enough - other tooling creates that by accident, + and treating it as an opt-in would hide the user's real profiles. - `MUDLET_TEST_FAILURE_MARKER` - absolute path for the failure marker, so it is not shared either. ```sh CONFIG_DIR=$(mktemp -d) -mkdir -p "$CONFIG_DIR/mudlet" # pre-create to opt into the isolated config root +mkdir -p "$CONFIG_DIR/mudlet/profiles" # pre-create to opt into the isolated config root AUTORUN_BUSTED_TESTS=true \ MUDLET_TEST_MODE=1 \ QUIT_MUDLET_AFTER_TESTS=true \ diff --git a/src/mudlet-lua/tests/Spawn_spec.lua b/src/mudlet-lua/tests/Spawn_spec.lua new file mode 100644 index 000000000..684e7871f --- /dev/null +++ b/src/mudlet-lua/tests/Spawn_spec.lua @@ -0,0 +1,70 @@ +-- Every spawn() error path longjmps out of C++ code that owns heap: the +-- program name, the accumulated argument list and the failure message. These +-- drive each path so LeakSanitizer fails the build if one starts stranding +-- again, and pin the messages while doing it. +-- +-- Only the failing paths are exercised - a successful spawn would leave a real +-- child process behind for the rest of the suite. + +describe("spawn", function() + + describe("argument checking", function() + + it("should reject a call with no process name", function() + local ok, err = pcall(spawn, function() end) + assert.is_false(ok) + assert.are.equal("Need read function and process name as parameters.", err) + end) + + it("should reject a first argument that is not a function", function() + local ok, err = pcall(spawn, "not a function", "echo") + assert.is_false(ok) + assert.are.equal("Need read function as first parameter.", err) + end) + + it("should reject a non-string process name", function() + local ok, err = pcall(spawn, function() end, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("bad argument #2", 1, true)) + assert.is_truthy(tostring(err):find("string expected, got table", 1, true)) + end) + + -- the process name is already built and held when a later argument is + -- rejected, and every argument before the bad one is in the list too + it("should reject a non-string argument after valid ones", function() + local ok, err = pcall(spawn, function() end, "echo", "first", "second", {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("bad argument #5", 1, true)) + assert.is_truthy(tostring(err):find("string expected, got table", 1, true)) + end) + + it("should accept numbers where strings are expected, as Lua does", function() + -- coercible, so this gets past argument checking and fails on the binary + local ok, err = pcall(spawn, function() end, "/nonexistent/mudlet-spawn-test", 42) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("Failed to start process", 1, true)) + end) + + end) + + describe("start failure", function() + + -- the failure message embeds the program name, working directory and PATH, + -- so it is the largest thing this function ever holds at a raise + it("should report a binary that does not exist", function() + local ok, err = pcall(spawn, function() end, "/nonexistent/mudlet-spawn-test") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("Failed to start process '/nonexistent/mudlet-spawn-test'", 1, true)) + assert.is_truthy(tostring(err):find("Working directory:", 1, true)) + assert.is_truthy(tostring(err):find("PATH:", 1, true)) + end) + + it("should report a binary that does not exist when given arguments too", function() + local ok, err = pcall(spawn, function() end, "/nonexistent/mudlet-spawn-test", "one", "two", "three") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("Failed to start process", 1, true)) + end) + + end) + +end) diff --git a/src/mudlet-lua/tests/StringUtils_spec.lua b/src/mudlet-lua/tests/StringUtils_spec.lua index 35661cb52..b375ffb50 100644 --- a/src/mudlet-lua/tests/StringUtils_spec.lua +++ b/src/mudlet-lua/tests/StringUtils_spec.lua @@ -65,6 +65,14 @@ describe("Tests StringUtils.lua functions", function() local suffix = "system" assert.is_false(string.ends(s, suffix)) end) + + it("should return true for an empty suffix", function() + assert.is_true(("This is a test"):ends("")) + end) + + it("should return false when the suffix is longer than the string", function() + assert.is_false(("hi"):ends("this is far too long")) + end) end) describe("Tests the functionality of string.genNocasePattern", function() @@ -138,6 +146,25 @@ describe("Tests StringUtils.lua functions", function() local actual = str:split("") assert.same(expected, actual) end) + + it("should split on a multi-character delimiter", function() + local str = "alpha::beta::gamma" + local expected = { "alpha", "beta", "gamma" } + assert.same(expected, str:split("::")) + end) + + it("should treat the delimiter as a Lua pattern, not a plain string", function() + -- '.' is the 'any character' pattern, so it does not split on literal dots; + -- the dot must be escaped to split on real dots. + assert.same({ "1", "2", "3" }, ("1.2.3"):split("%.")) + assert.are_not.same({ "1", "2", "3" }, ("1.2.3"):split(".")) + end) + + it("should produce empty leading and trailing segments when the delimiter is at the edges", function() + local str = ",a,b," + local expected = { "", "a", "b", "" } + assert.same(expected, str:split(",")) + end) end) describe("Tests the functionality of string.starts", function() @@ -150,6 +177,15 @@ describe("Tests StringUtils.lua functions", function() local str = "This is a test" assert.is_false(str:starts("Elephant")) end) + + it("should return true for an empty prefix", function() + assert.is_true(("This is a test"):starts("")) + end) + + it("should return true when the prefix is the whole string", function() + local str = "This is a test" + assert.is_true(str:starts(str)) + end) end) describe("Tests the functionality of string.title", function() @@ -171,6 +207,14 @@ describe("Tests StringUtils.lua functions", function() local errfn = function() string.title(str) end assert.has_error(errfn, "string.title: bad argument #1 type (string to title as string expected, got table!)") end) + + it("should return an empty string unchanged", function() + assert.equals("", string.title("")) + end) + + it("should leave a string that does not start with a lowercase letter unchanged", function() + assert.equals("123abc", string.title("123abc")) + end) end) describe("Tests the functionality of string.trim", function() @@ -195,6 +239,10 @@ describe("Tests StringUtils.lua functions", function() assert.equals(str, string.trim(str)) assert.equals(str, str:trim()) end) + + it("should strip leading and trailing tabs and newlines, not just spaces", function() + assert.equals("this is a test", ("\t\n this is a test \n\t"):trim()) + end) end) describe("Tests the functionality of string.patternEscape", function() diff --git a/src/mudlet-lua/tests/TBufferOSC_spec.lua b/src/mudlet-lua/tests/TBufferOSC_spec.lua index 8258f537e..2ca6d08d5 100644 --- a/src/mudlet-lua/tests/TBufferOSC_spec.lua +++ b/src/mudlet-lua/tests/TBufferOSC_spec.lua @@ -1,9 +1,22 @@ --- Test for OSC sequence buffer underflow protection --- This test verifies the fix for a buffer underflow bug in TBuffer::translateToPlainText() --- when processing OSC (Operating System Command) sequences at the beginning of a buffer. +-- How TBuffer::translateToPlainText() handles the out-of-band sequences that +-- arrive mixed into the game's text: OSC, the DCS/SOS/PM/APC string sequences +-- and escape sequences that Mudlet does not act on. describe("Tests TBuffer OSC sequence handling", function() - + + -- feedTriggers writes to the main console; fish the line carrying our + -- unique marker back out of the buffer to see what actually rendered + local function findRecentLine(needle) + local lastLine = getLastLineNumber("main") + local lines = getLines("main", math.max(0, lastLine - 15), lastLine + 1) + for i = #lines, 1, -1 do + if lines[i]:find(needle, 1, true) then + return lines[i] + end + end + return nil + end + describe("Tests the protection against buffer underflow in OSC sequences", function() it("should handle OSC sequences at buffer start without crashing", function() @@ -95,19 +108,6 @@ describe("Tests TBuffer OSC sequence handling", function() describe("Tests ANSI string sequence handling (DCS, SOS, PM, APC)", function() - -- feedTriggers writes to the main console; fish the line carrying our - -- unique marker back out of the buffer to see what actually rendered - local function findRecentLine(needle) - local lastLine = getLastLineNumber("main") - local lines = getLines("main", math.max(0, lastLine - 15), lastLine + 1) - for i = #lines, 1, -1 do - if lines[i]:find(needle, 1, true) then - return lines[i] - end - end - return nil - end - it("should swallow an APC sequence terminated by ST", function() assert.is_true(feedTriggers("APCST1(\027_secret apc payload\027\\)APCST1\n")) assert.equals("APCST1()APCST1", findRecentLine("APCST1")) @@ -151,4 +151,125 @@ describe("Tests TBuffer OSC sequence handling", function() end) + describe("Tests escape sequences that Mudlet does not handle", function() + + local previousEncoding + + -- these tests are not encoding agnostic: feedTriggers transcodes its UTF-8 + -- argument into the server encoding, so under anything else the "\195\169" + -- pairs below reach the parser as a single byte and stop exercising the + -- multibyte lead byte that it must not swallow + setup(function() + previousEncoding = getServerEncoding() + setServerEncoding("UTF-8") + end) + + teardown(function() + setServerEncoding(previousEncoding) + end) + + it("should consume the two-byte escapes it recognises", function() + assert.is_true(feedTriggers("TWOBYTE1(\027" .. "7|\027" .. "8|\027c)TWOBYTE1\n")) + assert.equals("TWOBYTE1(||)TWOBYTE1", findRecentLine("TWOBYTE1")) + end) + + it("should keep the byte of a two-byte escape it does not recognise", function() + assert.is_true(feedTriggers("UNKNOWN1(\027M\027D\027>\027=)UNKNOWN1\n")) + assert.equals("UNKNOWN1(MD>=)UNKNOWN1", findRecentLine("UNKNOWN1")) + end) + + it("should consume a character set designation", function() + assert.is_true(feedTriggers("CHARSET1(\027(B)CHARSET1\n")) + assert.equals("CHARSET1()CHARSET1", findRecentLine("CHARSET1")) + end) + + it("should not let a CSI introducer name a character set and start a CSI", function() + assert.is_true(feedTriggers("GUARD1(\027([31mred\027[0m)GUARD1\n")) + assert.equals("GUARD1(31mred)GUARD1", findRecentLine("GUARD1")) + end) + + it("should not let an APC introducer name a character set and start a string sequence", function() + assert.is_true(feedTriggers("GUARD2(\027(_payload)GUARD2\n")) + assert.equals("GUARD2(payload)GUARD2", findRecentLine("GUARD2")) + end) + + it("should restart the sequence when an escape follows a designation", function() + assert.is_true(feedTriggers("RELATCH1(\027(\027[31mred\027[0m)RELATCH1\n")) + assert.equals("RELATCH1(red)RELATCH1", findRecentLine("RELATCH1")) + end) + + it("should keep the letter after a stray escape", function() + assert.is_true(feedTriggers("STRAY1(\027ABC)STRAY1\n")) + assert.equals("STRAY1(ABC)STRAY1", findRecentLine("STRAY1")) + end) + + it("should keep the digit after a stray escape", function() + assert.is_true(feedTriggers("STRAY2(\027" .. "1234)STRAY2\n")) + assert.equals("STRAY2(1234)STRAY2", findRecentLine("STRAY2")) + end) + + it("should keep the text after several stray escapes", function() + assert.is_true(feedTriggers("STRAY3(\027A\027BCD)STRAY3\n")) + assert.equals("STRAY3(ABCD)STRAY3", findRecentLine("STRAY3")) + end) + + it("should keep a run of punctuation after a stray escape", function() + assert.is_true(feedTriggers("PUNCT1(\027--- Hello)PUNCT1\n")) + assert.equals("PUNCT1(--- Hello)PUNCT1", findRecentLine("PUNCT1")) + end) + + it("should keep a space after a stray escape", function() + assert.is_true(feedTriggers("PUNCT2(\027 spaced)PUNCT2\n")) + assert.equals("PUNCT2( spaced)PUNCT2", findRecentLine("PUNCT2")) + end) + + it("should keep a multibyte character that follows a stray escape", function() + assert.is_true(feedTriggers("UTF8ESC1(caf\027\195\169)UTF8ESC1\n")) + assert.equals("UTF8ESC1(caf\195\169)UTF8ESC1", findRecentLine("UTF8ESC1")) + end) + + it("should keep a multibyte character that cannot name a character set", function() + assert.is_true(feedTriggers("UTF8ESC2(\027(\195\169)UTF8ESC2\n")) + assert.equals("UTF8ESC2(\195\169)UTF8ESC2", findRecentLine("UTF8ESC2")) + end) + + it("should keep a line break that follows a stray escape", function() + assert.is_true(feedTriggers("NLESC1(\027\nNLESC2)\n")) + assert.equals("NLESC1(", findRecentLine("NLESC1")) + assert.equals("NLESC2)", findRecentLine("NLESC2")) + end) + + it("should keep a line break that cannot name a character set", function() + assert.is_true(feedTriggers("NLESC3(\027(\nNLESC4)\n")) + assert.equals("NLESC3(", findRecentLine("NLESC3")) + assert.equals("NLESC4)", findRecentLine("NLESC4")) + end) + + it("should apply a trailing escape to the next packet", function() + assert.is_true(feedTriggers("SPLITESC1(\027")) + assert.is_true(feedTriggers("7 then ABC)SPLITESC1\n")) + assert.equals("SPLITESC1( then ABC)SPLITESC1", findRecentLine("SPLITESC1")) + end) + + it("should apply a trailing designation to the next packet", function() + assert.is_true(feedTriggers("SPLITINT1(\027(")) + assert.is_true(feedTriggers("B)SPLITINT1\n")) + assert.equals("SPLITINT1()SPLITINT1", findRecentLine("SPLITINT1")) + end) + + it("should not eat a multibyte character starting the next packet", function() + assert.is_true(feedTriggers("SPLITESC2(\027")) + assert.is_true(feedTriggers("\195\169)SPLITESC2\n")) + assert.equals("SPLITESC2(\195\169)SPLITESC2", findRecentLine("SPLITESC2")) + end) + + it("should keep an 8-bit character that follows a stray escape", function() + setServerEncoding("ISO 8859-1") + assert.is_true(feedTriggers("LATIN1(\027\195\169)LATIN1\n")) + assert.equals("LATIN1(\195\169)LATIN1", findRecentLine("LATIN1")) + setServerEncoding("UTF-8") + end) + + end) + end) diff --git a/src/mudlet-lua/tests/TableUtils_spec.lua b/src/mudlet-lua/tests/TableUtils_spec.lua index 9a01d15a9..46f0b4483 100644 --- a/src/mudlet-lua/tests/TableUtils_spec.lua +++ b/src/mudlet-lua/tests/TableUtils_spec.lua @@ -105,8 +105,8 @@ describe("Tests TableUtils.lua functions", function() end) end) - -- methods skipped here: printTable, _printTable, listPrint, listAdd, listRemove - -- they are undocumented and unused in our own code. + -- printTable, listPrint, __printTable, listAdd and listRemove are covered + -- near the end of this file. describe("Tests the functionality of table.size", function() @@ -263,7 +263,31 @@ describe("Tests TableUtils.lua functions", function() local errfn = function() table.n_collect(tbl, func) end - assert.has_error(errfn, "table.n_collect: bad argument #2 type (function to run against each item in tbl as function expected, got string)") + assert.has_error(errfn, "table.n_collect: bad argument #2 type (function to run against each item in tbl as function expected, got string)") + end) + + it("should keep a value that is equal to an index already collected", function() + local actual = table.n_collect({ "a", 1 }, function() return true end) + table.sort(actual, function(a, b) return tostring(a) < tostring(b) end) + assert.are.same({ 1, "a" }, actual) + end) + + it("should keep a value that also appears inside a nested table", function() + local actual = table.n_collect({ { "z" }, "z" }, function() return true end) + assert.are.equal(2, #actual) + local nested, plain + for _, value in ipairs(actual) do + if type(value) == "table" then nested = value else plain = value end + end + assert.are.same({ "z" }, nested) + assert.are.equal("z", plain) + end) + + it("should still drop real duplicates", function() + local actual = table.n_collect({ 5, "x", 5, "x" }, function() return true end) + assert.are.equal(2, #actual) + table.sort(actual, function(a, b) return tostring(a) < tostring(b) end) + assert.are.same({ 5, "x" }, actual) end) end) @@ -510,6 +534,63 @@ describe("Tests TableUtils.lua functions", function() end) + -- table.contains is a loop over table._contains, one pass per value it was + -- asked about. Everything the search itself does lives in _contains, and it + -- is the only one of the two that reports being handed something that is not + -- a table: table.contains treats that report as "not found". + describe("Tests the functionality of table._contains", function() + + it("should return true for a value in the table", function() + assert.is_true(table._contains({"one", "two"}, "two")) + end) + + it("should return true for a key in the table", function() + assert.is_true(table._contains({one = 1, two = 2}, "two")) + end) + + it("should find a value nested inside another table", function() + assert.is_true(table._contains({outer = {inner = {"needle"}}}, "needle")) + assert.is_true(table._contains({outer = {inner = {"needle"}}}, "inner")) + end) + + it("should return false for something the table does not hold", function() + assert.is_false(table._contains({one = 1}, "two")) + end) + + it("should return false for an empty table", function() + assert.is_false(table._contains({}, "anything")) + end) + + it("should report being handed something that is not a table", function() + local found, message = table._contains("not a table", "anything") + assert.is_nil(found) + assert.are.equal("first parameter passed isn't a table", message) + + found, message = table._contains(nil, "anything") + assert.is_nil(found) + assert.are.equal("first parameter passed isn't a table", message) + end) + + it("should let table.contains turn that report into a plain false", function() + -- the caller of table.contains never sees the message, so a script that + -- wants to know it passed a table has to ask _contains + assert.is_false(table.contains("not a table", "anything")) + end) + + it("should search for exactly one value, unlike table.contains", function() + -- table.contains loops over its extra arguments, _contains ignores them + assert.is_false(table._contains({"one"}, "two", "one")) + assert.is_true(table.contains({"one"}, "two", "one")) + end) + + it("should find a false value stored in the table", function() + -- returning the search result rather than the value found is what makes + -- a stored false distinguishable from "not there" + assert.is_true(table._contains({flag = false}, false)) + assert.is_false(table._contains({flag = true}, false)) + end) + end) + describe("Tests the functionality of table.index_of", function() it("should return the index of the item being searched", function() local tbl = { @@ -644,6 +725,24 @@ describe("Tests TableUtils.lua functions", function() local actual = table.union(tblA, tblB, tblC) assert.same(expected,actual) end) + + it("should not modify a table it was given", function() + local first = { key = { 1, 2 } } + local actual = table.union(first, { key = 5 }) + assert.same({ { 1, 2 }, 5 }, actual.key) + assert.same({ 1, 2 }, first.key) + assert.is_false(rawequal(actual.key, first.key)) + end) + + it("should collect a colliding false into a subtable", function() + local actual = table.union({ key = false }, { key = 7 }) + assert.same({ false, 7 }, actual.key) + end) + + it("should append a third colliding value to the same subtable", function() + local actual = table.union({ key = 1 }, { key = 2 }, { key = 3 }) + assert.same({ 1, 2, 3 }, actual.key) + end) end) describe("Tests the functionality of table.n_union", function() @@ -796,4 +895,204 @@ describe("Tests TableUtils.lua functions", function() assert.same(expected, actual) end) end) + + describe("Tests the functionality of table.deepcopy nested independence", function() + it("should copy nested tables so mutating the copy does not affect the original", function() + local original = { a = 1, nested = { b = 2, deep = { c = 3 } } } + local copy = table.deepcopy(original) + copy.nested.b = 20 + copy.nested.deep.c = 30 + assert.equals(2, original.nested.b) + assert.equals(3, original.nested.deep.c) + -- the nested tables are distinct references + assert.are_not.equal(original.nested, copy.nested) + assert.are_not.equal(original.nested.deep, copy.nested.deep) + end) + + it("should preserve the metatable of the copied table", function() + local mt = { __index = function() return "default" end } + local original = setmetatable({}, mt) + local copy = table.deepcopy(original) + assert.equals(mt, getmetatable(copy)) + assert.equals("default", copy.anything) + end) + + it("should return non-table values unchanged", function() + assert.equals(5, table.deepcopy(5)) + assert.equals("text", table.deepcopy("text")) + end) + end) + + describe("Tests the functionality of spairs on an empty table", function() + it("should iterate zero times over an empty table", function() + local count = 0 + for _ in spairs({}) do + count = count + 1 + end + assert.equals(0, count) + end) + end) + + describe("Tests the functionality of listAdd", function() + it("should append an item to the end of the list", function() + local list = { "one", "two" } + listAdd(list, "three") + assert.same({ "one", "two", "three" }, list) + end) + + it("should append to an empty list", function() + local list = {} + listAdd(list, "only") + assert.same({ "only" }, list) + end) + end) + + describe("Tests the functionality of listRemove", function() + it("should remove a matching item from the list", function() + local list = { "one", "two", "three" } + listRemove(list, "two") + assert.same({ "one", "three" }, list) + end) + + it("should leave the list unchanged when the item is not present", function() + local list = { "one", "two" } + listRemove(list, "missing") + assert.same({ "one", "two" }, list) + end) + + it("should leave an empty list empty", function() + local list = {} + listRemove(list, "x") + assert.same({}, list) + end) + + it("should remove the sole element when it matches", function() + local list = { "x" } + listRemove(list, "x") + assert.same({}, list) + end) + + -- #9546: removal used to happen during an ipairs loop, so deleting index i + -- shifted i+1 down into i, which the loop then skipped, leaving one of each + -- run of consecutive duplicates behind. + it("should remove a pair of consecutive duplicate matches", function() + local list = { "a", "x", "x", "b" } + listRemove(list, "x") + assert.same({ "a", "b" }, list) + end) + + it("should remove a run of three or more consecutive duplicates", function() + local list = { "x", "x", "x" } + listRemove(list, "x") + assert.same({}, list) + end) + + it("should remove every match whether the duplicates are adjacent or apart", function() + local list = { "x", "a", "x", "x", "b", "x" } + listRemove(list, "x") + assert.same({ "a", "b" }, list) + end) + end) + + describe("Tests the contract of printTable", function() + -- printTable/listPrint write to the screen via echo; we spy on the real + -- echo (pass-through) to assert the framing lines without mocking it. + it("should echo a header, a line per key/value pair and a footer", function() + local echo = spy.on(_G, "echo") + finally(function() echo:revert() end) + printTable({ alpha = "one", beta = "two" }) + -- header + 2 pairs + footer; header and footer are the same dashed string, + -- so the count is what pins that both framing lines are present + assert.spy(echo).was.called(4) + assert.spy(echo).was.called_with("-------------------------------------------------------\n") + assert.spy(echo).was.called_with("key=alpha value=one\n") + assert.spy(echo).was.called_with("key=beta value=two\n") + end) + + it("should render a value that is neither a string nor a number", function() + local echo = spy.on(_G, "echo") + finally(function() echo:revert() end) + local nested = {} + printTable({ flag = true, nested = nested, fn = print }) + assert.spy(echo).was.called(5) + assert.spy(echo).was.called_with("key=flag value=true\n") + assert.spy(echo).was.called_with("key=nested value=" .. tostring(nested) .. "\n") + assert.spy(echo).was.called_with("key=fn value=" .. tostring(print) .. "\n") + end) + + it("should render a key that is neither a string nor a number", function() + local echo = spy.on(_G, "echo") + finally(function() echo:revert() end) + local key = {} + printTable({ [key] = "one", [true] = "two" }) + assert.spy(echo).was.called(4) + assert.spy(echo).was.called_with("key=" .. tostring(key) .. " value=one\n") + assert.spy(echo).was.called_with("key=true value=two\n") + end) + + it("should not raise on a table of mixed value types", function() + local echo = spy.on(_G, "echo") + finally(function() echo:revert() end) + assert.has_no.errors(function() printTable({ 1, "two", true, {}, print }) end) + assert.has_no.errors(function() printTable({}) end) + end) + + it("should name itself when it is not given a table", function() + assert.has_error(function() printTable(nil) end, + 'printTable: bad argument #1 type (table expected, got nil!)') + assert.has_error(function() listPrint("not a table") end, + 'listPrint: bad argument #1 type (table expected, got string!)') + end) + end) + + describe("Tests the contract of listPrint", function() + it("should echo a numbered line for each list entry framed by dashed lines", function() + local echo = spy.on(_G, "echo") + finally(function() echo:revert() end) + listPrint({ "first", "second" }) + -- header + 2 entries + footer + assert.spy(echo).was.called(4) + assert.spy(echo).was.called_with("1. ) first\n") + assert.spy(echo).was.called_with("2. ) second\n") + end) + + it("should render entries that are neither strings nor numbers", function() + local echo = spy.on(_G, "echo") + finally(function() echo:revert() end) + local nested = {} + listPrint({ true, nested }) + assert.spy(echo).was.called(4) + assert.spy(echo).was.called_with("1. ) true\n") + assert.spy(echo).was.called_with("2. ) " .. tostring(nested) .. "\n") + end) + end) + + describe("Tests the contract of __printTable", function() + -- __printTable is documented as printTable's helper but printTable never + -- calls it; it is a standalone one pair formatter reachable from scripts, + -- writing into the main console at the cursor + it("should insert a newline terminated key and value pair", function() + local insertText = spy.on(_G, "insertText") + finally(function() insertText:revert() end) + __printTable("alpha", "one") + assert.spy(insertText).was.called(1) + assert.spy(insertText).was.called_with("\nkey = alpha value = one") + end) + + it("should tostring both the key and the value", function() + local insertText = spy.on(_G, "insertText") + finally(function() insertText:revert() end) + __printTable(3, true) + assert.spy(insertText).was.called_with("\nkey = 3 value = true") + end) + + it("should land the pair in the main console buffer", function() + clearWindow() + echo("a line for the cursor to sit on\n") + moveCursorEnd() + __printTable("visible", "value") + local text = table.concat(getLines("main", 0, getLastLineNumber("main") + 1), "\n") + assert.is_truthy(text:find("key = visible value = value", 1, true)) + end) + end) end) diff --git a/src/mudlet-lua/tests/Trigger_spec.lua b/src/mudlet-lua/tests/Trigger_spec.lua index df1b7e912..5a61a397f 100644 --- a/src/mudlet-lua/tests/Trigger_spec.lua +++ b/src/mudlet-lua/tests/Trigger_spec.lua @@ -127,6 +127,124 @@ describe("Trigger processing", function() end) + -- Color triggers must match the colors a line arrived with, even when an + -- earlier trigger in the same pass has already recolored it. The display + -- must still show the recolored version. Recoloring uses the same + -- TConsole::setFgColor path as the colorizer trigger checkbox, so this + -- covers both channels. + -- + -- The color trigger callbacks are string code because tempAnsiColorTrigger + -- does not run function callbacks when the expiry argument is omitted, and + -- assertions check containment because default-palette text also matches + -- ANSI white-on-black, so the triggers can fire on unrelated lines too. + describe("color trigger original-color matching", function() + + local function contains(list, value) + for _, v in ipairs(list) do + if v == value then + return true + end + end + return false + end + + it("should match original colors after an earlier trigger recolors the line", function() + _G.colorSnapshotMatches = {} + local highlighted = false + local lineNumber = nil + + local highlightTrigger = tempRegexTrigger("^ColorSnapshotTest$", function() + lineNumber = getLineNumber() + if selectString("ColorSnapshotTest", 1) > -1 then + setFgColor(255, 0, 0) + setBgColor(255, 255, 0) + highlighted = true + end + resetFormat() + end) + -- ANSI 7 = white foreground, ANSI 0 = black background + local colorTrigger = tempAnsiColorTrigger(7, 0, + [[table.insert(_G.colorSnapshotMatches, matches[1])]]) + + feedTriggers("\n\27[37;40mColorSnapshotTest\27[0m\n") + + local matched = contains(_G.colorSnapshotMatches, "ColorSnapshotTest") + killTrigger(highlightTrigger) + killTrigger(colorTrigger) + _G.colorSnapshotMatches = nil + + assert.is_true(highlighted, "Highlighting trigger should have run") + assert.is_true(matched, "Color trigger should match the original colors despite the recoloring") + + -- The display must keep the recolored version + moveCursor(0, lineNumber) + selectString("ColorSnapshotTest", 1) + local r, g, b = getFgColor() + deselect() + resetFormat() + assert.are.equal(255, r, "Display should show the recolored foreground") + assert.are.equal(0, g) + assert.are.equal(0, b) + end) + + it("should match original colors when the color trigger runs before the recoloring one", function() + _G.colorControlMatches = {} + local highlighted = false + + local colorTrigger = tempAnsiColorTrigger(7, 0, + [[table.insert(_G.colorControlMatches, matches[1])]]) + local highlightTrigger = tempRegexTrigger("^ColorSnapshotControl$", function() + if selectString("ColorSnapshotControl", 1) > -1 then + setFgColor(255, 0, 0) + highlighted = true + end + resetFormat() + end) + + feedTriggers("\n\27[37;40mColorSnapshotControl\27[0m\n") + + local matched = contains(_G.colorControlMatches, "ColorSnapshotControl") + killTrigger(colorTrigger) + killTrigger(highlightTrigger) + _G.colorControlMatches = nil + + assert.is_true(matched, "Color trigger should match when it runs first") + assert.is_true(highlighted, "Highlighting trigger should have run") + end) + + it("should keep the outer line's original colors across a nested feedTriggers", function() + _G.innerSnapshotMatches = {} + _G.outerSnapshotMatches = {} + + local outerTrigger = tempRegexTrigger("^OuterSnapshotLine$", function() + if selectString("OuterSnapshotLine", 1) > -1 then + setFgColor(0, 0, 255) + end + resetFormat() + -- ANSI 32/41 = green foreground on red background + feedTriggers("\n\27[32;41mInnerSnapshotLine\27[0m\n") + end) + local innerColorTrigger = tempAnsiColorTrigger(2, 1, + [[table.insert(_G.innerSnapshotMatches, matches[1])]]) + local outerColorTrigger = tempAnsiColorTrigger(7, 0, + [[table.insert(_G.outerSnapshotMatches, matches[1])]]) + + feedTriggers("\n\27[37;40mOuterSnapshotLine\27[0m\n") + + local innerMatched = contains(_G.innerSnapshotMatches, "InnerSnapshotLine") + local outerMatched = contains(_G.outerSnapshotMatches, "OuterSnapshotLine") + killTrigger(outerTrigger) + killTrigger(innerColorTrigger) + killTrigger(outerColorTrigger) + _G.innerSnapshotMatches = nil + _G.outerSnapshotMatches = nil + + assert.is_true(innerMatched, "Inner pass should match the inner line's original colors") + assert.is_true(outerMatched, "Outer pass should still match its original colors after the nested pass") + end) + + end) + describe("tempAnsiColorTrigger callbacks", function() it("should fire a function callback when the expiry argument is omitted", function() @@ -191,12 +309,10 @@ describe("Trigger processing", function() end) - -- feedTelnet only performs injection while the telnet socket is unconnected. - -- The self-test profile's socket is NOT in the unconnected state (the same - -- limitation MXP_spec documents), so feedTelnet refuses here with nil + a - -- message; its successful-injection and prompt paths cannot be exercised in - -- busted. The type-check happens before the connection check, so the - -- argument-type contract is still verifiable. + -- feedTelnet only performs injection while the telnet socket is unconnected; + -- otherwise it refuses with nil + a message. Its successful-injection and + -- prompt paths cannot be exercised in busted. The type-check happens before + -- the connection check, so the argument-type contract is still verifiable. describe("feedTelnet contract", function() it("raises an error when the data argument is not a string", function() @@ -206,9 +322,14 @@ describe("Trigger processing", function() it("refuses to inject while the socket is not unconnected", function() -- safety property: feedTelnet never injects into a live connection. - -- In the self-test profile the socket is not unconnected, so it must - -- return nil plus a refusal message rather than feeding. + -- Establish the precondition here rather than relying on the + -- profile's ambient socket state, which other specs may have cleared + -- with disconnect(): reconnect() starts a fresh lookup and leaves the + -- unconnected state synchronously, without the connection needing to + -- succeed. Restore a clean state afterwards with disconnect(). + reconnect() local ok, msg = feedTelnet("some server data") + disconnect() assert.is_nil(ok, "feedTelnet must not succeed against a non-unconnected socket") assert.is_string(msg) assert.is_truthy(msg:find("refused", 1, true), "expected a refusal message, got: " .. tostring(msg)) @@ -244,7 +365,6 @@ describe("Trigger processing", function() _G.TrigSpec = {count = 0} local id = tempExactMatchTrigger("exact_line_only", function() _G.TrigSpec.count = _G.TrigSpec.count + 1 end) assert.is_number(id) - -- superset line must NOT match an exact trigger feedTriggers("\nexact_line_only and more\n") assert.is_equal(0, _G.TrigSpec.count, "an exact-match trigger must not fire on a superset line") feedTriggers("\nexact_line_only\n") @@ -355,6 +475,25 @@ describe("Trigger processing", function() assert.is_equal(2, count, "a trigger set to expire after 2 fires should fire exactly twice") end) + -- Regression: the C++ helpers that run trigger/alias/script code used to + -- read the script's return value from an absolute stack slot and then + -- wipe the whole shared Lua stack. Running inside feedTriggers() those + -- slots hold feedTriggers' own arguments, so the Utf8Encoded boolean + -- below was mistaken for "the script returned true" and kept renewing + -- the expiry count, and the wipe took the caller's arguments with it. + it("expires on schedule when fed by a call that has arguments on the Lua stack", function() + _G.TrigSpecExpire = {count = 0} + local id = tempTrigger("expire_me_utf8", [[_G.TrigSpecExpire.count = _G.TrigSpecExpire.count + 1]], 1) + assert.is_number(id) + feedTriggers("\nexpire_me_utf8\n", true) + feedTriggers("\nexpire_me_utf8\n", true) + feedTriggers("\nexpire_me_utf8\n", true) + local count = _G.TrigSpecExpire.count + _G.TrigSpecExpire = nil + if type(id) == "number" and id > 0 then killTrigger(id) end + assert.is_equal(1, count, "a trigger set to expire after 1 fire must not be renewed by the caller's stack") + end) + end) describe("tempColorTrigger legacy colour remap", function() @@ -592,6 +731,77 @@ describe("Trigger processing", function() assert.is_false(killTrigger("no_such_trigger_name")) end) + it("killTrigger returns false the second time, as the trigger is already dead", function() + local id = tempRegexTrigger("^double_kill_probe$", [[]]) + assert.is_true(killTrigger(id), "killing a live temporary trigger should report success") + -- the trigger is still present here: only the deferred cleanup frees it, + -- so the second kill really is being told about a corpse it can find + assert.is_equal(1, exists(id, "trigger"), "the killed trigger is still present until cleanup runs") + assert.is_equal(0, isActive(id, "trigger"), "a killed trigger is no longer active") + assert.is_false(killTrigger(id), + "killing an already killed trigger achieves nothing and has to say so") + -- a fed line runs that cleanup, and the answer has to be the same after it + feedTriggers("\ndouble_kill_flush\n") + assert.is_equal(0, exists(id, "trigger"), "the trigger should be gone after kill and cleanup") + assert.is_false(killTrigger(id), "a freed trigger cannot be killed either") + end) + + it("a trigger killed earlier in a line's pass does not fire on that line", function() + _G.TrigSpec = {count = 0, witness = 0} + -- the killer is created first, so the trigger unit reaches it first and + -- its victim is still in the list this pass is walking; only the cleanup + -- at the end of the line frees the victim. The witness is created last so + -- that it proves the pass really did carry on past the killer + local victimId + local killerId = tempRegexTrigger("^kill_stops_firing$", function() + _G.TrigSpec.killed = killTrigger(victimId) + end) + victimId = tempRegexTrigger("^kill_stops_firing$", function() + _G.TrigSpec.count = _G.TrigSpec.count + 1 + end) + local witnessId = tempRegexTrigger("^kill_stops_firing$", function() + _G.TrigSpec.witness = _G.TrigSpec.witness + 1 + end) + feedTriggers("\nkill_stops_firing\n") + killTrigger(killerId) + killTrigger(witnessId) + assert.is_true(_G.TrigSpec.killed, "the first trigger should have killed the second") + assert.is_equal(1, _G.TrigSpec.witness, "the line should still reach triggers behind the killer") + assert.is_equal(0, _G.TrigSpec.count, + "a killed trigger must no more fire on the rest of the line than a disabled one does") + end) + + it("killTrigger returns false for a trigger that has used up its last firing", function() + -- an expiring trigger queues itself for the same deferred cleanup a killed + -- one does, so it is just as dead - as killTimer reports for a one-shot + -- timer that has already fired + _G.TrigSpec = {} + local expiringId = tempRegexTrigger("^expiry_kill_probe$", [[]], 1) + local killerId = tempRegexTrigger("^expiry_kill_probe$", function() + _G.TrigSpec.killedExpired = killTrigger(expiringId) + end) + feedTriggers("\nexpiry_kill_probe\n") + killTrigger(killerId) + assert.is_not_nil(_G.TrigSpec.killedExpired, "the killing trigger should have fired") + assert.is_false(_G.TrigSpec.killedExpired, + "a trigger that just used up its last firing cannot be killed again") + end) + + it("killTrigger returns false the second time inside the trigger's own script", function() + _G.TrigSpec = {} + local id + id = tempRegexTrigger("^self_kill_probe$", function() + _G.TrigSpec.killed = killTrigger(id) + _G.TrigSpec.killedAgain = killTrigger(id) + end) + feedTriggers("\nself_kill_probe\n") + assert.is_not_nil(_G.TrigSpec.killed, "the trigger should have fired") + assert.is_true(_G.TrigSpec.killed, + "killTrigger should report success from inside the trigger's own script") + assert.is_false(_G.TrigSpec.killedAgain, + "killing the same trigger twice from its own script must fail the second time") + end) + it("exists rejects an invalid item type", function() local ok, err = exists(1, "notarealtype") assert.is_nil(ok) @@ -723,4 +933,106 @@ describe("Trigger processing", function() end) end) + + -- The delete of an expired or killed trigger is deferred until the outermost + -- processDataStream() pass ends, so everything between the queueing and the + -- free has to behave as if the trigger were already gone. + describe("deferred deletion", function() + + it("does not let an expired trigger fire again from a nested feed", function() + local fires = 0 + -- expireAfter = 1, so this must fire exactly once no matter how many + -- lines reach it + tempRegexTrigger("^expiry_reentry$", function() fires = fires + 1 end, 1) + + local nestedFed = false + local reentrantId = tempRegexTrigger("^expiry_reentry$", function() + if not nestedFed then + nestedFed = true + -- re-enters trigger processing while the expired trigger is + -- still queued for deletion + feedTriggers("\nexpiry_reentry\n") + end + end) + finally(function() killTrigger(reentrantId) end) + + feedTriggers("\nexpiry_reentry\n") + + assert.is_true(nestedFed, "the re-entrant trigger should have fed a nested line") + assert.are.equal(1, fires, "a trigger with expireAfter = 1 must not fire a second time") + end) + + it("does not let an expiring trigger fire again from its own nested feed", function() + -- A separate defect from the one above, found while fixing it: the + -- expiry count is decremented at the end of match(), after execute() + -- has run, so a trigger whose own script re-feeds the matching line is + -- still at its old count and still active when the nested pass reaches + -- it. Fixing that means moving the expiry accounting ahead of + -- execute(), which also has to keep the "return true to extend the + -- expiry" contract working - out of scope for the deactivate() fix. + pending("expiry is accounted after execute(), so a self-refeeding trigger overshoots expireAfter") + local fires = 0 + local nestedFed = false + tempRegexTrigger("^self_expiry_reentry$", function() + fires = fires + 1 + if not nestedFed then + nestedFed = true + feedTriggers("\nself_expiry_reentry\n") + end + end, 1) + + feedTriggers("\nself_expiry_reentry\n") + + assert.is_true(nestedFed) + assert.are.equal(1, fires, "a trigger with expireAfter = 1 must not fire a second time") + end) + + it("does not let enableTrigger revive a trigger that is waiting to be freed", function() + -- a killed trigger stays findable by name until the deferred delete + -- runs, so enabling it again would resurrect it + local name = "Spec Enable Resurrection" + _G.EnableResurrectionSpec = 0 + finally(function() _G.EnableResurrectionSpec = nil end) + + tempComplexRegexTrigger(name, "^enable_resurrection$", + [[_G.EnableResurrectionSpec = _G.EnableResurrectionSpec + 1]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + assert.is_true(killTrigger(name)) + assert.is_false(enableTrigger(name), "a killed trigger must not be re-enabled before it is freed") + + feedTriggers("\nenable_resurrection\n") + + assert.are.equal(0, _G.EnableResurrectionSpec, "a killed trigger must not fire, whatever enableTrigger was told") + end) + + it("keeps a same-named permanent trigger in the lookup table", function() + local name = "Spec Name Eviction" + _G.NameEvictionSpec = 0 + finally(function() + disableTrigger(name) + _G.NameEvictionSpec = nil + end) + + -- permanent triggers cannot be deleted from Lua, so earlier local runs + -- leave same-named ones behind: work from a relative baseline + assert.is_true(permRegexTrigger(name, "", {"^name_eviction_perm$"}, [[_G.NameEvictionSpec = (_G.NameEvictionSpec or 0) + 1]]) > 0) + local permanents = exists(name, "trigger") + assert.is_true(permanents >= 1) + + -- tempComplexRegexTrigger is the one temporary-trigger API that takes a + -- user-supplied name, so sharing one with a permanent trigger is easy. + -- Note it copies the pattern list of the trigger it finds under that + -- name, so this temporary also carries ^name_eviction_perm$ - harmless + -- here, since it is killed before anything is fed + tempComplexRegexTrigger(name, "^name_eviction_temp$", [[]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + assert.are.equal(permanents + 1, exists(name, "trigger")) + + killTrigger(name) -- only the temporary one can be killed + feedTriggers("\nname_eviction_perm\n") -- the pass ends, flushing the deferred delete + + assert.are.equal(permanents, exists(name, "trigger"), "only the temporary trigger should leave the lookup table") + assert.is_true(_G.NameEvictionSpec >= 1, "the permanent trigger should still fire") + assert.is_true(disableTrigger(name), "the permanent trigger must still be reachable by name") + end) + + end) end) diff --git a/src/mudlet-lua/tests/UI_spec.lua b/src/mudlet-lua/tests/UI_spec.lua index 26518b920..578e8c285 100644 --- a/src/mudlet-lua/tests/UI_spec.lua +++ b/src/mudlet-lua/tests/UI_spec.lua @@ -133,11 +133,22 @@ describe("Tests UI functions", function() assert.are.equal(windowType("fake commandline"), nil) end) + it("Should identify a scroll box", function() + createScrollBox("testscrollbox", 0,0,100,100) + + assert.are.equal(windowType("testscrollbox"), "scrollbox") + end) + + it("Should not identify a non-existing scroll box", function() + assert.are.equal(windowType("fake scrollbox"), nil) + end) + teardown(function() deleteLabel("testlabel") hideWindow("testuserwindow") hideWindow("testminiconsole") disableCommandLine("testcommandline") + deleteScrollBox("testscrollbox") end) end) @@ -1457,6 +1468,61 @@ describe("Tests UI functions", function() end) end) + describe("Tests isAnsiFgColor/isAnsiBgColor error handling", function() + setup(function() + feedTriggers("isAnsiColor test text\n") + moveCursorUp() + selectCurrentLine() + end) + + teardown(function() + deselect() + moveCursorEnd() + end) + + it("isAnsiFgColor returns nil and a message for an out of range color code", function() + local ok, err = isAnsiFgColor(17) + assert.is_nil(ok) + assert.are.equal("ANSI color 17 out of range (0 to 16)", err) + + ok, err = isAnsiFgColor(-1) + assert.is_nil(ok) + assert.are.equal("ANSI color -1 out of range (0 to 16)", err) + end) + + it("isAnsiBgColor returns nil and a message for an out of range color code", function() + local ok, err = isAnsiBgColor(17) + assert.is_nil(ok) + assert.are.equal("ANSI color 17 out of range (0 to 16)", err) + + ok, err = isAnsiBgColor(-1) + assert.is_nil(ok) + assert.are.equal("ANSI color -1 out of range (0 to 16)", err) + end) + + it("isAnsiFgColor returns a boolean for a valid color code", function() + assert.is_boolean(isAnsiFgColor(0)) + end) + + it("isAnsiBgColor returns a boolean for a valid color code", function() + assert.is_boolean(isAnsiBgColor(0)) + end) + end) + + describe("Tests enableScrolling/disableScrolling error handling", function() + it("enableScrolling returns nil and a message for the main window", function() + local ok, err = enableScrolling("main") + assert.is_nil(ok) + assert.are.equal("scrolling cannot be enabled/disabled for the 'main' window", err) + end) + + it("disableScrolling returns nil and a message for the main window", function() + local ok, err = disableScrolling("main") + assert.is_nil(ok) + assert.are.equal("scrolling cannot be enabled/disabled for the 'main' window", err) + end) + end) + -- BaseUI.parseVitalsLine is the pure parser behind the starter UI's -- prompt/score vitals fallback (the base-ui package installs into fresh -- profiles, including the self-test one) @@ -1806,6 +1872,177 @@ describe("Tests UI functions", function() -- absurd magnitudes are ids or timestamps, never vitals assert.are.same({}, BaseUI.parseVitalsLine("Health: 1234567890123/9999999999999")) end) + + -- The readable sample. The exhaustive version - every label spelling + -- crossed with every layout - is in StarterUiTriggerCostTest.cpp, which + -- installs the package itself and so always runs. + describe("the vitals trigger prefilter", function() + local readableLines = { + -- prompt shapes, labels after and before the numbers + "<523/600hp 210/250m 80/100mv>", + "HP: 523/600 MP: 210/250", + "523/600hp", + "100hp", + "hp100/120", + "<87%hp 80%m>", + "hp: 87%", + "<523hp 210m 80mv>", + "1200/1500 tnl", + "End: 40/60", + "Stamina 40/60", + -- score screens + "Health : 523/600", + "Mana : 210/250", + "Moves : 80/100", + "Experience: 1000/5000", + "Spell Points: 90/95", + "Hit Points: 12,345/23,456", + "Hitpoints: 90 of 90", + "PRACT: 005 Hitpoints: 90 of 90", + "Hit : [ 168/168 ]", + "| Level: 201 Hit Points: 500/500 Moves: 1000/1000 |", + "| Race: Undead Atavian | Health: 4252/4252 |", + "Health: 3600/3600 Mana: 3400/3400", + "Hp: 2331(2331) Gp: 433(459) Xp: 1143225 Burden: 21%", + "Hp: 143 (167) Gp: 240 (240) Xp: 267000", + "Level: 5 HitPoints: 100/ 100 Pager ( )", + "Race : Human Mana : 1000/ 1000 Autoexit (X)", + -- score sentences + "You have 100/120 hit points left.", + "You have 100(100) hit, 90(90) mana, and 100(100) movement points.", + "You have 123 experience points, 45 gold coins, 50 hit points(50).", + "You have 1110 (1110) hit points, 167 (167) guild points, 2 (684) quest points.", + } + + for _, line in ipairs(readableLines) do + it("lets through: " .. line, function() + assert.is_true(#BaseUI.parseVitalsLine(line) > 0, + "sample line no longer produces any reading - fix the sample, not the prefilter") + -- rex.find: rex.match returns false for an unset capture group + assert.is_not_nil(rex.find(line, BaseUI.vitalsPrefilter), + "prefilter drops a line the vitals shapes read: the gauges would never appear") + end) + end + + local ordinaryOutput = { + "You are standing in a dark forest. The trees tower above you.", + "A gentle breeze carries the scent of pine and distant woodsmoke.", + "You are carrying: a rusty sword, a silver ring, and 12 gold coins.", + "The Village Square", + "A glowing ember drifts past the Ancient Tower.", + "Gandalf tells you 'meet me at the tower'", + } + + for _, line in ipairs(ordinaryOutput) do + it("keeps out: " .. line, function() + -- extra parens: rex.find's second return value would land in + -- luassert's message slot + assert.is_nil((rex.find(line, BaseUI.vitalsPrefilter))) + end) + end + + it("is precompiled rather than recompiled per line", function() + assert.is_true(BaseUI.shapesArePrecompiled()) + end) + + -- restore whatever the assertions do: a raised vitalsLock left behind + -- makes createVitalsTriggers a silent no-op for every later test + local savedIds, savedLock + + local function borrowVitalsTriggerState(lock) + savedIds, savedLock = BaseUI.vitalsTriggerIds, BaseUI.vitalsLock + BaseUI.vitalsTriggerIds, BaseUI.vitalsLock = {}, lock + end + + local function returnVitalsTriggerState() + BaseUI.killVitalsTriggers() + BaseUI.vitalsTriggerIds, BaseUI.vitalsLock = savedIds, savedLock + end + + it("arms exactly one trigger, not one per shape", function() + if BaseUI.dormant() then + pending("the starter UI is dormant in this profile") + return + end + borrowVitalsTriggerState(0) + local ok, err = pcall(function() + BaseUI.createVitalsTriggers() + assert.are.equal(1, #BaseUI.vitalsTriggerIds) + end) + returnVitalsTriggerState() + assert.is_true(ok, tostring(err)) + end) + + it("stays retired while a protocol owns the gauges", function() + if BaseUI.dormant() then + pending("the starter UI is dormant in this profile") + return + end + borrowVitalsTriggerState(3) + local ok, err = pcall(function() + assert.is_true(BaseUI.structuredVitalsOwnGauges()) + BaseUI.createVitalsTriggers() + assert.are.same({}, BaseUI.vitalsTriggerIds) + end) + returnVitalsTriggerState() + assert.is_true(ok, tostring(err)) + end) + end) + + describe("the chat capture shapes", function() + local chatLines = { + "Bob tells you, 'hello there'", + "You tell Bob, 'hi'", + "You tell the group 'incoming'", + "Bob whispers to you, 'psst'", + "Bob tells the group 'incoming'", + "Bob says, 'hello'", + "Bob asks, 'where is the bank?'", + "Bob exclaims, 'at last!'", + "You say, 'hello'", + "You ask, 'which way?'", + "You exclaim, 'finally!'", + "Bob yells, 'help!'", + "You shout, 'hello'", + "[newbie] Ann: how do I get out of here?", + "(gossip) Ann: anyone around?", + "< chat | Ann: anyone around?", + } + + -- the last two are captured by a shape and then turned away by + -- chatChannelNames, so they stay available to the vitals layer + local notChatLines = { + "You are standing in a dark forest.", + "The orc hits you for 14 damage!", + "[combat] 100/120 hp", + "(12) something that is not a channel", + } + + it("recognises every shape of chat line", function() + for _, line in ipairs(chatLines) do + assert.is_true(BaseUI.chatLikeLine(line), "not recognised as chat: " .. line) + end + end) + + it("leaves ordinary game text to the vitals layer", function() + for _, line in ipairs(notChatLines) do + assert.is_false(BaseUI.chatLikeLine(line), "ordinary game text taken for chat: " .. line) + end + end) + + it("has a shape for every line the trigger tree routes", function() + for _, regex in ipairs(BaseUI.chatShapeRegexes()) do + local matched = false + for _, line in ipairs(chatLines) do + if rex.find(line, regex) then + matched = true + break + end + end + assert.is_true(matched, "no line above exercises the shape: " .. regex) + end + end) + end) end) -- when a game installs its own interface (a Client.GUI package), the @@ -1829,7 +2066,7 @@ describe("Tests UI functions", function() after_each(function() BaseUI.settings = savedSettings BaseUI.saveSettings() - BaseUI.createChatTriggers() + BaseUI.armChatTriggers() BaseUI.createVitalsTriggers() end) @@ -1841,11 +2078,11 @@ describe("Tests UI functions", function() it("should retire its capture triggers while standing aside", function() BaseUI.standAside("sysServerGuiInstalled", "SomeGameUI") - assert.is_nil(next(BaseUI.chatTriggerIds)) + assert.is_false(BaseUI.chatTriggersArmed()) assert.is_nil(next(BaseUI.vitalsTriggerIds)) - BaseUI.createChatTriggers() + BaseUI.armChatTriggers() BaseUI.createVitalsTriggers() - assert.is_nil(next(BaseUI.chatTriggerIds)) + assert.is_false(BaseUI.chatTriggersArmed()) assert.is_nil(next(BaseUI.vitalsTriggerIds)) end) @@ -2259,7 +2496,17 @@ describe("Tests UI functions", function() end) it("setWindowWrap round-trips through getWindowWrap", function() + assert.is_true(setWindowWrap(win, 42)) + assert.are.equal(42, getWindowWrap(win)) + end) + + -- a window zero columns wide can show nothing, and used to hang Mudlet + -- as soon as the next line was displayed in it (issue #9622) + it("setWindowWrap refuses a wrap width below one and keeps the old width", function() setWindowWrap(win, 42) + local ok, err = setWindowWrap(win, 0) + assert.is_nil(ok) + assert.is_truthy(err:find("greater than zero", 1, true)) assert.are.equal(42, getWindowWrap(win)) end) @@ -2469,3 +2716,3280 @@ describe("Tests UI functions", function() end) end) end) + +-- Window state getters: getWindowGeometry, windowVisible, getLabelText. +-- Self-contained top-level block kept at the tail of the file; do not +-- interleave it with the "Tests UI functions" block above. +describe("Window state getters", function() + -- Unique-ish names so repeat runs against the same profile do not collide: + -- user windows cannot be deleted from Lua, only hidden. + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local labelName = "wsgLabel" .. suffix + local consoleName = "wsgConsole" .. suffix + local scrollBoxName = "wsgScrollBox" .. suffix + local cmdLineName = "wsgCmdLine" .. suffix + local textEditName = "wsgTextEdit" .. suffix + local userWindowName = "wsgUserWindow" .. suffix + -- a label parented inside the user window, to probe ancestor-aware visibility + local childLabelName = "wsgChildLabel" .. suffix + + setup(function() + createLabel(labelName, 10, 20, 100, 50, 1) + createMiniConsole(consoleName, 30, 40, 300, 150) + createScrollBox(scrollBoxName, 60, 70, 120, 90) + createCommandLine(cmdLineName, 15, 25, 140, 35) + createTextEdit(textEditName, 45, 55, 160, 110) + openUserWindow(userWindowName) + createLabel(userWindowName, childLabelName, 5, 5, 40, 20, 1) + end) + + before_each(function() + -- restore baseline geometry and visibility so one failing spec cannot + -- cascade into later specs (busted runs specs in definition order) + moveWindow(labelName, 10, 20) + resizeWindow(labelName, 100, 50) + moveWindow(consoleName, 30, 40) + resizeWindow(consoleName, 300, 150) + for _, name in ipairs({labelName, consoleName, scrollBoxName, cmdLineName, textEditName, userWindowName}) do + showWindow(name) + end + end) + + teardown(function() + deleteLabel(childLabelName) + deleteLabel(labelName) + deleteMiniConsole(consoleName) + deleteScrollBox(scrollBoxName) + deleteCommandLine(cmdLineName) + deleteTextEdit(textEditName) + -- user windows cannot be deleted from Lua, so just hide it again + hideWindow(userWindowName) + end) + + describe("getWindowGeometry", function() + it("returns a label's position and size as x, y, width, height", function() + local x, y, w, h = getWindowGeometry(labelName) + assert.are.equal(10, x) + assert.are.equal(20, y) + assert.are.equal(100, w) + assert.are.equal(50, h) + end) + + it("returns a miniconsole's position and size", function() + local x, y, w, h = getWindowGeometry(consoleName) + assert.are.equal(30, x) + assert.are.equal(40, y) + assert.are.equal(300, w) + assert.are.equal(150, h) + end) + + it("returns a scroll box's position and size", function() + local x, y, w, h = getWindowGeometry(scrollBoxName) + assert.are.equal(60, x) + assert.are.equal(70, y) + assert.are.equal(120, w) + assert.are.equal(90, h) + end) + + it("returns a command line's position and size", function() + local x, y, w, h = getWindowGeometry(cmdLineName) + assert.are.equal(15, x) + assert.are.equal(25, y) + assert.are.equal(140, w) + assert.are.equal(35, h) + end) + + it("returns a text edit's position and size", function() + local x, y, w, h = getWindowGeometry(textEditName) + assert.are.equal(45, x) + assert.are.equal(55, y) + assert.are.equal(160, w) + assert.are.equal(110, h) + end) + + it("reflects moveWindow on a label", function() + moveWindow(labelName, 55, 66) + local x, y = getWindowGeometry(labelName) + assert.are.equal(55, x) + assert.are.equal(66, y) + end) + + it("reflects resizeWindow on a miniconsole", function() + resizeWindow(consoleName, 321, 123) + local _, _, w, h = getWindowGeometry(consoleName) + assert.are.equal(321, w) + assert.are.equal(123, h) + end) + + it("reflects resizeWindow on a user window", function() + -- read back through the dock widget; size() is the exact inverse of + -- resize() and does not depend on the window manager honouring a move + resizeWindow(userWindowName, 400, 200) + local _, _, w, h = getWindowGeometry(userWindowName) + assert.are.equal(400, w) + assert.are.equal(200, h) + end) + + it("returns nil and a message naming an unknown window", function() + local result, err = getWindowGeometry("wdgNoSuchWindow") + assert.is_nil(result) + assert.are.equal("string", type(err)) + assert.is_truthy(err:find("wdgNoSuchWindow", 1, true)) + end) + + it("returns the main window's geometry under both of its names", function() + local width, height = getMainWindowSize() + for _, name in ipairs({"main", ""}) do + local x, y, w, h = getWindowGeometry(name) + assert.are.equal(0, x) + assert.are.equal(0, y) + assert.are.equal(width, w) + assert.are.equal(height, h) + end + end) + + it("errors when called without a window name", function() + assert.has_error(function() getWindowGeometry() end) + end) + end) + + describe("windowVisible", function() + it("reflects hideWindow then showWindow on a label", function() + assert.is_true(windowVisible(labelName)) + hideWindow(labelName) + assert.is_false(windowVisible(labelName)) + showWindow(labelName) + assert.is_true(windowVisible(labelName)) + end) + + it("reflects hideWindow then showWindow on a miniconsole", function() + assert.is_true(windowVisible(consoleName)) + hideWindow(consoleName) + assert.is_false(windowVisible(consoleName)) + showWindow(consoleName) + assert.is_true(windowVisible(consoleName)) + end) + + it("reflects hideWindow then showWindow on a scroll box", function() + assert.is_true(windowVisible(scrollBoxName)) + hideWindow(scrollBoxName) + assert.is_false(windowVisible(scrollBoxName)) + showWindow(scrollBoxName) + assert.is_true(windowVisible(scrollBoxName)) + end) + + it("reflects hideWindow then showWindow on a command line", function() + assert.is_true(windowVisible(cmdLineName)) + hideWindow(cmdLineName) + assert.is_false(windowVisible(cmdLineName)) + showWindow(cmdLineName) + assert.is_true(windowVisible(cmdLineName)) + end) + + it("reflects hideWindow then showWindow on a user window", function() + assert.is_true(windowVisible(userWindowName)) + hideWindow(userWindowName) + assert.is_false(windowVisible(userWindowName)) + showWindow(userWindowName) + assert.is_true(windowVisible(userWindowName)) + end) + + it("reports a child hidden by its user window as not visible", function() + -- windowVisible reflects effective (ancestor-aware) visibility: hiding + -- the parent user window hides the child even though the child itself + -- was never hidden + assert.is_true(windowVisible(childLabelName)) + hideWindow(userWindowName) + assert.is_false(windowVisible(childLabelName)) + showWindow(userWindowName) + assert.is_true(windowVisible(childLabelName)) + end) + + it("returns nil and a message naming an unknown window", function() + local result, err = windowVisible("wdgNoSuchWindow") + assert.is_nil(result) + assert.are.equal("string", type(err)) + assert.is_truthy(err:find("wdgNoSuchWindow", 1, true)) + end) + + it("reports the main window as visible under both of its names", function() + assert.is_true(windowVisible("main")) + assert.is_true(windowVisible("")) + end) + + it("errors when called without a window name", function() + assert.has_error(function() windowVisible() end) + end) + end) + + describe("getLabelText", function() + it("returns text set on a label via echo", function() + echo(labelName, "hello label") + assert.are.equal("hello label", getLabelText(labelName)) + end) + + it("round-trips updated label text", function() + echo(labelName, "first") + assert.are.equal("first", getLabelText(labelName)) + echo(labelName, "second") + assert.are.equal("second", getLabelText(labelName)) + end) + + it("returns nil and a message naming an unknown label", function() + local result, err = getLabelText("wdgNoSuchLabel") + assert.is_nil(result) + assert.are.equal("string", type(err)) + assert.is_truthy(err:find("wdgNoSuchLabel", 1, true)) + end) + + it("returns nil and a message for a non-label window", function() + local result, err = getLabelText(consoleName) + assert.is_nil(result) + assert.are.equal("string", type(err)) + end) + + it("errors when called without a label name", function() + assert.has_error(function() getLabelText() end) + end) + end) +end) + +-- Raw window/label API: creation geometry, movement, visibility, text and +-- state readback. Uses getWindowGeometry/windowVisible/getLabelText plus the +-- pre-existing getters; Geyser wrappers are covered in the Geyser* specs. +describe("Window and label state", function() + -- user windows cannot be deleted from Lua, so keep the names unique per run + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local function name(base) + return base .. suffix + end + + -- one shared user window for the whole block: opening one is expensive and + -- Lua cannot delete it again, only hide it + local sharedUserWindow = name("wlsUserWindow") + + setup(function() + -- loadLayout is off so a saved layout cannot move the window under us + openUserWindow(sharedUserWindow, false) + end) + + teardown(function() + hideWindow(sharedUserWindow) + end) + + describe("creation, containment and type of window elements", function() + local label = name("wlsLabel") + local console = name("wlsConsole") + local scrollBox = name("wlsScrollBox") + local cmdLine = name("wlsCmdLine") + local textEdit = name("wlsTextEdit") + local userWindow = sharedUserWindow + local childLabel = name("wlsChildLabel") + local childConsole = name("wlsChildConsole") + local scrollBoxLabel = name("wlsScrollBoxLabel") + + setup(function() + createLabel(label, 11, 22, 133, 44, 1) + createMiniConsole(console, 12, 23, 300, 150) + createScrollBox(scrollBox, 13, 24, 120, 90) + createCommandLine(cmdLine, 14, 25, 140, 35) + createTextEdit(textEdit, 15, 26, 160, 110) + createLabel(userWindow, childLabel, 5, 6, 40, 20, 1) + createMiniConsole(userWindow, childConsole, 7, 8, 200, 100) + createLabel(scrollBox, scrollBoxLabel, 4, 5, 30, 20, 1) + end) + + teardown(function() + deleteLabel(childLabel) + deleteMiniConsole(childConsole) + deleteLabel(scrollBoxLabel) + deleteLabel(label) + deleteMiniConsole(console) + deleteScrollBox(scrollBox) + deleteCommandLine(cmdLine) + deleteTextEdit(textEdit) + end) + + -- geometry straight after creation in the main window is covered by the + -- "Window state getters" block above; these cover the other two parents + + it("a label created in a user window is positioned inside that window", function() + -- the coordinates are relative to the parent, not to the main window + assert.are.same({5, 6, 40, 20}, {getWindowGeometry(childLabel)}) + end) + + it("a miniconsole created in a user window is positioned inside that window", function() + assert.are.same({7, 8, 200, 100}, {getWindowGeometry(childConsole)}) + end) + + it("a label created in a scroll box is positioned inside that scroll box", function() + assert.are.same({4, 5, 30, 20}, {getWindowGeometry(scrollBoxLabel)}) + end) + + it("every created element is visible and reports its own type", function() + assert.is_true(windowVisible(label)) + assert.is_true(windowVisible(console)) + assert.is_true(windowVisible(scrollBox)) + assert.is_true(windowVisible(cmdLine)) + assert.is_true(windowVisible(textEdit)) + assert.are.equal("label", windowType(label)) + assert.are.equal("miniconsole", windowType(console)) + assert.are.equal("commandline", windowType(cmdLine)) + assert.are.equal("textedit", windowType(textEdit)) + assert.are.equal("scrollbox", windowType(scrollBox)) + end) + + it("openUserWindow reports the window as a userwindow and is repeatable", function() + assert.are.equal("userwindow", windowType(userWindow)) + -- re-opening an already open user window re-shows the same dock rather + -- than reporting the name as taken + assert.is_true(openUserWindow(userWindow, false)) + end) + + it("openUserWindow refuses a name already taken by a label", function() + local ok, err = openUserWindow(label) + assert.is_nil(ok) + assert.are.equal(("label with the name '%s' already exists"):format(label), err) + end) + + it("createLabel on an existing name returns false and a message", function() + local ok, err = createLabel(label, 41, 51, 61, 71, 1) + assert.is_false(ok) + assert.are.equal(("label '%s' already exists"):format(label), err) + -- the parented form reports the same way + local childOk, childErr = createLabel(userWindow, childLabel, 4, 5, 30, 20, 1) + assert.is_false(childOk) + assert.are.equal(("label '%s' already exists"):format(childLabel), childErr) + -- unlike createMiniConsole/createScrollBox a refused createLabel must leave + -- the existing labels alone rather than moving and resizing them + assert.are.same({11, 22, 133, 44}, {getWindowGeometry(label)}) + assert.are.same({5, 6, 40, 20}, {getWindowGeometry(childLabel)}) + end) + + it("createLabel on a name taken by a miniconsole returns false and a message", function() + local ok, err = createLabel(console, 1, 2, 3, 4, 1) + assert.is_false(ok) + assert.are.equal(("a miniconsole/userwindow with the name '%s' already exists"):format(console), err) + end) + + it("createLabel hard-errors on a non-number coordinate", function() + -- a string second argument selects the parented form, so the two forms + -- report the same argument number for different coordinates + local mainOk, mainErr = pcall(createLabel, name("wlsBadCoordLabel"), 0, "here", 40, 40, 1) + assert.is_false(mainOk) + assert.is_truthy(mainErr:find("createLabel: bad argument #3 type (label y-coordinate", 1, true)) + local childOk, childErr = pcall(createLabel, userWindow, name("wlsBadCoordChild"), "here", 0, 40, 40, 1) + assert.is_false(childOk) + assert.is_truthy(childErr:find("createLabel: bad argument #3 type (label x-coordinate", 1, true)) + end) + + it("createMiniConsole on an existing name moves and resizes it instead", function() + local ok, err = createMiniConsole(console, 40, 50, 260, 130) + local geometry = {getWindowGeometry(console)} + -- put it back before asserting so a failure here cannot cascade + createMiniConsole(console, 12, 23, 300, 150) + assert.is_false(ok) + assert.are.equal(("miniconsole '%s' already exists, moving/resizing '%s'"):format(console, console), err) + assert.are.same({40, 50, 260, 130}, geometry) + end) + + it("createScrollBox on an existing name moves and resizes it instead", function() + local ok, err = createScrollBox(scrollBox, 41, 51, 261, 131) + local geometry = {getWindowGeometry(scrollBox)} + createScrollBox(scrollBox, 13, 24, 120, 90) + assert.is_false(ok) + assert.are.equal(("scrollBox '%s' already exists, moving/resizing '%s'"):format(scrollBox, scrollBox), err) + assert.are.same({41, 51, 261, 131}, geometry) + end) + + it("createCommandLine hard-errors without a name", function() + local ok, err = pcall(createCommandLine) + assert.is_false(ok) + assert.is_truthy(err:find("createCommandLine: bad argument #1 type", 1, true)) + end) + + it("createTextEdit hard-errors without a name", function() + local ok, err = pcall(createTextEdit) + assert.is_false(ok) + assert.is_truthy(err:find("createTextEdit: bad argument #1 type", 1, true)) + end) + + it("createScrollBox hard-errors without a name", function() + local ok, err = pcall(createScrollBox) + assert.is_false(ok) + assert.is_truthy(err:find("createScrollBox: bad argument #1 type", 1, true)) + end) + end) + + describe("moveWindow and resizeWindow", function() + local label = name("wlsMoveLabel") + local console = name("wlsMoveConsole") + local scrollBox = name("wlsMoveScrollBox") + local cmdLine = name("wlsMoveCmdLine") + local textEdit = name("wlsMoveTextEdit") + + setup(function() + createLabel(label, 10, 10, 100, 50, 1) + createMiniConsole(console, 130, 10, 200, 50) + createScrollBox(scrollBox, 10, 70, 100, 50) + createCommandLine(cmdLine, 10, 130, 100, 30) + createTextEdit(textEdit, 10, 170, 100, 50) + end) + + teardown(function() + deleteLabel(label) + deleteMiniConsole(console) + deleteScrollBox(scrollBox) + deleteCommandLine(cmdLine) + deleteTextEdit(textEdit) + end) + + it("moveWindow relocates a miniconsole", function() + moveWindow(console, 21, 31) + local x, y = getWindowGeometry(console) + assert.are.same({21, 31}, {x, y}) + end) + + it("moveWindow relocates a scroll box", function() + moveWindow(scrollBox, 33, 44) + local x, y = getWindowGeometry(scrollBox) + assert.are.same({33, 44}, {x, y}) + end) + + it("moveWindow relocates a text edit", function() + moveWindow(textEdit, 77, 88) + local x, y = getWindowGeometry(textEdit) + assert.are.same({77, 88}, {x, y}) + end) + + it("resizeWindow resizes a command line", function() + resizeWindow(cmdLine, 180, 40) + local _, _, w, h = getWindowGeometry(cmdLine) + assert.are.same({180, 40}, {w, h}) + end) + + it("resizeWindow resizes a label", function() + resizeWindow(label, 210, 95) + local _, _, w, h = getWindowGeometry(label) + assert.are.same({210, 95}, {w, h}) + end) + + it("moveWindow truncates fractional coordinates", function() + -- the coordinates are read as doubles and cast to int, so .9 is dropped + moveWindow(label, 70.9, 80.9) + local x, y = getWindowGeometry(label) + assert.are.same({70, 80}, {x, y}) + end) + + it("moveWindow and resizeWindow return no values for an unknown window", function() + -- both silently ignore names they cannot resolve, returning nothing at + -- all rather than nil - so count the returns instead of reading one + assert.are.equal(0, select("#", moveWindow(name("wlsNoSuchWindow"), 1, 2))) + assert.are.equal(0, select("#", resizeWindow(name("wlsNoSuchWindow"), 1, 2))) + end) + + it("moveWindow and resizeWindow hard-error without arguments", function() + local movedOk, movedErr = pcall(moveWindow) + assert.is_false(movedOk) + assert.is_truthy(movedErr:find("moveWindow: bad argument #1 type", 1, true)) + local resizedOk, resizedErr = pcall(resizeWindow) + assert.is_false(resizedOk) + assert.is_truthy(resizedErr:find("resizeWindow: bad argument #1 type", 1, true)) + end) + end) + + describe("showWindow and hideWindow", function() + local label = name("wlsShowLabel") + local textEdit = name("wlsShowTextEdit") + + setup(function() + createLabel(label, 10, 10, 60, 30, 1) + createTextEdit(textEdit, 10, 50, 100, 60) + end) + + teardown(function() + deleteLabel(label) + deleteTextEdit(textEdit) + end) + + it("showWindow returns true for an element it knows", function() + assert.is_true(showWindow(label)) + end) + + it("showWindow returns false for an unknown name", function() + assert.is_false(showWindow(name("wlsNoSuchWindow"))) + end) + + it("showWindow returns false for the main window", function() + -- the main console is not one of the elements show/hideWindow act on + assert.is_false(showWindow("main")) + end) + + it("hideWindow returns no value but does hide the element", function() + assert.are.equal(0, select("#", hideWindow(label))) + assert.is_false(windowVisible(label)) + showWindow(label) + assert.is_true(windowVisible(label)) + end) + + it("hideWindow returns no value for an unknown name", function() + assert.are.equal(0, select("#", hideWindow(name("wlsNoSuchWindow")))) + end) + + it("hides and shows a text edit", function() + assert.is_true(windowVisible(textEdit)) + hideWindow(textEdit) + assert.is_false(windowVisible(textEdit)) + showWindow(textEdit) + assert.is_true(windowVisible(textEdit)) + end) + + it("showWindow and hideWindow hard-error without a name", function() + local shownOk, shownErr = pcall(showWindow) + assert.is_false(shownOk) + assert.is_truthy(shownErr:find("showWindow: bad argument #1 type", 1, true)) + local hiddenOk, hiddenErr = pcall(hideWindow) + assert.is_false(hiddenOk) + assert.is_truthy(hiddenErr:find("hideWindow: bad argument #1 type", 1, true)) + end) + end) + + describe("label text readback", function() + local label = name("wlsTextLabel") + local console = name("wlsTextConsole") + + setup(function() + createLabel(label, 10, 10, 200, 40, 1) + createMiniConsole(console, 10, 60, 300, 100) + end) + + before_each(function() + echo(label, "") + clearWindow(console) + end) + + teardown(function() + deleteLabel(label) + deleteMiniConsole(console) + end) + + it("a freshly created label has no text", function() + local fresh = name("wlsFreshLabel") + createLabel(fresh, 0, 0, 10, 10, 1) + assert.are.equal("", getLabelText(fresh)) + deleteLabel(fresh) + end) + + it("echo stores HTML markup on a label verbatim", function() + -- labels are QLabels: the markup is kept as given, not stripped + echo(label, "<b>bold</b> text") + assert.are.equal("<b>bold</b> text", getLabelText(label)) + end) + + it("echo keeps an anchor tag verbatim", function() + echo(label, [[<a href="x">link</a>]]) + assert.are.equal([[<a href="x">link</a>]], getLabelText(label)) + end) + + it("cecho renders to HTML that still carries the plain text", function() + cecho(label, "<red>redtext") + local text = getLabelText(label) + assert.are.equal("<span", text:sub(1, 5)) + assert.is_truthy(text:find("redtext", 1, true)) + assert.is_truthy(text:find("rgb(255, 0, 0)", 1, true)) + end) + + it("decho renders to HTML that still carries the plain text", function() + decho(label, "<0,255,0>dechotext") + local text = getLabelText(label) + assert.are.equal("<span", text:sub(1, 5)) + assert.is_truthy(text:find("dechotext", 1, true)) + assert.is_truthy(text:find("rgb(0, 255, 0)", 1, true)) + end) + + it("hecho renders to HTML that still carries the plain text", function() + hecho(label, "#0000ffhechotext") + local text = getLabelText(label) + assert.are.equal("<span", text:sub(1, 5)) + assert.is_truthy(text:find("hechotext", 1, true)) + assert.is_truthy(text:find("rgb(0, 0, 255)", 1, true)) + end) + + it("echoUserWindow sets the text of a label", function() + echoUserWindow(label, "from echoUserWindow") + assert.are.equal("from echoUserWindow", getLabelText(label)) + end) + + it("echoUserWindow appends a line to a miniconsole", function() + echoUserWindow(console, "console line\n") + assert.are.equal(1, getLineCount(console)) + moveCursor(console, 0, 0) + assert.are.equal("console line", getCurrentLine(console)) + end) + + it("clearUserWindow empties a miniconsole", function() + echo(console, "a\nb\n") + assert.are.equal(2, getLineCount(console)) + clearUserWindow(console) + assert.are.equal(0, getLineCount(console)) + end) + end) + + describe("label appearance setters", function() + local label = name("wlsStyleLabel") + + setup(function() + createLabel(label, 10, 10, 120, 40, 1) + end) + + teardown(function() + deleteLabel(label) + end) + + it("setLabelStyleSheet round-trips through getLabelStyleSheet", function() + assert.is_true(setLabelStyleSheet(label, "background-color: rgb(1,2,3);")) + assert.are.equal("background-color: rgb(1,2,3);", getLabelStyleSheet(label)) + end) + + it("getLabelStyleSheet reports an unknown label", function() + local ok, err = getLabelStyleSheet(name("wlsNoSuchLabel")) + assert.is_nil(ok) + assert.are.equal(("label '%s' does not exist"):format(name("wlsNoSuchLabel")), err) + end) + + it("setLabelStyleSheet reports an unknown label", function() + local ok, err = setLabelStyleSheet(name("wlsNoSuchLabel"), "color: red;") + assert.is_nil(ok) + assert.are.equal(("label name '%s' not found"):format(name("wlsNoSuchLabel")), err) + end) + + it("setLabelToolTip accepts a label and reports an unknown one", function() + assert.is_true(setLabelToolTip(label, "a tooltip")) + local ok, err = setLabelToolTip(name("wlsNoSuchLabel"), "a tooltip") + assert.is_nil(ok) + assert.are.equal(("label name '%s' not found"):format(name("wlsNoSuchLabel")), err) + end) + + it("setLabelCursor accepts a cursor shape and reports an unknown label", function() + assert.is_true(setLabelCursor(label, 2)) + local ok, err = setLabelCursor(name("wlsNoSuchLabel"), 2) + assert.is_nil(ok) + assert.are.equal(("label name '%s' not found"):format(name("wlsNoSuchLabel")), err) + end) + + it("getLabelSizeHint reports a positive size for a label with text", function() + echo(label, "some text") + local w, h = getLabelSizeHint(label) + assert.is_true(w > 0) + assert.is_true(h > 0) + end) + + it("getLabelSizeHint rejects an empty name and an unknown label", function() + local ok, err = getLabelSizeHint("") + assert.is_nil(ok) + assert.are.equal("label name cannot be an empty string", err) + local ok2, err2 = getLabelSizeHint(name("wlsNoSuchLabel")) + assert.is_nil(ok2) + assert.are.equal(("label '%s' does not exist"):format(name("wlsNoSuchLabel")), err2) + end) + + it("setBackgroundColor and getBackgroundColor work on a label", function() + assert.is_true(setBackgroundColor(label, 5, 6, 7, 255)) + assert.are.same({5, 6, 7, 255}, {getBackgroundColor(label)}) + end) + + local linkStyleCalls = { + {name = "setLinkStyle", call = function(target) return setLinkStyle(target, "red", "blue", true) end}, + {name = "resetLinkStyle", call = function(target) return resetLinkStyle(target) end}, + {name = "clearVisitedLinks", call = function(target) return clearVisitedLinks(target) end}, + } + + for _, entry in ipairs(linkStyleCalls) do + it(entry.name .. " succeeds on a label and reports an unknown one", function() + assert.is_true(entry.call(label)) + local ok, err = entry.call(name("wlsNoSuchLabel")) + assert.is_nil(ok) + assert.are.equal(("label '%s' not found"):format(name("wlsNoSuchLabel")), err) + end) + end + end) + + describe("label callback setters", function() + local label = name("wlsCallbackLabel") + + setup(function() + createLabel(label, 10, 10, 60, 30, 1) + end) + + teardown(function() + deleteLabel(label) + end) + + it("setLabelClickCallback accepts a function", function() + assert.is_true(setLabelClickCallback(label, function() end)) + end) + + it("setLabelClickCallback accepts extra arguments for the callback", function() + assert.is_true(setLabelClickCallback(label, function() end, "one", 2)) + end) + + it("setLabelClickCallback accepts a function name as a string", function() + -- the Lua wrapper turns a string into a function calling that name + assert.is_true(setLabelClickCallback(label, "wlsNoSuchGlobalFunction")) + end) + + it("setLabelClickCallback hard-errors on a value that is neither function, string nor nil", function() + local ok, err = pcall(setLabelClickCallback, label, 42) + assert.is_false(ok) + assert.is_truthy(err:find("setLabelClickCallback: bad argument #2 type (function expected, got number!)", 1, true)) + end) + + it("setLabelClickCallback rejects an empty label name", function() + local ok, err = setLabelClickCallback("", function() end) + assert.is_nil(ok) + assert.are.equal("label name cannot be an empty string", err) + end) + + local callbackSetters = { + "setLabelClickCallback", + "setLabelDoubleClickCallback", + "setLabelReleaseCallback", + "setLabelMoveCallback", + "setLabelWheelCallback", + "setLabelOnEnter", + "setLabelOnLeave", + } + + for _, setter in ipairs(callbackSetters) do + it(setter .. " reports an unknown label", function() + local ok, err = _G[setter](name("wlsNoSuchLabel"), function() end) + assert.is_nil(ok) + assert.are.equal(("label name '%s' not found"):format(name("wlsNoSuchLabel")), err) + end) + end + end) + + describe("font readback", function() + local console = name("wlsFontConsole") + + setup(function() + createMiniConsole(console, 10, 10, 300, 150) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + it("setFontSize round-trips through getFontSize", function() + assert.is_true(setFontSize(console, 14)) + assert.are.equal(14, getFontSize(console)) + end) + + it("setMiniConsoleFontSize round-trips through getFontSize", function() + assert.is_true(setMiniConsoleFontSize(console, 9)) + assert.are.equal(9, getFontSize(console)) + end) + + it("getFontSize, getFont and setFontSize report an unknown window", function() + local unknown = name("wlsNoSuchWindow") + local ok, err = getFontSize(unknown) + assert.is_nil(ok) + assert.are.equal(('window "%s" not found'):format(unknown), err) + local ok2, err2 = getFont(unknown) + assert.is_nil(ok2) + assert.are.equal(('window "%s" not found'):format(unknown), err2) + local ok3, err3 = setFontSize(unknown, 10) + assert.is_nil(ok3) + assert.are.equal(('window "%s" not found'):format(unknown), err3) + end) + + it("setFont changes the font getFont reports, and can be set back", function() + local original = getFont(console) + assert.is_true(#original > 0) + -- pick a family the font database really offers; some of the names it + -- lists are generic aliases (Monospace, Serif, ...) that resolve to a + -- different family, so keep looking until one actually round-trips + local families = {} + for family in pairs(getAvailableFonts()) do + families[#families + 1] = family + end + table.sort(families) + local applied + for _, family in ipairs(families) do + if family ~= original then + setFont(console, family) + if getFont(console) == family then + applied = family + break + end + end + end + assert.is_string(applied) + assert.are.equal(applied, getFont(console)) + assert.is_true(setFont(console, original)) + assert.are.equal(original, getFont(console)) + end) + + it("setFont rejects a font that is not available", function() + local ok, err = setFont(console, "wlsNoSuchFontFamily") + assert.is_nil(ok) + assert.are.equal("font 'wlsNoSuchFontFamily' is not available", err) + end) + + it("setFont rejects an empty font name", function() + local ok, err = setFont(console, "") + assert.is_nil(ok) + assert.are.equal("font must not be empty", err) + end) + + it("getAvailableFonts returns a table keyed by font name", function() + local fonts = getAvailableFonts() + assert.is_table(fonts) + local count = 0 + for fontName, present in pairs(fonts) do + assert.is_string(fontName) + assert.is_true(present) + count = count + 1 + end + assert.is_true(count > 0) + end) + + it("calcFontSize returns a positive cell size for a font size", function() + local w, h = calcFontSize(12) + assert.is_true(w > 0) + assert.is_true(h > 0) + end) + + it("calcFontSize returns a positive cell size for a size and font name", function() + -- name a family the console itself resolved to, so this cannot silently + -- fall through to the substituted default font + local w, h = calcFontSize(12, getFont(console)) + assert.is_true(w > 0) + assert.is_true(h > 0) + end) + + it("calcFontSize on a window grows with that window's font size", function() + setMiniConsoleFontSize(console, 8) + local smallWidth, smallHeight = calcFontSize(console) + setMiniConsoleFontSize(console, 20) + local largeWidth, largeHeight = calcFontSize(console) + assert.is_true(largeWidth > smallWidth) + assert.is_true(largeHeight > smallHeight) + end) + + it("calcFontSize returns nil for an unknown window", function() + assert.is_nil(calcFontSize(name("wlsNoSuchWindow"))) + end) + end) + + describe("console metrics", function() + local console = name("wlsMetricConsole") + + setup(function() + createMiniConsole(console, 10, 10, 200, 150) + setMiniConsoleFontSize(console, 10) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + it("getColumnCount grows when the console is made wider", function() + resizeWindow(console, 200, 150) + local narrow = getColumnCount(console) + resizeWindow(console, 600, 150) + local wide = getColumnCount(console) + assert.is_true(narrow > 0) + assert.is_true(wide > narrow) + end) + + it("getRowCount grows when the console is made taller", function() + resizeWindow(console, 600, 150) + local short = getRowCount(console) + resizeWindow(console, 600, 400) + local tall = getRowCount(console) + assert.is_true(short > 0) + assert.is_true(tall > short) + end) + + it("getColumnCount and getRowCount report an unknown window", function() + local unknown = name("wlsNoSuchWindow") + local ok, err = getColumnCount(unknown) + assert.is_nil(ok) + assert.are.equal(('window "%s" not found'):format(unknown), err) + local ok2, err2 = getRowCount(unknown) + assert.is_nil(ok2) + assert.are.equal(('window "%s" not found'):format(unknown), err2) + end) + + it("getMainWindowSize returns a positive width and height", function() + local w, h = getMainWindowSize() + assert.is_true(w > 0) + assert.is_true(h > 0) + end) + + it("getMainConsoleWidth returns a positive width", function() + assert.is_true(getMainConsoleWidth() > 0) + end) + + it("getProfileTabNumber returns the one-based tab position", function() + assert.is_true(getProfileTabNumber() >= 1) + end) + + it("getUserWindowSize returns the size of a user window", function() + -- the height of a dock that has never been laid out by a window manager + -- is not meaningful headless, so only the width is pinned + local w, h = getUserWindowSize(sharedUserWindow) + assert.is_number(w) + assert.is_number(h) + assert.is_true(w > 0) + end) + end) + + describe("border sizes and colour", function() + local originalSizes + local originalColor + + setup(function() + originalSizes = getBorderSizes() + originalColor = {getBorderColor()} + end) + + teardown(function() + setBorderSizes(originalSizes.top, originalSizes.right, originalSizes.bottom, originalSizes.left) + setBorderColor(originalColor[1], originalColor[2], originalColor[3]) + end) + + it("the individual border setters round-trip through their getters", function() + setBorderTop(7) + setBorderRight(10) + setBorderBottom(8) + setBorderLeft(9) + assert.are.equal(7, getBorderTop()) + assert.are.equal(10, getBorderRight()) + assert.are.equal(8, getBorderBottom()) + assert.are.equal(9, getBorderLeft()) + assert.are.same({top = 7, right = 10, bottom = 8, left = 9}, getBorderSizes()) + end) + + it("setBorderSizes with one argument sets all four borders", function() + setBorderSizes(3) + assert.are.same({top = 3, right = 3, bottom = 3, left = 3}, getBorderSizes()) + end) + + it("setBorderSizes with two arguments takes height then width", function() + setBorderSizes(4, 5) + assert.are.same({top = 4, right = 5, bottom = 4, left = 5}, getBorderSizes()) + end) + + it("setBorderSizes with three arguments takes top, width, bottom", function() + setBorderSizes(1, 2, 3) + assert.are.same({top = 1, right = 2, bottom = 3, left = 2}, getBorderSizes()) + end) + + it("setBorderSizes with four arguments takes top, right, bottom, left", function() + setBorderSizes(1, 2, 3, 4) + assert.are.same({top = 1, right = 2, bottom = 3, left = 4}, getBorderSizes()) + end) + + it("setBorderSizes with no arguments leaves the borders alone", function() + setBorderSizes(6, 6, 6, 6) + setBorderSizes() + assert.are.same({top = 6, right = 6, bottom = 6, left = 6}, getBorderSizes()) + end) + + it("setBorderTop hard-errors on a non-number", function() + local ok, err = pcall(setBorderTop, "wide") + assert.is_false(ok) + assert.is_truthy(err:find("setBorderTop: bad argument #1 type", 1, true)) + end) + + it("setBorderColor round-trips through getBorderColor", function() + setBorderColor(11, 22, 33) + assert.are.same({11, 22, 33}, {getBorderColor()}) + end) + end) + + describe("timestamps", function() + local console = name("wlsStampConsole") + + setup(function() + createMiniConsole(console, 10, 10, 200, 100) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + it("a new miniconsole has timestamps off, and they can be turned on and off", function() + assert.is_false(timeStampsEnabled(console)) + assert.is_true(enableTimeStamps(console)) + assert.is_true(timeStampsEnabled(console)) + assert.is_true(disableTimeStamps(console)) + assert.is_false(timeStampsEnabled(console)) + end) + + -- Both refusals share one message, and on the enable path it reads + -- "timestamps were not enabled ..." when they in fact already are - so the + -- shape is asserted rather than that wrong wording, which should change. + it("enableTimeStamps refuses when timestamps are already on", function() + enableTimeStamps(console) + local ok, err = enableTimeStamps(console) + assert.is_nil(ok) + assert.is_string(err) + disableTimeStamps(console) + end) + + it("disableTimeStamps refuses when timestamps are already off", function() + disableTimeStamps(console) + local ok, err = disableTimeStamps(console) + assert.is_nil(ok) + assert.is_string(err) + end) + + it("timeStampsEnabled reports an unknown window", function() + local unknown = name("wlsNoSuchWindow") + local ok, err = timeStampsEnabled(unknown) + assert.is_nil(ok) + assert.are.equal(('window "%s" not found'):format(unknown), err) + end) + end) + + describe("scrolling state", function() + local console = name("wlsScrollConsole") + + setup(function() + createMiniConsole(console, 10, 10, 200, 100) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + it("scrollingActive is true for the main window", function() + assert.is_true(scrollingActive("main")) + end) + + it("disableScrolling and enableScrolling toggle scrollingActive", function() + assert.is_true(scrollingActive(console)) + assert.is_true(disableScrolling(console)) + assert.is_false(scrollingActive(console)) + assert.is_true(enableScrolling(console)) + assert.is_true(scrollingActive(console)) + end) + + it("getScroll follows the buffer as lines arrive", function() + clearWindow(console) + assert.are.equal(0, getScroll(console)) + for i = 1, 30 do + echo(console, "line " .. i .. "\n") + end + -- the view stays at the tail, so the reported position is the last line + assert.are.equal(getLastLineNumber(console), getScroll(console)) + assert.are.equal(30, getScroll(console)) + clearWindow(console) + end) + + local unknownWindowCalls = { + {name = "scrollingActive", call = function(target) return scrollingActive(target) end}, + {name = "getScroll", call = function(target) return getScroll(target) end}, + {name = "scrollTo", call = function(target) return scrollTo(target, 1) end}, + {name = "disableScrollBar", call = function(target) return disableScrollBar(target) end}, + {name = "enableScrollBar", call = function(target) return enableScrollBar(target) end}, + {name = "disableHorizontalScrollBar", call = function(target) return disableHorizontalScrollBar(target) end}, + {name = "enableHorizontalScrollBar", call = function(target) return enableHorizontalScrollBar(target) end}, + {name = "enableScrolling", call = function(target) return enableScrolling(target) end}, + {name = "disableScrolling", call = function(target) return disableScrolling(target) end}, + } + + for _, entry in ipairs(unknownWindowCalls) do + it(entry.name .. " reports an unknown window", function() + local unknown = name("wlsNoSuchWindow") + local ok, err = entry.call(unknown) + assert.is_nil(ok) + assert.are.equal(('window "%s" not found'):format(unknown), err) + end) + end + + it("the scroll bar toggles return no value for a console they know", function() + assert.are.equal(0, select("#", disableScrollBar(console))) + assert.are.equal(0, select("#", enableScrollBar(console))) + assert.are.equal(0, select("#", disableHorizontalScrollBar(console))) + assert.are.equal(0, select("#", enableHorizontalScrollBar(console))) + end) + end) + + describe("clipboard", function() + local originalText + + setup(function() + -- this is the real system clipboard, so put back whatever was in it + originalText = getClipboardText() + end) + + teardown(function() + setClipboardText(originalText) + end) + + it("setClipboardText round-trips through getClipboardText", function() + assert.is_true(setClipboardText("wls clipboard text")) + assert.are.equal("wls clipboard text", getClipboardText()) + end) + + it("setClipboardText hard-errors on a table", function() + local ok, err = pcall(setClipboardText, {}) + assert.is_false(ok) + assert.is_truthy(err:find("setClipboardText: bad argument #1 type", 1, true)) + end) + end) + + describe("mouse events", function() + local unique = name("wlsMouseEvent") + local minimal = name("wlsMouseEventMinimal") + + teardown(function() + removeMouseEvent(unique) + removeMouseEvent(minimal) + end) + + it("addMouseEvent registers an entry that getMouseEvents reports back", function() + assert.is_true(addMouseEvent(unique, "wlsEventName", "Display name", "Tooltip text")) + local events = getMouseEvents() + assert.is_table(events) + assert.are.same({ + ["event name"] = "wlsEventName", + ["display name"] = "Display name", + ["tooltip text"] = "Tooltip text", + }, events[unique]) + end) + + it("addMouseEvent defaults the display name to the unique name", function() + assert.is_true(addMouseEvent(minimal, "wlsMinimalEvent")) + assert.are.same({ + ["event name"] = "wlsMinimalEvent", + ["display name"] = minimal, + ["tooltip text"] = "", + }, getMouseEvents()[minimal]) + end) + + it("addMouseEvent refuses a name that is already registered", function() + addMouseEvent(unique, "wlsEventName") + local ok, err = addMouseEvent(unique, "wlsEventName") + assert.is_nil(ok) + assert.are.equal(("mouse event '%s' already exists"):format(unique), err) + end) + + it("removeMouseEvent drops the entry", function() + addMouseEvent(unique, "wlsEventName") + assert.is_true(removeMouseEvent(unique)) + assert.is_nil(getMouseEvents()[unique]) + end) + + it("removeMouseEvent refuses an event that is not registered", function() + removeMouseEvent(unique) + local ok, err = removeMouseEvent(unique) + assert.is_nil(ok) + assert.are.equal(("mouse event '%s' does not exist"):format(unique), err) + end) + end) + + describe("command line menu events and visibility", function() + local cmdLine = name("wlsMenuCmdLine") + -- the main command line outlives this block, so its menu items are named + -- per run and removed again in the teardown + local menuLabel = name("wlsMenuLabel") + local otherMenuLabel = name("wlsMenuLabel2") + + setup(function() + createCommandLine(cmdLine, 10, 10, 150, 30) + end) + + teardown(function() + removeCommandLineMenuEvent(menuLabel) + deleteCommandLine(cmdLine) + end) + + it("a menu event added to the main command line can be removed again", function() + assert.is_true(addCommandLineMenuEvent(menuLabel, "wlsMenuEvent")) + assert.is_true(removeCommandLineMenuEvent(menuLabel)) + end) + + it("removing a menu event twice reports false and a message", function() + addCommandLineMenuEvent(menuLabel, "wlsMenuEvent") + removeCommandLineMenuEvent(menuLabel) + local ok, err = removeCommandLineMenuEvent(menuLabel) + assert.is_false(ok) + assert.are.equal(("removeCommandLineMenuEvent: cannot remove '%s', menu item does not exist"):format(menuLabel), err) + end) + + it("a menu event can be added to a named command line", function() + assert.is_true(addCommandLineMenuEvent(cmdLine, otherMenuLabel, "wlsMenuEvent2")) + assert.is_true(removeCommandLineMenuEvent(cmdLine, otherMenuLabel)) + end) + + it("addCommandLineMenuEvent reports an unknown command line", function() + local unknown = name("wlsNoSuchCmdLine") + local ok, err = addCommandLineMenuEvent(unknown, "label", "event") + assert.is_nil(ok) + assert.are.equal(('command line "%s" not found'):format(unknown), err) + end) + + it("disableCommandLine hides a command line and enableCommandLine shows it", function() + assert.is_true(windowVisible(cmdLine)) + assert.is_true(disableCommandLine(cmdLine)) + assert.is_false(windowVisible(cmdLine)) + assert.is_true(enableCommandLine(cmdLine)) + assert.is_true(windowVisible(cmdLine)) + end) + + it("the main command line cannot be enabled or disabled", function() + local ok, err = disableCommandLine("main") + assert.is_nil(ok) + assert.are.equal("this function is not permitted on the main command line", err) + local ok2, err2 = enableCommandLine("main") + assert.is_nil(ok2) + assert.are.equal("this function is not permitted on the main command line", err2) + end) + + it("enableCommandLine reports an unknown command line", function() + local unknown = name("wlsNoSuchCmdLine") + local ok, err = enableCommandLine(unknown) + assert.is_nil(ok) + assert.are.equal(('command line "%s" not found'):format(unknown), err) + end) + end) + + describe("setTextFormat", function() + local console = name("wlsFormatConsole") + + setup(function() + createMiniConsole(console, 10, 10, 400, 100) + end) + + before_each(function() + clearWindow(console) + resetFormat(console) + moveCursor(console, 0, 0) + deselect(console) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + it("sets the colours and attributes of following output", function() + assert.is_true(setTextFormat(console, 1, 2, 3, 250, 251, 252, true, false, true)) + echo(console, "formatted\n") + moveCursor(console, 0, 0) + selectSection(console, 0, 3) + local format = getTextFormat(console) + assert.are.same({250, 251, 252}, format.foreground) + assert.are.same({1, 2, 3}, format.background) + assert.is_true(format.bold) + assert.is_true(format.italic) + assert.is_false(format.underline) + end) + + it("clamps colour components above 255", function() + setTextFormat(console, 0, 0, 0, 999, 0, 0, false, false, false) + echo(console, "clamped\n") + moveCursor(console, 0, 0) + selectSection(console, 0, 3) + assert.are.same({255, 0, 0}, getTextFormat(console).foreground) + end) + + it("sets the optional strikeout, overline and reverse attributes", function() + assert.is_true(setTextFormat(console, 1, 2, 3, 4, 5, 6, false, false, false, true, true, true)) + echo(console, "optional\n") + moveCursor(console, 0, 0) + selectSection(console, 0, 3) + local format = getTextFormat(console) + assert.is_true(format.strikeout) + assert.is_true(format.overline) + assert.is_true(format.reverse) + assert.is_false(format.bold) + end) + + it("accepts an optional blink mode and reports it back", function() + assert.is_true(setTextFormat(console, 0, 0, 0, 1, 2, 3, false, false, false, false, false, false, "slow")) + echo(console, "slow blink\n") + moveCursor(console, 0, 0) + selectSection(console, 0, 3) + assert.are.equal("slow", getTextFormat(console).blinking) + end) + + it("rejects an unknown blink mode", function() + local ok, err = setTextFormat(console, 0, 0, 0, 1, 2, 3, false, false, false, false, false, false, "sometimes") + assert.is_nil(ok) + assert.are.equal('blink mode must be "none", "slow", or "fast", got "sometimes"', err) + end) + + it("takes numbers as well as booleans for the attribute flags", function() + assert.is_true(setTextFormat(console, 0, 0, 0, 1, 2, 3, 1, 0, 1)) + echo(console, "numeric\n") + moveCursor(console, 0, 0) + selectSection(console, 0, 3) + local format = getTextFormat(console) + assert.is_true(format.bold) + assert.is_false(format.underline) + assert.is_true(format.italic) + end) + + -- these four cover setTextFormat's raising paths, which used to leak the + -- objects the function built before validating (issue #9576) - they assert the + -- messages, the leak checker asserts the rest + + it("hard-errors on a non-number colour component", function() + local ok, err = pcall(setTextFormat, console, "red", 0, 0, 0, 0, 0, false, false, false) + assert.is_false(ok) + assert.is_truthy(err:find("setTextFormat: bad argument #2 type", 1, true)) + end) + + it("hard-errors on a non-boolean attribute", function() + local ok, err = pcall(setTextFormat, console, 0, 0, 0, 0, 0, 0, "yes", false, false) + assert.is_false(ok) + assert.is_truthy(err:find("setTextFormat: bad argument #8 type", 1, true)) + end) + + it("hard-errors on a non-boolean optional attribute", function() + local ok, err = pcall(setTextFormat, console, 0, 0, 0, 0, 0, 0, false, false, false, "yes") + assert.is_false(ok) + assert.is_truthy(err:find("setTextFormat: bad argument #11 type", 1, true)) + end) + + it("hard-errors on a blink mode that is not a string", function() + local ok, err = pcall(setTextFormat, console, 0, 0, 0, 0, 0, 0, false, false, false, false, false, false, {}) + assert.is_false(ok) + assert.is_truthy(err:find("setTextFormat: bad argument #14 type", 1, true)) + end) + + it("returns false and a message for an unknown window", function() + -- unlike most of the UI API this one reports false rather than nil + local unknown = name("wlsNoSuchWindow") + local ok, err = setTextFormat(unknown, 0, 0, 0, 0, 0, 0, false, false, false) + assert.is_false(ok) + assert.are.equal(("window '%s' does not exist"):format(unknown), err) + end) + end) + + describe("command line colours", function() + local console = name("wlsCommandColorConsole") + + setup(function() + createMiniConsole(console, 10, 10, 200, 100) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + it("setCommandForegroundColor and setCommandBackgroundColor accept a console", function() + assert.is_true(setCommandForegroundColor(console, 10, 20, 30)) + assert.is_true(setCommandBackgroundColor(console, 40, 50, 60, 128)) + end) + + it("both reject a colour component outside 0-255", function() + local ok, err = setCommandForegroundColor(console, 300, 0, 0) + assert.is_nil(ok) + assert.are.equal("red value 300 needs to be between 0-255", err) + local ok2, err2 = setCommandBackgroundColor(console, 0, 300, 0) + assert.is_nil(ok2) + assert.are.equal("green value 300 needs to be between 0-255", err2) + end) + + it("both report an unknown window", function() + local unknown = name("wlsNoSuchWindow") + local ok, err = setCommandForegroundColor(unknown, 1, 2, 3) + assert.is_nil(ok) + assert.are.equal(("window/label '%s' not found"):format(unknown), err) + local ok2, err2 = setCommandBackgroundColor(unknown, 1, 2, 3) + assert.is_nil(ok2) + assert.are.equal(("window/label '%s' not found"):format(unknown), err2) + end) + end) + + describe("getImageSize", function() + it("returns the size of a bundled image", function() + local w, h = getImageSize(":/icons/mudlet.png") + assert.is_true(w > 0) + assert.is_true(h > 0) + end) + + it("rejects an empty location", function() + local ok, err = getImageSize("") + assert.is_nil(ok) + assert.are.equal("image location cannot be an empty string", err) + end) + + it("reports a location it cannot read", function() + local ok, err = getImageSize("/wls/no/such/image.png") + assert.is_nil(ok) + assert.are.equal("couldn't retrieve image size, is the location '/wls/no/such/image.png' correct?", err) + end) + end) + + describe("setWindow reparenting", function() + local label = name("wlsReparentLabel") + local userWindow = sharedUserWindow + + setup(function() + createLabel(label, 11, 22, 100, 50, 1) + end) + + before_each(function() + setWindow("main", label, 11, 22, true) + end) + + teardown(function() + deleteLabel(label) + end) + + it("moves an element into a user window at the given position", function() + assert.is_true(setWindow(userWindow, label, 3, 4, true)) + local x, y, w, h = getWindowGeometry(label) + assert.are.same({3, 4, 100, 50}, {x, y, w, h}) + assert.is_true(windowVisible(label)) + end) + + it("moves an element back to the main window", function() + setWindow(userWindow, label, 3, 4, true) + assert.is_true(setWindow("main", label, 60, 70, true)) + local x, y = getWindowGeometry(label) + assert.are.same({60, 70}, {x, y}) + end) + + -- Qt hides a widget when it is reparented, and setWindow only calls show() + -- again when asked to, so an unshown element stays hidden after the move + it("leaves a reparented element hidden when asked not to show it", function() + assert.is_true(setWindow(userWindow, label, 3, 4, false)) + assert.is_false(windowVisible(label)) + end) + + it("defaults to the origin and to showing the element", function() + assert.is_true(setWindow(userWindow, label)) + local x, y = getWindowGeometry(label) + assert.are.same({0, 0}, {x, y}) + assert.is_true(windowVisible(label)) + end) + + it("reports an element it cannot find", function() + local unknown = name("wlsNoSuchElement") + local ok, err = setWindow("main", unknown, 0, 0, true) + assert.is_nil(ok) + assert.are.equal(("element '%s' not found"):format(unknown), err) + end) + + it("reports a parent window it cannot find", function() + local unknown = name("wlsNoSuchWindow") + local ok, err = setWindow(unknown, label, 0, 0, true) + assert.is_nil(ok) + assert.are.equal(("window '%s' not found"):format(unknown), err) + end) + end) + + describe("user window title and stylesheet", function() + local userWindow = sharedUserWindow + + teardown(function() + -- the window itself cannot be deleted, so undo what these specs set + resetUserWindowTitle(userWindow) + setUserWindowStyleSheet(userWindow, "") + end) + + it("setUserWindowTitle accepts a title and reports an unknown window", function() + assert.is_true(setUserWindowTitle(userWindow, "A title")) + local unknown = name("wlsNoSuchWindow") + local ok, err = setUserWindowTitle(unknown, "A title") + assert.is_nil(ok) + assert.are.equal(("user window name '%s' not found"):format(unknown), err) + end) + + it("setUserWindowStyleSheet accepts a stylesheet and reports an unknown window", function() + assert.is_true(setUserWindowStyleSheet(userWindow, "background-color: rgb(1,2,3);")) + local unknown = name("wlsNoSuchWindow") + local ok, err = setUserWindowStyleSheet(unknown, "background-color: rgb(1,2,3);") + assert.is_nil(ok) + assert.are.equal(("userwindow name '%s' not found"):format(unknown), err) + end) + end) + + describe("stacking and buffer transfer", function() + local label = name("wlsStackLabel") + local source = name("wlsStackSource") + local target = name("wlsStackTarget") + + setup(function() + createLabel(label, 10, 10, 60, 30, 1) + createMiniConsole(source, 10, 50, 300, 100) + createMiniConsole(target, 10, 160, 300, 100) + end) + + teardown(function() + deleteLabel(label) + deleteMiniConsole(source) + deleteMiniConsole(target) + end) + + it("raiseWindow and lowerWindow accept an element they know", function() + assert.is_true(raiseWindow(label)) + assert.is_true(lowerWindow(label)) + end) + + it("raiseWindow and lowerWindow return false for an unknown element", function() + local unknown = name("wlsNoSuchWindow") + assert.is_false(raiseWindow(unknown)) + assert.is_false(lowerWindow(unknown)) + end) + + it("pasteWindow places the copied selection into another console", function() + clearWindow(source) + clearWindow(target) + echo(source, "pasted line\n") + moveCursor(source, 0, 0) + selectCurrentLine(source) + copy(source) + pasteWindow(target) + assert.are.equal(1, getLineCount(target)) + assert.are.same({"pasted line"}, getLines(target, 0, 1)) + end) + + it("pasteWindow hard-errors on a non-string window name", function() + local ok, err = pcall(pasteWindow, {}) + assert.is_false(ok) + assert.is_truthy(err:find("pasteWindow: bad argument #1 type", 1, true)) + end) + + it("deleteTextEdit reports a text edit it cannot find", function() + local unknown = name("wlsNoSuchTextEdit") + local ok, err = deleteTextEdit(unknown) + assert.is_false(ok) + assert.are.equal(("text edit name '%s' not found"):format(unknown), err) + end) + + it("deleteLabel refuses to delete something that is not a label", function() + local ok, err = deleteLabel(source) + assert.is_false(ok) + assert.are.equal(("label name '%s' not found"):format(source), err) + end) + end) +end) + +-- Widget state getters: titles, stylesheets, tooltips, scroll bars and the map +-- widget's geometry, all of which could previously only be set. Self-contained +-- top-level block kept at the tail of the file; do not interleave it with the +-- blocks above. +describe("Widget state getters", function() + -- user windows and the map widget cannot be deleted from Lua, only hidden, + -- so keep the names unique per run + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local function name(base) + return base .. suffix + end + + local userWindow = name("wdgUserWindow") + local label = name("wdgLabel") + local console = name("wdgConsole") + local cmdLine = name("wdgCmdLine") + + setup(function() + -- loadLayout is off so a saved layout cannot move the window under us + openUserWindow(userWindow, false) + createLabel(label, 10, 20, 100, 50, 1) + createMiniConsole(console, 30, 40, 300, 150) + createCommandLine(cmdLine, 15, 25, 140, 35) + end) + + teardown(function() + deleteLabel(label) + deleteMiniConsole(console) + deleteCommandLine(cmdLine) + hideWindow(userWindow) + end) + + describe("getUserWindowTitle", function() + teardown(function() + resetUserWindowTitle(userWindow) + end) + + it("returns the title set by setUserWindowTitle", function() + assert.is_true(setUserWindowTitle(userWindow, "A user window title")) + assert.are.equal("A user window title", getUserWindowTitle(userWindow)) + end) + + it("round-trips an updated title", function() + setUserWindowTitle(userWindow, "first title") + assert.are.equal("first title", getUserWindowTitle(userWindow)) + setUserWindowTitle(userWindow, "second title") + assert.are.equal("second title", getUserWindowTitle(userWindow)) + end) + + it("reports the generated default title after resetUserWindowTitle", function() + setUserWindowTitle(userWindow, "not the default") + assert.is_true(resetUserWindowTitle(userWindow)) + local title = getUserWindowTitle(userWindow) + assert.are.equal("string", type(title)) + assert.is_truthy(title:find(getProfileName(), 1, true)) + assert.is_truthy(title:find(userWindow, 1, true)) + end) + + it("returns nil and a message naming an unknown user window", function() + local unknown = name("wdgNoSuchUserWindow") + local ok, err = getUserWindowTitle(unknown) + assert.is_nil(ok) + assert.are.equal(("user window name '%s' not found"):format(unknown), err) + end) + + it("says a miniconsole of that name is not a user window", function() + -- the same distinction setUserWindowTitle makes, so a script is not told + -- a name is free when it is already taken by something else + local ok, err = getUserWindowTitle(console) + assert.is_nil(ok) + assert.are.equal(('"%s" is not a user window'):format(console), err) + end) + + it("rejects an empty name the way setUserWindowTitle does", function() + local ok, err = getUserWindowTitle("") + assert.is_nil(ok) + assert.are.equal("a user window cannot have an empty string as its name", err) + end) + + it("errors when called without a name", function() + assert.has_error(function() getUserWindowTitle() end) + end) + end) + + describe("getUserWindowStyleSheet", function() + teardown(function() + setUserWindowStyleSheet(userWindow, "") + end) + + it("returns the stylesheet set by setUserWindowStyleSheet", function() + local css = "background-color: rgb(11,22,33);" + assert.is_true(setUserWindowStyleSheet(userWindow, css)) + assert.are.equal(css, getUserWindowStyleSheet(userWindow)) + end) + + it("round-trips an updated stylesheet", function() + setUserWindowStyleSheet(userWindow, "background-color: rgb(1,2,3);") + assert.are.equal("background-color: rgb(1,2,3);", getUserWindowStyleSheet(userWindow)) + setUserWindowStyleSheet(userWindow, "background-color: rgb(4,5,6);") + assert.are.equal("background-color: rgb(4,5,6);", getUserWindowStyleSheet(userWindow)) + end) + + it("reports an empty stylesheet once it is cleared", function() + setUserWindowStyleSheet(userWindow, "background-color: rgb(7,8,9);") + assert.is_true(setUserWindowStyleSheet(userWindow, "")) + assert.are.equal("", getUserWindowStyleSheet(userWindow)) + end) + + it("returns nil and a message naming an unknown user window", function() + local unknown = name("wdgNoSuchUserWindow") + local ok, err = getUserWindowStyleSheet(unknown) + assert.is_nil(ok) + assert.are.equal(("userwindow name '%s' not found"):format(unknown), err) + end) + + it("rejects an empty name the way setUserWindowStyleSheet does", function() + local ok, err = getUserWindowStyleSheet("") + assert.is_nil(ok) + assert.are.equal("a userwindow cannot have an empty string as its name", err) + end) + + it("errors when called without a name", function() + assert.has_error(function() getUserWindowStyleSheet() end) + end) + end) + + describe("getCmdLineStyleSheet", function() + local originalMainStyleSheet + + setup(function() + originalMainStyleSheet = getCmdLineStyleSheet() + end) + + teardown(function() + setCmdLineStyleSheet("main", originalMainStyleSheet) + setCmdLineStyleSheet(cmdLine, "") + end) + + it("returns the stylesheet set on a created command line", function() + local css = "color: rgb(12,34,56);" + assert.is_true(setCmdLineStyleSheet(cmdLine, css)) + assert.are.equal(css, getCmdLineStyleSheet(cmdLine)) + end) + + it("round-trips an updated stylesheet", function() + setCmdLineStyleSheet(cmdLine, "color: rgb(1,2,3);") + assert.are.equal("color: rgb(1,2,3);", getCmdLineStyleSheet(cmdLine)) + setCmdLineStyleSheet(cmdLine, "color: rgb(4,5,6);") + assert.are.equal("color: rgb(4,5,6);", getCmdLineStyleSheet(cmdLine)) + end) + + it("defaults to the main command line when given no name or nil", function() + -- the one-argument form of the setter targets "main" as well + local css = "color: rgb(9,9,9);" + assert.is_true(setCmdLineStyleSheet(css)) + assert.are.equal(css, getCmdLineStyleSheet()) + assert.are.equal(css, getCmdLineStyleSheet(nil)) + assert.are.equal(css, getCmdLineStyleSheet("main")) + end) + + it("returns nil and a message naming an unknown command line", function() + local unknown = name("wdgNoSuchCmdLine") + local ok, err = getCmdLineStyleSheet(unknown) + assert.is_nil(ok) + assert.are.equal(("command-line name '%s' not found"):format(unknown), err) + end) + end) + + describe("getLabelToolTip", function() + teardown(function() + resetLabelToolTip(label) + end) + + it("returns the tooltip set by setLabelToolTip", function() + assert.is_true(setLabelToolTip(label, "a tooltip")) + assert.are.equal("a tooltip", getLabelToolTip(label)) + end) + + -- only the text is read back: the setter's duration reaches Qt's own + -- tooltip timer, which reinterprets it, so it is not part of this getter + it("keeps the text when a display duration is given", function() + assert.is_true(setLabelToolTip(label, "a timed tooltip", 5)) + assert.are.equal("a timed tooltip", getLabelToolTip(label)) + end) + + it("round-trips a multi-byte tooltip unchanged", function() + assert.is_true(setLabelToolTip(label, "Ünïcödé tooltip - 日本語")) + assert.are.equal("Ünïcödé tooltip - 日本語", getLabelToolTip(label)) + end) + + it("reports an empty tooltip after resetLabelToolTip", function() + setLabelToolTip(label, "a tooltip to clear") + assert.is_true(resetLabelToolTip(label)) + assert.are.equal("", getLabelToolTip(label)) + end) + + it("returns nil and a message naming an unknown label", function() + local unknown = name("wdgNoSuchLabel") + local ok, err = getLabelToolTip(unknown) + assert.is_nil(ok) + assert.are.equal(("label name '%s' not found"):format(unknown), err) + end) + + it("rejects an empty name the way setLabelToolTip does", function() + local ok, err = getLabelToolTip("") + assert.is_nil(ok) + assert.are.equal("a label cannot have an empty string as its name", err) + end) + + it("errors when called without a label name", function() + assert.has_error(function() getLabelToolTip() end) + end) + end) + + describe("getScrollBarVisible", function() + local originalMainScrollBar + local freshConsole = name("wdgFreshConsole") + local bufferName = name("wdgBuffer") + + setup(function() + originalMainScrollBar = getScrollBarVisible("main") + end) + + teardown(function() + -- restore the shared main window even if a spec above bailed out early + if originalMainScrollBar then + enableScrollBar("main") + else + disableScrollBar("main") + end + showWindow(console) + deleteMiniConsole(freshConsole) + deleteMiniConsole(bufferName) + end) + + it("reflects enableScrollBar and disableScrollBar on a miniconsole", function() + enableScrollBar(console) + assert.is_true(getScrollBarVisible(console)) + disableScrollBar(console) + assert.is_false(getScrollBarVisible(console)) + enableScrollBar(console) + assert.is_true(getScrollBarVisible(console)) + end) + + it("reports a miniconsole's scroll bar as hidden until it is enabled", function() + createMiniConsole(freshConsole, 10, 10, 200, 100) + assert.is_false(getScrollBarVisible(freshConsole)) + enableScrollBar(freshConsole) + assert.is_true(getScrollBarVisible(freshConsole)) + end) + + it("keeps reporting an enabled scroll bar while the console is hidden", function() + -- the reason this reads back an intent rather than the widget: Mudlet + -- hides the whole console of any profile that is not the front tab + enableScrollBar(console) + hideWindow(console) + assert.is_true(getScrollBarVisible(console)) + showWindow(console) + assert.is_true(getScrollBarVisible(console)) + end) + + it("reports a buffer, which never has a scroll bar, as not having one", function() + createBuffer(bufferName) + assert.is_false(getScrollBarVisible(bufferName)) + end) + + it("reflects disableScrollBar and enableScrollBar on the main window", function() + disableScrollBar("main") + assert.is_false(getScrollBarVisible("main")) + enableScrollBar("main") + assert.is_true(getScrollBarVisible("main")) + end) + + it("defaults to the main window when given no name", function() + disableScrollBar("main") + assert.is_false(getScrollBarVisible()) + enableScrollBar("main") + assert.is_true(getScrollBarVisible()) + end) + + it("returns nil and a message naming an unknown window", function() + local unknown = name("wdgNoSuchWindow") + local ok, err = getScrollBarVisible(unknown) + assert.is_nil(ok) + assert.are.equal(('window "%s" not found'):format(unknown), err) + end) + end) + + -- The "no map widget" error path for these two is covered in Mapper_spec, + -- which runs first and reaches it by closing the widget. + describe("map widget getters", function() + setup(function() + assert.is_true(openMapWidget()) + end) + + teardown(function() + resetMapWindowTitle() + -- resizeMapWidget/moveMapWidget force the widget floating; put it back so + -- this block does not hand a floating map widget to whatever runs next + openMapWidget("r") + end) + + it("getMapWindowTitle returns the title set by setMapWindowTitle", function() + assert.is_true(setMapWindowTitle("A map title")) + assert.are.equal("A map title", getMapWindowTitle()) + end) + + it("getMapWindowTitle round-trips an updated title", function() + setMapWindowTitle("first map title") + assert.are.equal("first map title", getMapWindowTitle()) + setMapWindowTitle("second map title") + assert.are.equal("second map title", getMapWindowTitle()) + end) + + it("getMapWindowTitle reports the generated default after resetMapWindowTitle", function() + setMapWindowTitle("not the default") + assert.is_true(resetMapWindowTitle()) + local title = getMapWindowTitle() + assert.are.equal("string", type(title)) + assert.is_truthy(title:find(getProfileName(), 1, true)) + end) + + -- the sizes below are comfortably above the map widget's minimum size hint + -- so that a resize cannot come back clamped + it("getMapWidgetGeometry reflects resizeMapWidget", function() + -- size() is the exact inverse of the resize() resizeMapWidget makes and + -- does not depend on a window manager honouring a move + resizeMapWidget(640, 480) + local _, _, w, h = getMapWidgetGeometry() + assert.are.same({640, 480}, {w, h}) + resizeMapWidget(560, 440) + local _, _, w2, h2 = getMapWidgetGeometry() + assert.are.same({560, 440}, {w2, h2}) + end) + + it("getMapWidgetGeometry reflects moveMapWidget", function() + resizeMapWidget(600, 460) + moveMapWidget(120, 130) + local x1, y1 = getMapWidgetGeometry() + moveMapWidget(300, 350) + local x2, y2, w, h = getMapWidgetGeometry() + -- a window manager can add a constant frame offset to where a floating + -- dock lands, so the movement is asserted rather than the position + assert.are.same({180, 220}, {x2 - x1, y2 - y1}) + assert.are.same({600, 460}, {w, h}) + end) + + it("getMapWidgetGeometry returns exactly four values", function() + assert.are.equal(4, select("#", getMapWidgetGeometry())) + end) + end) +end) + +-- https://wiki.mudlet.org/w/Manual:UI_Functions +describe("Command line argument handling", function() + local cmdLine = "cmdArgHandlingLine" + + setup(function() + createCommandLine(cmdLine, 10, 10, 150, 30) + end) + + teardown(function() + deleteCommandLine(cmdLine) + clearCmdLine() + end) + + -- These seven take an optional leading window name and used to locate their + -- mandatory string at lua_gettop(L). Called with no arguments at all that is + -- index 0, which Lua 5.1 resolves to the first free stack slot instead of + -- rejecting - so the type check ran against whatever an earlier call had left + -- there, and a leftover string made the call quietly succeed on it. + local zeroArgumentFunctions = { + "addCmdLineSuggestion", + "appendCmdLine", + "removeCmdLineSuggestion", + "printCmdLine", + "setCmdLineStyleSheet", + "addCmdLineBlacklist", + "removeCmdLineBlacklist", + } + + -- leaves its argument in the stack slot the next call in the same function + -- body starts from, which is exactly the slot index 0 used to resolve to + local function leaveOnStack() end + + for _, functionName in ipairs(zeroArgumentFunctions) do + it(functionName .. " reports its missing argument as #1", function() + local ok, err = pcall(function() + leaveOnStack("cmdArgHandlingLeftover") + _G[functionName]() + end) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("bad argument #1", 1, true)) + end) + end + + it("printCmdLine with no arguments does not print unrelated stack data", function() + local functionName = "printCmdLine" + printCmdLine("kept text") + pcall(function() + leaveOnStack("cmdArgHandlingLeftover") + _G[functionName]() + end) + assert.are.equal("kept text", getCmdLine()) + end) + + it("appendCmdLine with no arguments does not append unrelated stack data", function() + local functionName = "appendCmdLine" + printCmdLine("kept text") + pcall(function() + leaveOnStack("cmdArgHandlingLeftover") + _G[functionName]() + end) + assert.are.equal("kept text", getCmdLine()) + end) + + it("setCmdLineStyleSheet with no arguments does not apply unrelated stack data", function() + local functionName = "setCmdLineStyleSheet" + setCmdLineStyleSheet("color: rgb(12,34,56);") + pcall(function() + leaveOnStack("cmdArgHandlingLeftover") + _G[functionName]() + end) + assert.are.equal("color: rgb(12,34,56);", getCmdLineStyleSheet()) + setCmdLineStyleSheet("") + end) + + describe("selectCmdLineText", function() + it("returns true for the main command line", function() + printCmdLine("select me") + assert.is_true(selectCmdLineText()) + -- selecting must not disturb what is typed + assert.are.equal("select me", getCmdLine()) + end) + + it("returns true for a named command line", function() + printCmdLine(cmdLine, "select me too") + assert.is_true(selectCmdLineText(cmdLine)) + assert.are.equal("select me too", getCmdLine(cmdLine)) + end) + + it("returns nil and a message naming an unknown command line", function() + local ok, err = selectCmdLineText("cmdArgHandlingNoSuchLine") + assert.is_nil(ok) + assert.is_truthy(tostring(err):find("cmdArgHandlingNoSuchLine", 1, true)) + end) + end) +end) + +-- The movie API needs a real animated GIF to work on. Rather than commit a +-- binary fixture, one is assembled here: three frames so setMovieFrame() has +-- somewhere to jump to, and a 60 second frame delay so the animation never +-- advances on its own while a spec is reading the movie back. +local function threeFrameGif() + -- 1x1 logical screen, global colour table of four entries + local logicalScreen = "GIF89a" .. string.char(1, 0, 1, 0, 0x91, 0, 0) + local globalColourTable = string.char(255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0) + -- graphic control extension: 0x1770 hundredths of a second per frame + local graphicControl = string.char(0x21, 0xF9, 0x04, 0x00, 0x70, 0x17, 0x00, 0x00) + local imageDescriptor = string.char(0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0) + -- LZW, minimum code size 2: a clear code, one pixel, end of information + local imageData = string.char(0x02, 0x02, 0x4C, 0x01, 0x00) + local frame = graphicControl .. imageDescriptor .. imageData + return logicalScreen .. globalColourTable .. frame:rep(3) .. string.char(0x3B) +end + +-- The fixtures below are generated at run time rather than committed, and they +-- go in the profile directory the way DB_spec's and Package_spec's do: it is +-- writable on every platform, where /tmp does not exist on Windows at all. +-- Every one of them is removed again in teardown. +local function specFilePath(name) + return ("%s/%s"):format(getMudletHomeDir(), name) +end + +-- binary mode: the GIF must not be newline-translated +local function writeSpecFile(path, contents) + local handle = io.open(path, "wb") + assert.is_not_nil(handle, "could not open " .. path .. " for writing") + assert.is_not_nil(handle:write(contents), "could not write " .. path) + handle:close() +end + +describe("Label movies", function() + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local giffile = specFilePath(("mudlet-spec-movie%s.gif"):format(suffix)) + local notAGifFile = specFilePath(("mudlet-spec-notamovie%s.gif"):format(suffix)) + local missingFile = specFilePath(("mudlet-spec-there-is-no-such%s.gif"):format(suffix)) + + -- every movie function takes a label name first and rejects the same three + -- ways, so the shared cases are driven over the whole family + -- an array rather than a keyed table so the specs are always generated in + -- the same order + local movieFunctions = { + {"setMovie", function(labelName) return setMovie(labelName, giffile) end}, + {"startMovie", startMovie}, + {"pauseMovie", pauseMovie}, + {"scaleMovie", scaleMovie}, + {"setMovieSpeed", function(labelName) return setMovieSpeed(labelName, 100) end}, + {"setMovieFrame", function(labelName) return setMovieFrame(labelName, 0) end}, + } + -- setMovie reports a missing label itself, the rest go through the shared + -- label lookup, so the two say it differently + local ownsItsLabelLookup = {setMovie = true} + + local function gifStats() + local gifs = getProfileStats().gifs + return gifs.total, gifs.active + end + + setup(function() + writeSpecFile(giffile, threeFrameGif()) + writeSpecFile(notAGifFile, "this is not a GIF at all") + end) + + teardown(function() + os.remove(giffile) + os.remove(notAGifFile) + end) + + describe("setMovie", function() + local label = "movieSetLabel" .. suffix + + before_each(function() + createLabel(label, 10, 10, 60, 30, 1) + end) + + after_each(function() + deleteLabel(label) + end) + + it("returns true and registers the gif with the profile", function() + local totalBefore, activeBefore = gifStats() + assert.is_true(setMovie(label, giffile)) + local totalAfter, activeAfter = gifStats() + assert.are.equal(totalBefore + 1, totalAfter) + -- setMovie starts the movie as well as loading it + assert.are.equal(activeBefore + 1, activeAfter) + end) + + it("reuses the same movie when called twice on one label", function() + assert.is_true(setMovie(label, giffile)) + local totalAfterFirst = gifStats() + assert.is_true(setMovie(label, giffile)) + local totalAfterSecond = gifStats() + assert.are.equal(totalAfterFirst, totalAfterSecond) + end) + + it("deleting the label unregisters its gif again", function() + local totalBefore = gifStats() + assert.is_true(setMovie(label, giffile)) + assert.are.equal(totalBefore + 1, gifStats()) + assert.is_true(deleteLabel(label)) + assert.are.equal(totalBefore, gifStats()) + end) + + it("returns nil and a message for a file that is not a movie", function() + local ok, err = setMovie(label, notAGifFile) + assert.is_nil(ok) + assert.are.equal(("no valid movie found at '%s'"):format(notAGifFile), err) + end) + + it("returns nil and a message for a file that is not there", function() + local ok, err = setMovie(label, missingFile) + assert.is_nil(ok) + assert.are.equal(("no valid movie found at '%s'"):format(missingFile), err) + end) + + it("a refused movie leaves no gif registered", function() + pending("the QMovie is made and handed to the gif tracker before the file is read, so a refused setMovie still leaves one counted in getProfileStats()") + end) + + it("a refused movie over a working one leaves the label driving the dead movie", function() + pending("Host::setMovie calls setFileName on the label's live QMovie before it finds out the new file is not a movie, so the label keeps a movie the call said it would not have") + end) + + it("a refused movie leaves the label without a movie to drive", function() + assert.is_nil(setMovie(label, notAGifFile)) + local ok, err = startMovie(label) + assert.is_nil(ok) + assert.are.equal(("no movie found at label '%s'"):format(label), err) + end) + + it("hard-errors when the movie path is missing", function() + local ok, err = pcall(setMovie, label) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setMovie: bad argument #2 type", 1, true)) + end) + + it("hard-errors on a non-string movie path", function() + local ok, err = pcall(setMovie, label, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setMovie: bad argument #2 type", 1, true)) + end) + end) + + describe("start, pause and the other movie functions", function() + local label = "movieRunLabel" .. suffix + local labelWithoutMovie = "movieBareLabel" .. suffix + + setup(function() + createLabel(labelWithoutMovie, 10, 50, 60, 30, 1) + end) + + teardown(function() + deleteLabel(labelWithoutMovie) + end) + + before_each(function() + createLabel(label, 10, 10, 60, 30, 1) + assert.is_true(setMovie(label, giffile)) + end) + + after_each(function() + deleteLabel(label) + end) + + it("pauseMovie stops the gif counting as active", function() + local _, activeWhileRunning = gifStats() + assert.is_true(pauseMovie(label)) + local _, activeWhilePaused = gifStats() + assert.are.equal(activeWhileRunning - 1, activeWhilePaused) + end) + + it("startMovie makes a paused gif count as active again", function() + assert.is_true(pauseMovie(label)) + local _, activeWhilePaused = gifStats() + assert.is_true(startMovie(label)) + local _, activeAfterStart = gifStats() + assert.are.equal(activeWhilePaused + 1, activeAfterStart) + end) + + it("startMovie on an already running movie leaves it active", function() + local _, activeWhileRunning = gifStats() + assert.is_true(startMovie(label)) + local _, activeAfterStart = gifStats() + assert.are.equal(activeWhileRunning, activeAfterStart) + end) + + it("setMovieSpeed returns true and does not stop the movie", function() + local _, activeBefore = gifStats() + assert.is_true(setMovieSpeed(label, 50)) + local _, activeAfter = gifStats() + assert.are.equal(activeBefore, activeAfter) + assert.is_true(setMovieSpeed(label, 100)) + end) + + it("setMovieSpeed hard-errors on a non-number speed", function() + local ok, err = pcall(setMovieSpeed, label, "fast") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setMovieSpeed: bad argument #2 type", 1, true)) + end) + + it("setMovieFrame answers whether the frame could be jumped to", function() + assert.is_true(setMovieFrame(label, 1)) + -- the fixture only has three frames + assert.is_false(setMovieFrame(label, 99)) + assert.is_false(setMovieFrame(label, -1)) + end) + + it("setMovieFrame hard-errors on a non-number frame", function() + local ok, err = pcall(setMovieFrame, label, "second") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setMovieFrame: bad argument #2 type", 1, true)) + end) + + -- the scaling itself is not readable from Lua: all these can check is that + -- turning it on and off is accepted and leaves the movie alone + it("scaleMovie returns true with, without and against its optional argument", function() + assert.is_true(scaleMovie(label)) + assert.is_true(scaleMovie(label, true)) + assert.is_true(scaleMovie(label, false)) + -- turning scaling off and on again must leave the movie usable + assert.is_true(scaleMovie(label, true)) + assert.is_true(startMovie(label)) + end) + + it("scaleMovie hard-errors on a non-boolean second argument", function() + local ok, err = pcall(scaleMovie, label, "yes") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("scaleMovie: bad argument #2 type", 1, true)) + end) + + for _, movieFunction in ipairs(movieFunctions) do + local functionName, call = movieFunction[1], movieFunction[2] + + it(functionName .. " hard-errors on a label name that is no string", function() + local ok, err = pcall(call, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find(functionName .. ": bad argument #1 type", 1, true)) + end) + + it(functionName .. " returns nil and a message for an empty label name", function() + local ok, err = call("") + assert.is_nil(ok) + assert.are.equal("label name cannot be an empty string", err) + end) + + it(functionName .. " returns nil and a message naming an unknown label", function() + local unknown = "movieNoSuchLabel" .. suffix + local ok, err = call(unknown) + assert.is_nil(ok) + if ownsItsLabelLookup[functionName] then + assert.are.equal(("label '%s' does not exist"):format(unknown), err) + else + assert.are.equal(('label "%s" not found'):format(unknown), err) + end + end) + end + + for _, movieFunction in ipairs(movieFunctions) do + local functionName, call = movieFunction[1], movieFunction[2] + if functionName ~= "setMovie" then + it(functionName .. " returns nil and a message for a label with no movie", function() + local ok, err = call(labelWithoutMovie) + assert.is_nil(ok) + assert.are.equal(("no movie found at label '%s'"):format(labelWithoutMovie), err) + end) + end + end + end) +end) + +describe("Console buffer size", function() + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local console = "bufferSizeConsole" .. suffix + local mainLinesLimit, mainBatchSize + + setup(function() + createMiniConsole(console, 0, 0, 400, 200) + mainLinesLimit, mainBatchSize = getConsoleBufferSize() + end) + + teardown(function() + deleteMiniConsole(console) + setConsoleBufferSize(mainLinesLimit, mainBatchSize) + end) + + it("getConsoleBufferSize reports two numbers for the main console", function() + local linesLimit, batchSize = getConsoleBufferSize() + assert.are.equal("number", type(linesLimit)) + assert.are.equal("number", type(batchSize)) + assert.is_true(linesLimit >= 100) + assert.is_true(batchSize > 0) + end) + + it("setConsoleBufferSize round-trips through getConsoleBufferSize", function() + assert.is_true(setConsoleBufferSize(console, 5000, 500)) + assert.are.same({5000, 500}, {getConsoleBufferSize(console)}) + assert.is_true(setConsoleBufferSize(console, 1000, 100)) + assert.are.same({1000, 100}, {getConsoleBufferSize(console)}) + end) + + it("setConsoleBufferSize round-trips on the main console too", function() + assert.is_true(setConsoleBufferSize(2500, 250)) + assert.are.same({2500, 250}, {getConsoleBufferSize()}) + assert.is_true(setConsoleBufferSize(mainLinesLimit, mainBatchSize)) + assert.are.same({mainLinesLimit, mainBatchSize}, {getConsoleBufferSize()}) + end) + + it("a lines limit under the hundred line floor is raised to it", function() + assert.is_true(setConsoleBufferSize(console, 10, 5)) + local linesLimit = getConsoleBufferSize(console) + assert.are.equal(100, linesLimit) + end) + + it("a batch deletion size that is not smaller than the limit is cut to a tenth", function() + assert.is_true(setConsoleBufferSize(console, 1000, 1000)) + assert.are.same({1000, 100}, {getConsoleBufferSize(console)}) + end) + + it("the buffer actually stops growing past the limit that was set", function() + clearWindow(console) + assert.is_true(setConsoleBufferSize(console, 100, 10)) + for lineNumber = 1, 400 do + echo(console, ("buffer line %d\n"):format(lineNumber)) + end + local lineCount = getLineCount(console) + -- the buffer is trimmed a batch at a time once it is over the limit, so it + -- settles within one batch of the limit rather than exactly on it + assert.is_true(lineCount <= 110, "line count was " .. lineCount) + assert.is_true(lineCount >= 90, "line count was " .. lineCount) + end) + + it("a bigger limit lets the same buffer hold more", function() + clearWindow(console) + assert.is_true(setConsoleBufferSize(console, 300, 10)) + for lineNumber = 1, 400 do + echo(console, ("buffer line %d\n"):format(lineNumber)) + end + local lineCount = getLineCount(console) + assert.is_true(lineCount >= 290, "line count was " .. lineCount) + assert.is_true(lineCount <= 310, "line count was " .. lineCount) + end) + + it("useMaximum raises the main console to the buffer maximum", function() + -- the main console has to be named for this one: with three arguments the + -- first is read as a window name, so the four argument form only lines up + -- when it is actually given one. The lines limit is then discarded and the + -- machine's maximum used instead + local before = getConsoleBufferSize() + assert.is_true(setConsoleBufferSize("main", 1000, 100, true)) + local maximum = getConsoleBufferSize() + assert.is_true(maximum > 1000, "maximum was " .. maximum) + assert.is_true(setConsoleBufferSize(before, mainBatchSize)) + assert.are.equal(before, getConsoleBufferSize()) + end) + + it("the useMaximum flag needs the window to be named", function() + -- without a name the flag lands in the batch deletion size's place + local ok, err = pcall(setConsoleBufferSize, 1000, 100, true) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setConsoleBufferSize: bad argument #3 type", 1, true)) + end) + + it("useMaximum is refused for anything but the main console", function() + local ok, err = setConsoleBufferSize(console, 1000, 100, true) + assert.is_nil(ok) + assert.are.equal("useMaximum parameter is only supported for the main console", err) + end) + + it("setConsoleBufferSize hard-errors on a non-number lines limit", function() + local ok, err = pcall(setConsoleBufferSize, console, "lots", 100) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setConsoleBufferSize: bad argument #2 type", 1, true)) + end) + + it("setConsoleBufferSize hard-errors on a non-number batch deletion size", function() + local ok, err = pcall(setConsoleBufferSize, console, 1000, "some") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setConsoleBufferSize: bad argument #3 type", 1, true)) + end) + + it("both functions return nil and a message naming an unknown window", function() + local unknown = "bufferSizeNoSuchWindow" .. suffix + local getOk, getErr = getConsoleBufferSize(unknown) + assert.is_nil(getOk) + assert.are.equal(('window "%s" not found'):format(unknown), getErr) + local setOk, setErr = setConsoleBufferSize(unknown, 1000, 100) + assert.is_nil(setOk) + assert.are.equal(('window "%s" not found'):format(unknown), setErr) + end) +end) + +describe("Main window size and saved layout", function() + -- resizing is a window manager request, so the size that comes back is only + -- ever an approximation of what was asked for; these specs check that the + -- request lands and that the reported size follows it, not that it matches + local testMode = os.getenv("MUDLET_TEST_MODE") ~= nil + local originalWidth, originalHeight + -- whether the console reports a size to measure against at all, and whether + -- this display honours a resize request - without a window manager it need + -- not, and then there is nothing here to measure or to put back + local measurable = false + local resizable = false + -- on a platform where resizing is known to work, a resize that stops working + -- is a regression rather than an environment quirk, so the CI legs that can + -- resize set this and turn the skips below into failures + local resizeRequired = os.getenv("MUDLET_TEST_REQUIRE_WINDOW_RESIZE") ~= nil + + local function resizableWindowAvailable() + if not measurable then + -- a console that latches to a zero size is a defect of its own, and the + -- console metrics specs earlier in this file report it; the resize gate + -- is not about that, so it stays out of the way here + pending("the console reports no size to measure a resize against") + return false + end + if resizeRequired then + assert.is_true(resizable, + "MUDLET_TEST_REQUIRE_WINDOW_RESIZE is set, but this display did not honour a resize request") + return true + end + if not resizable then + pending("this display does not honour a resize request, so there is nothing to measure") + return false + end + return true + end + + -- setMainWindowSize sizes the whole application window while + -- getMainWindowSize reports the console area inside it, and the chrome + -- between the two (menu bar, profile tabs, toolbars, command line) is not + -- readable from Lua. So the size is put back by asking for the console size + -- that was wanted and correcting by however much came back short. + local function restoreMainWindowSize() + if not measurable then + return false + end + local requestedWidth, requestedHeight = originalWidth, originalHeight + for _ = 1, 4 do + setMainWindowSize(requestedWidth, requestedHeight) + pumpEvents(100) + local width, height = getMainWindowSize() + if width == originalWidth and height == originalHeight then + return true + end + requestedWidth = requestedWidth + (originalWidth - width) + requestedHeight = requestedHeight + (originalHeight - height) + end + return false + end + + setup(function() + local firstWidth, firstHeight = getMainWindowSize() + measurable = testMode and firstWidth > 0 and firstHeight > 0 + if not measurable then + return + end + setMainWindowSize(firstWidth + 300, firstHeight + 300) + pumpEvents(200) + local width, height = getMainWindowSize() + resizable = width > firstWidth and height > firstHeight + + -- A dock another spec file left open - the map widget is the one that does + -- this - only takes its width out of the console at the next re-layout, + -- which is the resize just above. So the size to put the window back to is + -- read after asking for the first one again rather than before: a size the + -- window has actually been is a size it can be put back to. + setMainWindowSize(firstWidth, firstHeight) + pumpEvents(200) + originalWidth, originalHeight = getMainWindowSize() + restoreMainWindowSize() + end) + + teardown(restoreMainWindowSize) + + it("setMainWindowSize hard-errors on a non-number width", function() + local ok, err = pcall(setMainWindowSize, "wide", 600) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setMainWindowSize: bad argument #1 type", 1, true)) + end) + + it("setMainWindowSize hard-errors on a non-number height", function() + local ok, err = pcall(setMainWindowSize, 800, "tall") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setMainWindowSize: bad argument #2 type", 1, true)) + end) + + it("a bigger main window is reported as bigger", function() + if not resizableWindowAvailable() then + return + end + finally(restoreMainWindowSize) + local smallWidth, smallHeight = 700, 500 + -- and it answers nothing at all while it is at it + assert.are.equal(0, select("#", setMainWindowSize(smallWidth, smallHeight))) + pumpEvents(200) + local narrowWidth, shortHeight = getMainWindowSize() + + setMainWindowSize(smallWidth + 300, smallHeight + 300) + pumpEvents(200) + local wideWidth, tallHeight = getMainWindowSize() + + assert.is_true(wideWidth > narrowWidth, ("%d was not wider than %d"):format(wideWidth, narrowWidth)) + assert.is_true(tallHeight > shortHeight, ("%d was not taller than %d"):format(tallHeight, shortHeight)) + -- the console never claims more room than the window it sits in + assert.is_true(wideWidth <= smallWidth + 300) + assert.is_true(tallHeight <= smallHeight + 300) + end) + + it("the main window can be put back the size it was", function() + if not resizableWindowAvailable() then + return + end + setMainWindowSize(640, 480) + pumpEvents(200) + assert.is_true(restoreMainWindowSize(), "the window could not be put back") + assert.are.same({originalWidth, originalHeight}, {getMainWindowSize()}) + end) + + describe("saveWindowLayout and loadWindowLayout", function() + -- the layout lives beside the profiles directory rather than inside the + -- profile, so these specs write outside the profile and have to put both + -- files back the way they found them + local configurationDirectory = getMudletHomeDir():match("^(.*)/profiles/[^/]*$") + assert(configurationDirectory, "could not work out the configuration directory from " .. getMudletHomeDir()) + local layoutFiles = { + configurationDirectory .. "/windowLayout.dat", + configurationDirectory .. "/windowLayoutGeometry.dat", + } + local contentsBefore = {} + + setup(function() + for _, path in ipairs(layoutFiles) do + local handle = io.open(path, "rb") + if handle then + contentsBefore[path] = handle:read("*a") + handle:close() + end + end + end) + + -- after every spec rather than at the end of the block: these are the + -- shared files the next Mudlet start reads its layout from, so no more than + -- one spec's worth of writing to them is ever outstanding + after_each(function() + for _, path in ipairs(layoutFiles) do + if contentsBefore[path] then + writeSpecFile(path, contentsBefore[path]) + else + os.remove(path) + end + end + end) + + it("saveWindowLayout returns true and writes the layout file", function() + local layoutFile = layoutFiles[1] + -- taking the file away first is what makes this about the call rather + -- than about a file an earlier session left behind + os.remove(layoutFile) + assert.is_nil(lfs.attributes(layoutFile, "mode")) + assert.is_true(saveWindowLayout()) + assert.is_not_nil(lfs.attributes(layoutFile, "mode"), layoutFile .. " was not written") + assert.is_true(lfs.attributes(layoutFile, "size") > 0) + end) + + it("saving twice in a row keeps returning true", function() + -- the underlying save refuses a second time in a row, but the Lua + -- function clears that flag before every call + assert.is_true(saveWindowLayout()) + assert.is_true(saveWindowLayout()) + end) + + it("loadWindowLayout reads back a layout that was saved", function() + assert.is_true(saveWindowLayout()) + assert.is_true(loadWindowLayout()) + -- loading twice is not refused the way saving twice would be + assert.is_true(loadWindowLayout()) + end) + + it("verifying the restored dock geometry", function() + pending("dock widget geometry is not readable from Lua - needs a functional test") + end) + end) +end) + +describe("Application and profile style sheets", function() + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + + teardown(function() + -- leave no styling behind for the rest of the suite + setAppStyleSheet("") + setProfileStyleSheet("") + end) + + -- sysAppStyleSheetChange is raised from inside setAppStyleSheet(), before a + -- waitForEvent() could be armed, so the handler has to be there first + local function collectStyleSheetEvents() + local events = {} + local handler = registerAnonymousEventHandler("sysAppStyleSheetChange", function(_, ...) + events[#events + 1] = {...} + end) + finally(function() killAnonymousEventHandler(handler) end) + return events + end + + describe("setAppStyleSheet", function() + it("returns true and raises sysAppStyleSheetChange with the tag and profile", function() + local events = collectStyleSheetEvents() + local tag = "appStyleTag" .. suffix + assert.is_true(setAppStyleSheet("QLabel { color: rgb(1,2,3); }", tag)) + assert.are.equal(1, #events) + assert.are.equal(tag, events[1][1]) + assert.are.equal(getProfileName(), events[1][2]) + end) + + it("raises the event with an empty tag when none is given", function() + local events = collectStyleSheetEvents() + assert.is_true(setAppStyleSheet("QLabel { color: rgb(4,5,6); }")) + assert.are.equal(1, #events) + assert.are.equal("", events[1][1]) + assert.are.equal(getProfileName(), events[1][2]) + end) + + it("accepts an empty style sheet and still announces the change", function() + local events = collectStyleSheetEvents() + assert.is_true(setAppStyleSheet("")) + assert.are.equal(1, #events) + end) + + it("hard-errors on a non-string style sheet", function() + local ok, err = pcall(setAppStyleSheet, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setAppStyleSheet: bad argument #1 type", 1, true)) + end) + + it("hard-errors on a non-string tag", function() + local ok, err = pcall(setAppStyleSheet, "", {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setAppStyleSheet: bad argument #2 type", 1, true)) + end) + + it("a rejected call raises no event", function() + local events = collectStyleSheetEvents() + pcall(setAppStyleSheet, {}) + assert.are.equal(0, #events) + end) + end) + + describe("setProfileStyleSheet", function() + it("returns true for a style sheet and for an empty one", function() + assert.is_true(setProfileStyleSheet("QWidget { color: rgb(7,8,9); }")) + assert.is_true(setProfileStyleSheet("")) + end) + + it("raises no sysAppStyleSheetChange - it is per profile, not per application", function() + local events = collectStyleSheetEvents() + assert.is_true(setProfileStyleSheet("QWidget { color: rgb(9,8,7); }")) + assert.are.equal(0, #events) + setProfileStyleSheet("") + end) + + it("hard-errors on a non-string style sheet", function() + local ok, err = pcall(setProfileStyleSheet, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setProfileStyleSheet: bad argument #1 type", 1, true)) + end) + + it("verifying what the profile style sheet actually paints", function() + pending("there is no getProfileStyleSheet, and the effect is only visible in a screenshot") + end) + end) +end) + +-- Lua can create a toolbar and buttons (tempButtonToolbar/tempButton) but not a +-- push-down one, and it cannot remove either again - so the buttons the button +-- specs need come from a package that is installed for the block and +-- uninstalled after it, which takes them away again with it. Installing starts +-- a profile save, and the uninstall is refused until that save has drained, +-- which only happens when the event loop runs. +if not os.getenv("MUDLET_TEST_MODE") then + +describe("Toolbar buttons", function() + it("needs test mode", function() + pending("the button specs install a package for a push-down button, which needs pumpEvents()") + end) +end) + +else + +describe("Toolbar buttons", function() + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local packageName = "mudlet-spec-buttons" .. suffix + local toolbar = "buttonSpecToolbar" .. suffix + local pushDownButton = "buttonSpecPushDown" .. suffix + local plainButton = "buttonSpecPlain" .. suffix + local packageFile = specFilePath(packageName .. ".xml") + + local function actionXml(name, pushButton, isFolder) + return ([[<Action isActive="yes" isFolder="%s" isPushButton="%s" isFlatButton="no" useCustomLayout="no"> + <name>%s</name> + <script></script> + <css></css> + <commandButtonUp></commandButtonUp> + <commandButtonDown></commandButtonDown> + <icon></icon> + <orientation>0</orientation> + <location>0</location> + <buttonRotation>0</buttonRotation> + <sizeX>0</sizeX> + <sizeY>0</sizeY> + <mButtonState>1</mButtonState> + <buttonColumn>1</buttonColumn> + <buttonFillerOffset>0</buttonFillerOffset> + <posX>0</posX> + <posY>0</posY> + ]]):format(isFolder, pushButton, name) + end + + local function packageXml() + return table.concat({ + [[<?xml version="1.0" encoding="UTF-8"?>]], + [[<!DOCTYPE MudletPackage>]], + [[<MudletPackage version="1.001">]], + [[<ActionPackage>]], + actionXml(toolbar, "no", "yes"), + actionXml(pushDownButton, "yes", "no"), "</Action>", + actionXml(plainButton, "no", "no"), "</Action>", + "</Action>", + [[</ActionPackage>]], + [[</MudletPackage>]], + }, "\n") + end + + local function waitUntil(condition, timeoutMilliseconds) + local waited = 0 + while waited < timeoutMilliseconds do + if condition() then + return true + end + pumpEvents(50) + waited = waited + 50 + end + return condition() and true or false + end + + local function packageIsInstalled() + for _, name in ipairs(getPackages()) do + if name == packageName then + return true + end + end + return false + end + + -- Installing and uninstalling each start a profile save, and Lua cannot ask + -- whether one is running - but installPackage() gives it away: while a save is + -- in flight it postpones whatever it was asked to do and answers true, even + -- for the empty path it would otherwise refuse outright. + local function waitForProfileSaveToPass() + return waitUntil(function() return installPackage("") == nil end, 5000) + end + + setup(function() + writeSpecFile(packageFile, packageXml()) + assert.is_true(waitForProfileSaveToPass(), "a profile save was already running, so this install would be postponed") + assert.is_true(installPackage(packageFile), "could not install " .. packageFile) + assert.is_true(waitUntil(packageIsInstalled, 5000), packageName .. " did not turn up in getPackages()") + end) + + teardown(function() + -- asking whether the package is here rather than whether setup thought it + -- arrived: installPackage() postpones itself behind a running profile save, + -- so it can still land after setup gave up waiting, and then nothing else + -- would ever take it out of the reused profile again + if packageIsInstalled() then + -- uninstalling is refused while the save the install started is still + -- draining, and that only finishes when the event loop runs + assert.is_true(waitUntil(function() return uninstallPackage(packageName) == true end, 5000), + packageName .. " could not be uninstalled") + assert.is_true(waitUntil(function() return not packageIsInstalled() end, 5000), + packageName .. " was still installed after being uninstalled") + end + -- The save uninstallPackage() asks for is queued, not started there and + -- then, so it has to be given the event loop before anything can see it + -- running - ask too early and the wait below passes while the save is still + -- only pending. It has to finish here rather than during Mudlet's shutdown, + -- which gives up waiting after a thousand iterations and tears down around + -- the writer that is still going (a segfault on the quicker runners). + pumpEvents(300) + assert.is_true(waitForProfileSaveToPass(), "the profile save the uninstall queued never finished") + pumpEvents(100) + assert.is_true(waitForProfileSaveToPass(), "another profile save was queued behind the first") + os.remove(packageFile) + end) + + describe("setButtonState and getButtonState", function() + after_each(function() + setButtonState(pushDownButton, false) + end) + + it("round-trips a button state by name", function() + assert.is_false(getButtonState(pushDownButton)) + assert.is_true(setButtonState(pushDownButton, true)) + assert.is_true(getButtonState(pushDownButton)) + assert.is_true(setButtonState(pushDownButton, false)) + assert.is_false(getButtonState(pushDownButton)) + end) + + it("setButtonState answers false when the state was already what was asked for", function() + assert.is_true(setButtonState(pushDownButton, true)) + assert.is_false(setButtonState(pushDownButton, true)) + -- and the state it reported no change to is still the one that was asked for + assert.is_true(getButtonState(pushDownButton)) + end) + + it("both refuse an item ID that is no button", function() + local getOk, getErr = getButtonState(999999) + assert.is_nil(getOk) + assert.are.equal("no button item with ID 999999 found", getErr) + local setOk, setErr = setButtonState(999999, true) + assert.is_nil(setOk) + assert.are.equal("no button item with ID 999999 found", setErr) + end) + + it("getButtonState with no arguments answers the console's own button state", function() + -- with no arguments this answers TConsole::mButtonState, which is 1 or 2 + -- rather than the boolean the named form answers, and which only a real + -- click on a push-down button writes - setButtonState never touches it + local before = getButtonState() + assert.is_true(before == 1 or before == 2, "state was " .. tostring(before)) + setButtonState(pushDownButton, true) + assert.are.equal(before, getButtonState()) + end) + + it("both refuse a button that is not a push-down one", function() + local getOk, getErr = getButtonState(plainButton) + assert.is_nil(getOk) + assert.are.equal(("item with name '%s' is not a push-down button"):format(plainButton), getErr) + local setOk, setErr = setButtonState(plainButton, true) + assert.is_nil(setOk) + assert.are.equal(("item with name '%s' is not a push-down button"):format(plainButton), setErr) + end) + + it("both refuse a name that is no button at all", function() + local unknown = "buttonSpecNoSuchButton" .. suffix + local getOk, getErr = getButtonState(unknown) + assert.is_nil(getOk) + assert.are.equal(("no button item with name '%s' found"):format(unknown), getErr) + local setOk, setErr = setButtonState(unknown, true) + assert.is_nil(setOk) + assert.are.equal(("no button item with name '%s' found"):format(unknown), setErr) + end) + + it("both refuse an empty button name", function() + local getOk, getErr = getButtonState("") + assert.is_nil(getOk) + assert.are.equal("item name must not be an empty string", getErr) + local setOk, setErr = setButtonState("", true) + assert.is_nil(setOk) + assert.are.equal("item name must not be an empty string", setErr) + end) + + it("both refuse a negative item ID", function() + local getOk, getErr = getButtonState(-1) + assert.is_nil(getOk) + assert.is_truthy(tostring(getErr):find("must be equal or greater than zero", 1, true)) + local setOk, setErr = setButtonState(-1, true) + assert.is_nil(setOk) + assert.is_truthy(tostring(setErr):find("must be equal or greater than zero", 1, true)) + end) + + it("setButtonState hard-errors when the state is not a boolean", function() + local ok, err = pcall(setButtonState, pushDownButton, "down") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setButtonState: bad argument #2 type", 1, true)) + end) + + it("both hard-error when the button is given as neither a name nor an ID", function() + local getOk, getErr = pcall(getButtonState, {}) + assert.is_false(getOk) + assert.is_truthy(tostring(getErr):find("getButtonState: bad argument #1 type", 1, true)) + local setOk, setErr = pcall(setButtonState, {}, true) + assert.is_false(setOk) + assert.is_truthy(tostring(setErr):find("setButtonState: bad argument #1 type", 1, true)) + end) + end) + + describe("setButtonStyleSheet", function() + it("returns true for an existing button", function() + assert.is_true(setButtonStyleSheet(pushDownButton, "QPushButton { color: rgb(3,2,1); }")) + assert.is_true(setButtonStyleSheet(plainButton, "")) + end) + + it("styles a button that is not a push-down one too", function() + assert.is_true(setButtonStyleSheet(plainButton, "QPushButton { color: rgb(9,9,9); }")) + end) + + it("returns nil and a message naming a button that is not there", function() + local unknown = "buttonSpecNoSuchButton" .. suffix + local ok, err = setButtonStyleSheet(unknown, "") + assert.is_nil(ok) + assert.are.equal(("no button named '%s' found"):format(unknown), err) + end) + + it("hard-errors on a non-string name", function() + local ok, err = pcall(setButtonStyleSheet, {}, "") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setButtonStyleSheet: bad argument #1 type", 1, true)) + end) + + it("hard-errors on a non-string style sheet", function() + local ok, err = pcall(setButtonStyleSheet, pushDownButton, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setButtonStyleSheet: bad argument #2 type", 1, true)) + end) + + it("verifying what the button style sheet actually paints", function() + pending("there is no getButtonStyleSheet, and the effect is only visible in a screenshot") + end) + end) + + describe("showToolBar and hideToolBar", function() + -- both answer nothing at all, but they flip the active flag of the action + -- the toolbar was built from, which isActive() reads back. For a toolbar + -- that came out of a package that action is the package's own folder + -- rather than the toolbar, so the package's name is what they answer to + local function toolbarActive() + return isActive(packageName, "button") + end + + after_each(function() + showToolBar(packageName) + end) + + it("hideToolBar deactivates the toolbar and showToolBar activates it again", function() + assert.are.equal(1, toolbarActive()) + assert.are.equal(0, select("#", hideToolBar(packageName))) + assert.are.equal(0, toolbarActive()) + assert.are.equal(0, select("#", showToolBar(packageName))) + assert.are.equal(1, toolbarActive()) + end) + + it("hiding and showing repeatedly ends up where it started", function() + hideToolBar(packageName) + showToolBar(packageName) + hideToolBar(packageName) + showToolBar(packageName) + assert.are.equal(1, toolbarActive()) + assert.is_true(setButtonStyleSheet(pushDownButton, "")) + end) + + it("a name that is no toolbar is refused", function() + pending("both walk the toolbar list and do nothing at all when no name matches, so a typo is silent") + end) + + it("a packaged toolbar answering to its own name", function() + pending("regenerateEasyButtonBars builds a package's toolbars against the package's own action, so hideToolBar only answers to the package name and moves every toolbar in the package at once") + end) + + it("both hard-error on a non-string toolbar name", function() + local hideOk, hideErr = pcall(hideToolBar, {}) + assert.is_false(hideOk) + assert.is_truthy(tostring(hideErr):find("bad argument #1", 1, true)) + local showOk, showErr = pcall(showToolBar, {}) + assert.is_false(showOk) + assert.is_truthy(tostring(showErr):find("bad argument #1", 1, true)) + end) + + it("verifying that the toolbar is really on screen", function() + pending("toolbar visibility is not readable from Lua - needs a functional test") + end) + end) +end) + +end + +describe("Command line actions and suggestions", function() + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local cmdLine = "cmdActionLine" .. suffix + local unknown = "cmdActionNoSuchLine" .. suffix + + setup(function() + createCommandLine(cmdLine, 10, 10, 150, 30) + end) + + teardown(function() + deleteCommandLine(cmdLine) + end) + + describe("setCmdLineAction", function() + after_each(function() + resetCmdLineAction(cmdLine) + end) + + it("returns true for a command line that exists", function() + assert.is_true(setCmdLineAction(cmdLine, function() end)) + end) + + it("replacing an action returns true again", function() + assert.is_true(setCmdLineAction(cmdLine, function() end)) + assert.is_true(setCmdLineAction(cmdLine, function() end)) + end) + + it("returns nil and a message naming a command line that is not there", function() + local ok, err = setCmdLineAction(unknown, function() end) + assert.is_nil(ok) + assert.are.equal(("command line name '%s' not found"):format(unknown), err) + end) + + it("refuses the main command line, which takes no action", function() + -- only command lines made with createCommandLine can carry an action + local ok, err = setCmdLineAction("main", function() end) + assert.is_nil(ok) + assert.are.equal("command line name 'main' not found", err) + end) + + it("returns nil and a message for an empty command line name", function() + local ok, err = setCmdLineAction("", function() end) + assert.is_nil(ok) + assert.are.equal("command line name cannot be an empty string", err) + end) + + it("hard-errors on a non-string command line name", function() + local ok, err = pcall(setCmdLineAction, {}, function() end) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setCmdLineAction: bad argument #1 type", 1, true)) + end) + + it("takes the action as the name of a function to call, not only as a function", function() + -- the Lua wrapper compiles a string argument as "return <string>(...)", so + -- it has to name something callable rather than be a statement + assert.is_true(setCmdLineAction(cmdLine, "echo")) + end) + + it("hard-errors when the action is neither a function nor a string", function() + local ok, err = pcall(setCmdLineAction, cmdLine, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setCmdLineAction: bad argument #2 type (function expected, got table!)", 1, true)) + end) + + it("hard-errors when no action is given at all", function() + local ok, err = pcall(setCmdLineAction, cmdLine) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setCmdLineAction: bad argument #2 type (function expected, got nil!)", 1, true)) + end) + + it("the action actually running on a typed command", function() + pending("the callback only fires on a typed Enter - needs a functional test") + end) + end) + + describe("resetCmdLineAction", function() + it("returns true after an action was set", function() + assert.is_true(setCmdLineAction(cmdLine, function() end)) + assert.is_true(resetCmdLineAction(cmdLine)) + end) + + it("returns true even when no action was ever set", function() + assert.is_true(resetCmdLineAction(cmdLine)) + end) + + it("returns nil and a message naming a command line that is not there", function() + local ok, err = resetCmdLineAction(unknown) + assert.is_nil(ok) + assert.are.equal(("command line name '%s' not found"):format(unknown), err) + end) + + it("returns nil and a message for an empty command line name", function() + local ok, err = resetCmdLineAction("") + assert.is_nil(ok) + assert.are.equal("command line name cannot be an empty string", err) + end) + + it("hard-errors on a non-string command line name", function() + local ok, err = pcall(resetCmdLineAction, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("resetCmdLineAction: bad argument #1 type", 1, true)) + end) + end) + + describe("clearCmdLineSuggestions", function() + it("returns nothing at all for the main command line", function() + assert.are.equal(0, select("#", clearCmdLineSuggestions())) + end) + + it("returns nothing at all for a named command line", function() + addCmdLineSuggestion(cmdLine, "suggested") + assert.are.equal(0, select("#", clearCmdLineSuggestions(cmdLine))) + end) + + it("returns nil and a message naming a command line that is not there", function() + local ok, err = clearCmdLineSuggestions(unknown) + assert.is_nil(ok) + assert.are.equal(('command line "%s" not found'):format(unknown), err) + end) + + it("hard-errors on a non-string command line name", function() + local ok, err = pcall(clearCmdLineSuggestions, {}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("bad argument #1", 1, true)) + end) + + it("checking that the suggestion list is really empty", function() + pending("there is no getCmdLineSuggestions to read the list back with") + end) + end) +end) + +describe("setPopup", function() + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local console = "popupConsole" .. suffix + local unknown = "popupNoSuchWindow" .. suffix + + setup(function() + createMiniConsole(console, 0, 0, 400, 200) + end) + + teardown(function() + deleteMiniConsole(console) + end) + + before_each(function() + clearWindow(console) + echo(console, "popup me\n") + moveCursor(console, 0, 0) + selectString(console, "popup me", 1) + end) + + it("returns true for matching command and hint tables", function() + assert.is_true(setPopup(console, {"one", "two"}, {"first", "second"})) + end) + + it("accepts one extra hint for the popup's own title", function() + assert.is_true(setPopup(console, {"one", "two"}, {"title", "first", "second"})) + end) + + it("accepts functions in place of command strings", function() + assert.is_true(setPopup(console, {function() end, function() end}, {"first", "second"})) + end) + + it("returns nil and a message when there are too few hints", function() + local ok, err = setPopup(console, {"one", "two"}, {"only one"}) + assert.is_nil(ok) + assert.is_truthy(tostring(err):find("command table and hint table sizes do not match up", 1, true)) + end) + + it("returns nil and a message when there are too many hints", function() + local ok, err = setPopup(console, {"one"}, {"first", "second", "third"}) + assert.is_nil(ok) + assert.is_truthy(tostring(err):find("command table and hint table sizes do not match up", 1, true)) + end) + + it("hard-errors when the commands are not a table", function() + local ok, err = pcall(setPopup, console, "one", {"first"}) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setPopup: bad argument", 1, true)) + end) + + it("hard-errors when the hints are not a table", function() + local ok, err = pcall(setPopup, console, {"one"}, "first") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("setPopup: bad argument", 1, true)) + end) + + it("returns nil and a message naming a window that is not there", function() + local ok, err = setPopup(unknown, {"one"}, {"first"}) + assert.is_nil(ok) + assert.are.equal(('window "%s" not found'):format(unknown), err) + end) + + it("opening the popup menu and picking an entry", function() + pending("the menu only opens on a real right-click - needs a functional test") + end) +end) + +describe("Labels inside a user window", function() + -- user windows cannot be deleted from Lua, only hidden, so the name is + -- unique per run + local suffix = ("-%d-%d"):format(os.time(), math.random(100000)) + local userWindow = "labelUserWindow" .. suffix + local label = "labelInUserWindow" .. suffix + + setup(function() + -- loadLayout is off so a saved layout cannot move the window under us + openUserWindow(userWindow, false) + end) + + teardown(function() + hideWindow(userWindow) + end) + + before_each(function() + createLabel(userWindow, label, 5, 6, 120, 40, 1) + end) + + after_each(function() + deleteLabel(label) + end) + + it("the label really is inside the user window, not the main window", function() + -- createLabel falls back to the main window without a word when the parent + -- window name matches nothing, so a spec that only reads the label back + -- would pass either way; hiding the parent is what tells them apart + assert.is_true(windowVisible(label)) + hideWindow(userWindow) + assert.is_false(windowVisible(label)) + showWindow(userWindow) + assert.is_true(windowVisible(label)) + end) + + it("a parent window name that matches nothing is refused", function() + pending("createLabel puts the label in the main window and answers true when the parent window name is not a window") + end) + + it("echo puts text on a label that lives in a user window", function() + echo(label, "in the user window") + assert.is_truthy(getLabelText(label):find("in the user window", 1, true)) + end) + + it("resizeWindow and moveWindow work on it just as in the main window", function() + resizeWindow(label, 200, 60) + moveWindow(label, 15, 25) + assert.are.same({15, 25, 200, 60}, {getWindowGeometry(label)}) + end) + + it("hideWindow and showWindow work on it", function() + -- hideWindow answers nothing at all where showWindow answers a boolean + assert.are.equal(0, select("#", hideWindow(label))) + assert.is_false(windowVisible(label)) + assert.is_true(showWindow(label)) + assert.is_true(windowVisible(label)) + end) + + it("takes the fill background flag as a number as well as a boolean", function() + local numberFlag = "labelNumberFlag" .. suffix + local booleanFlag = "labelBooleanFlag" .. suffix + finally(function() + deleteLabel(numberFlag) + deleteLabel(booleanFlag) + end) + assert.is_true(createLabel(userWindow, numberFlag, 0, 0, 20, 10, 1)) + assert.is_true(createLabel(userWindow, booleanFlag, 0, 15, 20, 10, true)) + end) + + it("takes the optional clickthrough flag", function() + local clickthrough = "labelClickthrough" .. suffix + finally(function() deleteLabel(clickthrough) end) + assert.is_true(createLabel(userWindow, clickthrough, 0, 30, 20, 10, 1, 1)) + end) + + it("hard-errors on a non-boolean, non-number fill background flag", function() + local ok, err = pcall(createLabel, userWindow, "labelBadFill" .. suffix, 0, 0, 20, 10, "fill") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("createLabel: bad argument #7 type", 1, true)) + end) + + it("hard-errors on a non-boolean, non-number clickthrough flag", function() + local ok, err = pcall(createLabel, userWindow, "labelBadClick" .. suffix, 0, 0, 20, 10, 1, "through") + assert.is_false(ok) + assert.is_truthy(tostring(err):find("createLabel: bad argument #8 type", 1, true)) + end) + + it("hard-errors on a non-number label width", function() + local ok, err = pcall(createLabel, userWindow, "labelBadWidth" .. suffix, 0, 0, "wide", 10, 1) + assert.is_false(ok) + assert.is_truthy(tostring(err):find("createLabel: bad argument #5 type (label width", 1, true)) + end) +end) diff --git a/src/mudlet-lua/tests/fixtures/maps/minimal-map.xml b/src/mudlet-lua/tests/fixtures/maps/minimal-map.xml new file mode 100644 index 000000000..3d88301bc --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/maps/minimal-map.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Smallest map that reaches the Lua-observable parts of XMLimport::readMap(): + an area, two rooms with coordinates and a bidirectional exit, a special + exit in the IRE spelling (an exit with no direction but a command), a door, + a hidden exit (which arrives as a locked door), a room feature (which + arrives as room user data) and an environment colour. Used by Mapper_spec's + loadMap(".xml") specs. --> +<map> + <areas> + <area id="4001" name="Mapper Spec Import Area"/> + </areas> + <rooms> + <room id="4001" area="4001" title="Import Room One" environment="169"> + <coord x="0" y="0" z="0"/> + <exit direction="east" target="4002"/> + <exit special="1" command="enter gate" target="4002"/> + <features> + <feature type="shop"/> + </features> + </room> + <room id="4002" area="4001" title="Import Room Two" environment="170"> + <coord x="1" y="2" z="3"/> + <exit direction="west" target="4001" door="2"/> + <exit direction="north" target="4001" hidden="1"/> + </room> + </rooms> + <environments> + <environment id="169" color="8"/> + </environments> +</map> diff --git a/src/mudlet-lua/tests/fixtures/packages/README.md b/src/mudlet-lua/tests/fixtures/packages/README.md new file mode 100644 index 000000000..5a4b8f018 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/README.md @@ -0,0 +1,32 @@ +# Package fixtures + +Fixture packages and modules for `Package_spec.lua`. They are deliberately tiny +(the largest archive is about 1 KB) and every one of them is named +`mudlet-spec-*` so anything they leave behind is obviously test-owned. + +`sources/` holds the readable source of each fixture; the `.mpackage` files next +to this README are those directories zipped up. `.mpackage` files are zip +archives, so never edit one in place - change the source and rebuild: + +```sh +./build-fixtures.sh +``` + +The archives are committed instead of being zipped when the specs run because +busted runs on every platform Mudlet builds on and a `zip` tool is not there on +all of them. `build-fixtures.sh` forces the timestamps and passes `-X`, so +rebuilding unchanged sources with Info-ZIP reproduces the committed archives +byte for byte; another zip implementation may well write different bytes for the +same contents, which is harmless as long as the archives are only rebuilt +deliberately. + +| fixture | what it is for | +| --- | --- | +| `mudlet-spec-minimal` | valid package: `config.lua`, one alias, one script | +| `mudlet-spec-resources` | valid package that also ships a `resources/` folder with a nested subfolder | +| `mudlet-spec-module` | installed as a module; its script counts its own compiles so a reload is observable | +| `mudlet-spec-selfuninstall` | package whose event handler uninstalls its own package (regression #9557) | +| `mudlet-spec-noconfig` | archive with a package XML but no `config.lua`, so the name comes from the file name | +| `mudlet-spec-emptyarchive` | archive with neither `config.lua` nor a package XML | +| `mudlet-spec-notazip.mpackage` | not a zip archive at all, for the unpacking error path | +| `sources/mudlet-spec-xmlonly` | bare package XML, installed without any archive around it | diff --git a/src/mudlet-lua/tests/fixtures/packages/build-fixtures.sh b/src/mudlet-lua/tests/fixtures/packages/build-fixtures.sh new file mode 100755 index 000000000..5fba9717a --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/build-fixtures.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Rebuilds the .mpackage fixtures used by Package_spec.lua from the directories +# under sources/. Run it after editing any fixture source, then commit both the +# source and the rebuilt archive. +# +# The archives are committed rather than zipped at spec runtime so the specs do +# not depend on a zip tool being installed on every platform CI runs busted on. +# Each fixture is staged in a temporary folder where the timestamps are forced, +# and -X drops the platform-specific extra fields, so rebuilding from unchanged +# sources produces a byte-identical archive without disturbing the sources. +set -eu + +cd "$(dirname "$0")" +outputDirectory=$(pwd) + +command -v zip >/dev/null 2>&1 || { echo "zip is not installed" >&2; exit 1; } + +# Any fixed date does; this one is the day the fixture kit was added. +timestamp=202608040000.00 + +for source in sources/*/; do + name=$(basename "$source") + # mudlet-spec-xmlonly is installed straight from its .xml file, it has no archive + if [ "$name" = "mudlet-spec-xmlonly" ]; then + continue + fi + archive="$outputDirectory/$name.mpackage" + staging=$(mktemp -d) + cp -R "$source." "$staging" + find "$staging" -exec touch -t "$timestamp" {} + + rm -f "$archive" + (cd "$staging" && zip -q -r -X -9 "$archive" .) + rm -rf "$staging" + echo "built $name.mpackage" +done diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-emptyarchive.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-emptyarchive.mpackage new file mode 100644 index 000000000..8c781b708 Binary files /dev/null and b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-emptyarchive.mpackage differ diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-minimal.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-minimal.mpackage new file mode 100644 index 000000000..a493713c8 Binary files /dev/null and b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-minimal.mpackage differ diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-module.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-module.mpackage new file mode 100644 index 000000000..5b7b220f0 Binary files /dev/null and b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-module.mpackage differ diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-noconfig.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-noconfig.mpackage new file mode 100644 index 000000000..900af3d6c Binary files /dev/null and b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-noconfig.mpackage differ diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-notazip.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-notazip.mpackage new file mode 100644 index 000000000..0016f6554 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-notazip.mpackage @@ -0,0 +1,2 @@ +This file is deliberately not a zip archive, so that installPackage() has to +reject it. Keeping it as plain text also keeps the fixture reviewable. diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-resources.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-resources.mpackage new file mode 100644 index 000000000..8a39de534 Binary files /dev/null and b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-resources.mpackage differ diff --git a/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-selfuninstall.mpackage b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-selfuninstall.mpackage new file mode 100644 index 000000000..9ca2f1de1 Binary files /dev/null and b/src/mudlet-lua/tests/fixtures/packages/mudlet-spec-selfuninstall.mpackage differ diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-emptyarchive/readme.txt b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-emptyarchive/readme.txt new file mode 100644 index 000000000..5a72b6929 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-emptyarchive/readme.txt @@ -0,0 +1 @@ +This archive deliberately contains no config.lua and no package XML. diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-minimal/config.lua b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-minimal/config.lua new file mode 100644 index 000000000..758af6909 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-minimal/config.lua @@ -0,0 +1,5 @@ +mpackage = [[mudlet-spec-minimal]] +author = [[Mudlet test suite]] +title = [[Minimal fixture package for Package_spec.lua]] +version = [[1.0]] +description = [[One alias and one script, just enough to prove a package installed.]] diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-minimal/mudlet-spec-minimal.xml b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-minimal/mudlet-spec-minimal.xml new file mode 100644 index 000000000..64747854c --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-minimal/mudlet-spec-minimal.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>mudlet-spec-minimal alias</name> + <script>echo("mudlet-spec-minimal alias fired\n")</script> + <command></command> + <packageName></packageName> + <regex>^mudlet-spec-minimal$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage> + <Script isActive="yes" isFolder="no"> + <name>mudletSpecMinimalScript</name> + <packageName></packageName> + <script>mudletSpecMinimalRuns = (mudletSpecMinimalRuns or 0) + 1</script> + <eventHandlerList /> + </Script> + </ScriptPackage> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-module/config.lua b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-module/config.lua new file mode 100644 index 000000000..97432d624 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-module/config.lua @@ -0,0 +1,5 @@ +mpackage = [[mudlet-spec-module]] +author = [[Mudlet test suite]] +title = [[Module fixture for Package_spec.lua]] +version = [[3.1]] +description = [[Counts how often its script has been compiled, so a reload is observable.]] diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-module/mudlet-spec-module.xml b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-module/mudlet-spec-module.xml new file mode 100644 index 000000000..7d468ccdc --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-module/mudlet-spec-module.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>mudlet-spec-module alias</name> + <script>echo("mudlet-spec-module alias fired\n")</script> + <command></command> + <packageName></packageName> + <regex>^mudlet-spec-module$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage> + <Script isActive="yes" isFolder="no"> + <name>mudletSpecModuleScript</name> + <packageName></packageName> + <script>mudletSpecModuleRuns = (mudletSpecModuleRuns or 0) + 1</script> + <eventHandlerList /> + </Script> + </ScriptPackage> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-noconfig/mudlet-spec-noconfig.xml b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-noconfig/mudlet-spec-noconfig.xml new file mode 100644 index 000000000..5c86cfe70 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-noconfig/mudlet-spec-noconfig.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>mudlet-spec-noconfig alias</name> + <script>echo("mudlet-spec-noconfig alias fired\n")</script> + <command></command> + <packageName></packageName> + <regex>^mudlet-spec-noconfig$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage /> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/config.lua b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/config.lua new file mode 100644 index 000000000..697af8a8c --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/config.lua @@ -0,0 +1,5 @@ +mpackage = [[mudlet-spec-resources]] +author = [[Mudlet test suite]] +title = [[Fixture package carrying a resources folder]] +version = [[2.5]] +description = [[Ships non-Mudlet files so a spec can check they land on disk.]] diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/mudlet-spec-resources.xml b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/mudlet-spec-resources.xml new file mode 100644 index 000000000..cb626db04 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/mudlet-spec-resources.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>mudlet-spec-resources alias</name> + <script>echo("mudlet-spec-resources alias fired\n")</script> + <command></command> + <packageName></packageName> + <regex>^mudlet-spec-resources$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage /> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/resources/nested/spec-nested.txt b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/resources/nested/spec-nested.txt new file mode 100644 index 000000000..ab103dae8 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/resources/nested/spec-nested.txt @@ -0,0 +1 @@ +mudlet-spec-resources fixture resource in a nested folder diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/resources/spec-note.txt b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/resources/spec-note.txt new file mode 100644 index 000000000..de086e889 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-resources/resources/spec-note.txt @@ -0,0 +1 @@ +mudlet-spec-resources fixture resource diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-selfuninstall/config.lua b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-selfuninstall/config.lua new file mode 100644 index 000000000..0d0523406 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-selfuninstall/config.lua @@ -0,0 +1,5 @@ +mpackage = [[mudlet-spec-selfuninstall]] +author = [[Mudlet test suite]] +title = [[Fixture package that uninstalls itself from its own event handler]] +version = [[1.0]] +description = [[Regression fixture for the package self-uninstall crash (#9557).]] diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-selfuninstall/mudlet-spec-selfuninstall.xml b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-selfuninstall/mudlet-spec-selfuninstall.xml new file mode 100644 index 000000000..c05a5b280 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-selfuninstall/mudlet-spec-selfuninstall.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage /> + <ActionPackage /> + <ScriptPackage> + <Script isActive="yes" isFolder="no"> + <name>mudletSpecSelfUninstallHandler</name> + <packageName></packageName> + <script>function mudletSpecSelfUninstallHandler() + mudletSpecSelfUninstallRan = true + uninstallPackage("mudlet-spec-selfuninstall") +end</script> + <eventHandlerList> + <string>mudletSpecSelfUninstall</string> + </eventHandlerList> + </Script> + <Script isActive="yes" isFolder="no"> + <name>mudletSpecSelfUninstallSecondHandler</name> + <packageName></packageName> + <script>function mudletSpecSelfUninstallSecondHandler() + mudletSpecSelfUninstallSecondRan = true +end</script> + <eventHandlerList> + <string>mudletSpecSelfUninstall</string> + </eventHandlerList> + </Script> + </ScriptPackage> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-xmlonly/mudlet-spec-xmlonly.xml b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-xmlonly/mudlet-spec-xmlonly.xml new file mode 100644 index 000000000..2abefa354 --- /dev/null +++ b/src/mudlet-lua/tests/fixtures/packages/sources/mudlet-spec-xmlonly/mudlet-spec-xmlonly.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>mudlet-spec-xmlonly alias</name> + <script>echo("mudlet-spec-xmlonly alias fired\n")</script> + <command></command> + <packageName></packageName> + <regex>^mudlet-spec-xmlonly$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage /> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/mudlet.cpp b/src/mudlet.cpp index e90fd23c8..da61719dd 100644 --- a/src/mudlet.cpp +++ b/src/mudlet.cpp @@ -65,6 +65,7 @@ #include <QFileDialog> #include <QJsonDocument> #include <QImage> +#include <QKeyEvent> #include <QJsonObject> #include <QJsonValue> #include <QNetworkDiskCache> @@ -173,9 +174,13 @@ mudlet::mudlet() // Initialisation happens later in setupConfig() and init() } +static bool anyProfilesExist(const QString& profilesPath); + void mudlet::init() { - smFirstLaunch = !QFile::exists(mudlet::getMudletPath(enums::profilesPath)); + smFirstLaunch = !anyProfilesExist(mudlet::getMudletPath(enums::profilesPath)); + // Must be after setupConfig() created mpSettings and before anything of this run is written + rememberFirstLaunch(*mpSettings, mudlet::getMudletPath(enums::profilesPath), QDateTime::currentDateTime()); QFile gitShaFile(":/app-build.txt"); if (!gitShaFile.open(QIODevice::ReadOnly | QIODevice::Text)) { @@ -945,13 +950,22 @@ void mudlet::setupConfig() const auto resolution = utils::xdgConfigDir(confDirDefault); confPath = resolution.path; if (resolution.migrationPending) { - qInfo().nospace() << "mudlet::setupConfig() INFO: XDG_CONFIG_HOME is set but $XDG_CONFIG_HOME/mudlet is not a Mudlet config directory yet, so the existing " << confPath - << " is still in use. Move it to $XDG_CONFIG_HOME/mudlet to migrate."; + qInfo().nospace() << "mudlet::setupConfig() INFO: XDG_CONFIG_HOME is set but $XDG_CONFIG_HOME/mudlet holds no profiles, so the existing " << confPath + << " is still in use. Move its contents into $XDG_CONFIG_HOME/mudlet to migrate."; + } + if (!resolution.shadowedProfilesPath.isEmpty()) { + qWarning().nospace() << "mudlet::setupConfig() WARN: using $XDG_CONFIG_HOME/mudlet (" << confPath << ") because it holds profiles, but " << resolution.shadowedProfilesPath + << " holds profiles as well and they will not be listed. Unset XDG_CONFIG_HOME to use that directory instead."; } } qDebug() << "mudlet::setupConfig() INFO:" << "using config dir:" << confPath; - mpSettings = new QSettings(qsl("%1/Mudlet.ini").arg(confPath), QSettings::IniFormat); + // parented to the application, not this window: the window deletes itself + // on close and the Updater keeps using this QSettings past that point. + // Which is also why setupConfig() must not run again once init() has + // created the Updater - the delete below would dangle its pointer. + delete mpSettings; + mpSettings = new QSettings(qsl("%1/Mudlet.ini").arg(confPath), QSettings::IniFormat, qApp); migrateConfig(*mpSettings); } @@ -964,6 +978,16 @@ void mudlet::setupConfig() void mudlet::initEdbee() { + // edbee's init() has no re-entry guard - a second call reassigns all of its + // manager members and orphans the previous graph. Everything set up here is + // process-global, so one pass is enough however many mudlet instances a + // test constructs. + static bool initialised = false; + if (initialised) { + return; + } + initialised = true; + auto edbee = edbee::Edbee::instance(); edbee->init(); edbee->autoShutDownOnAppExit(); @@ -1691,6 +1715,10 @@ void mudlet::slot_closeProfileRequested(int tab) return; } + if (closeHeldOffByEventPump(pH)) { + return; + } + if (!pH->requestClose()) { return; } @@ -1707,6 +1735,18 @@ void mudlet::slot_closeProfileRequested(int tab) }); } +// Closing a profile destroys the lua_State the pump is still executing on. The +// application-wide close paths are deliberately not guarded like this: refusing +// there would cancel a shutdown nobody would retry. +bool mudlet::closeHeldOffByEventPump(Host* pHost) const +{ + if (!pHost->getLuaInterpreter()->pumpingEvents()) { + return false; + } + qWarning() << "mudlet: asked to close profile" << pHost->getName() << "while the test-mode event pump is running on it, ignoring"; + return true; +} + void mudlet::slot_closeProfileByName(const QString& profileName) { Host* pH = mHostManager.getHost(profileName); @@ -1714,6 +1754,10 @@ void mudlet::slot_closeProfileByName(const QString& profileName) return; } + if (closeHeldOffByEventPump(pH)) { + return; + } + if (!pH->requestClose()) { return; } @@ -2007,6 +2051,39 @@ void mudlet::closeHost(const QString& name) return; } + if (pH->mpMap && pH->mpMap->mapOperationInProgress()) { + // A map import, export or download is on the stack, and it is that + // operation's own qApp->processEvents() that has delivered whatever + // asked for this close. Destroying the Host here would free the TMap + // under its running loop (#9520), so tell the operation to stop and try + // again once the stack has unwound. Retried on a timer rather than + // immediately: the retry would otherwise land back in the same pump, + // spinning until the operation ends instead of letting it get there. + if (!pH->mpMap->mapOperationAbortRequested()) { + qDebug().nospace().noquote() << "mudlet::closeHost(\"" << name << "\") INFO - a map operation is still running, so the profile will be closed once it has stopped."; + } + pH->mpMap->requestMapOperationAbort(); + const QPointer<Host> pClosingHost(pH); + QTimer::singleShot(50ms, this, [this, name, pClosingHost]() { + if (mHostManager.getHost(name) != pClosingHost) { + // Somebody else closed it while we waited, and the name now + // belongs to a profile that was never asked to close. + return; + } + closeHost(name); + // The callers that defer to us run their own follow-up before this + // retry comes round, when the profile is still open and it does + // nothing. Left out, closing the last profile mid-operation ends + // with no profile and no connection dialog either. + updateMainWindowToolbarState(); + if (!mHostManager.getHostCount() && !mIsGoingDown) { + disableToolbarButtons(); + slot_showConnectionDialog(); + } + }); + return; + } + migrateDebugConsole(pH); // Clean up any main window dock widgets for this profile @@ -2115,6 +2192,56 @@ void mudlet::switchToProfileTab(int index) } } +// Whether this key press would activate one of the profile tab switching +// shortcuts. Comparing it to them literally is not enough - a shortcut can be +// spelt differently to the press that activates it: +bool mudlet::profileSwitchShortcutMatches(const QKeyEvent* ke) const +{ + if (!ke) { + return false; + } + + const auto key = static_cast<Qt::Key>(ke->key()); + const Qt::KeyboardModifiers modifiers = ke->modifiers(); + + // QShortcutMap retries with the modifiers the platform consumed producing + // the character stripped off, so Ctrl and a numpad digit activates Ctrl+1, + // and so does Ctrl+Shift+1 on layouts needing Shift for a top-row digit + // (French AZERTY) - the same reason handleCtrlTabChange() ignores Shift. + QList<QKeySequence> candidates; + const Qt::KeyboardModifiers strippable[] = {Qt::NoModifier, Qt::KeypadModifier, Qt::ShiftModifier, Qt::ShiftModifier | Qt::KeypadModifier}; + for (const auto stripped : strippable) { + const QKeySequence candidate(QKeyCombination(modifiers & ~stripped, key)); + if (!candidates.contains(candidate)) { + candidates.append(candidate); + } + } + + if (key == Qt::Key_Backtab) { + // Shift+Tab produces the Backtab keysym while the sequences are spelt + // with Key_Tab. Shift is normally still set here, but Qt's own Backtab + // handling does not rely on that, so put it back rather than assume: + candidates.append(QKeySequence(QKeyCombination(modifiers | Qt::ShiftModifier, Qt::Key_Tab))); + } + + auto shadows = [&candidates](const QKeySequence& sequence) { + // A shortcut cleared in the preferences is empty, and would match any candidate that was too + return !sequence.isEmpty() && candidates.contains(sequence); + }; + + if (shadows(mKeySequenceNextProfile) || shadows(mKeySequencePreviousProfile)) { + return true; + } + + for (const auto& sequence : mKeySequencesSwitchToProfile) { + if (shadows(sequence)) { + return true; + } + } + + return false; +} + // Moved as much as possible to activateProfile()... void mudlet::slot_tabChanged(int tabID) { @@ -2203,6 +2330,25 @@ void mudlet::addConsoleForNewHost(Host* pH) connect(&pH->mTelnet, &cTelnet::signal_packageDownloadProgress, pConsole, &TMainConsole::updatePackageDownloadProgress, Qt::UniqueConnection); connect(&pH->mTelnet, &cTelnet::signal_packageDownloadFinished, pConsole, &TMainConsole::closePackageDownloadProgress, Qt::UniqueConnection); + connect(pH, &Host::signal_showMapperScriptReminder, pConsole, &TMainConsole::showMapperScriptReminder, Qt::UniqueConnection); + connect(pH, &Host::signal_showUnpackingProgress, pConsole, &TMainConsole::showUnpackingProgress, Qt::UniqueConnection); + connect(pH, &Host::signal_hideUnpackingProgress, pConsole, &TMainConsole::closeUnpackingProgress, Qt::UniqueConnection); + + // Wire the map engine's progress signals to the console that owns the dialog. + // Must be connected before the profile's map is loaded (further down in + // slot_connectionDialogueFinished()), or early map operations have no + // frontend to show progress. + if (!pH->mpMap.isNull()) { + auto pMap = pH->mpMap.data(); + connect(pMap, &TMap::signal_mapTransferProgressStart, pConsole, &TMainConsole::showMapTransferProgress, Qt::UniqueConnection); + connect(pMap, &TMap::signal_mapJsonProgressStart, pConsole, &TMainConsole::showMapJsonProgress, Qt::UniqueConnection); + connect(pMap, &TMap::signal_mapProgressSetLabel, pConsole, &TMainConsole::setMapProgressDialogLabel, Qt::UniqueConnection); + connect(pMap, &TMap::signal_mapProgressSetRange, pConsole, &TMainConsole::setMapProgressDialogRange, Qt::UniqueConnection); + connect(pMap, &TMap::signal_mapProgressSetValue, pConsole, &TMainConsole::setMapProgressDialogValue, Qt::UniqueConnection); + connect(pMap, &TMap::signal_mapProgressDisableCancel, pConsole, &TMainConsole::disableMapProgressDialogCancel, Qt::UniqueConnection); + connect(pMap, &TMap::signal_mapProgressClose, pConsole, &TMainConsole::closeMapProgressDialog, Qt::UniqueConnection); + } + if (pH->mpMedia) { // Pin DirectConnection so the bool& out-parameter is filled synchronously, never queued. connect(pH->mpMedia.data(), &TMedia::signal_setupVideoOutput, pConsole, &TMainConsole::setupVideoOutput, static_cast<Qt::ConnectionType>(Qt::DirectConnection | Qt::UniqueConnection)); @@ -2400,6 +2546,14 @@ void mudlet::slot_timerFires() pTT->start(); } + // Flush any deletes TimerUnit::uninstall() deferred whilst execute() was + // on the stack (a timer script uninstalling its own package). Doing it + // here - after the last use of pTT - keeps the window in which the + // "uninstalled" timers linger down to this event loop iteration, before + // the profile save that Host::uninstallPackage() queues for the next + // event loop pass can serialize them back into the profile: + pHost->getTimerUnit()->doCleanup(); + // Okay now we've found it we are done: return; } @@ -3346,6 +3500,13 @@ void mudlet::slot_showConnectionDialog() // Use a timer to ensure the main window is ready before showing the dialog // This is especially important at startup when the main window might not be fully initialized QTimer::singleShot(0ms, this, [this]() { + // closeEvent() closes this WA_DeleteOnClose dialog and clears the + // QPointer, so quitting before this runs leaves nothing to show - and + // show() below would undo closeEvent()'s hide() of the main window + if (!mpConnectionDialog) { + return; + } + // Ensure the main window is visible and ready if (!isVisible()) { show(); @@ -4210,8 +4371,8 @@ void mudlet::slot_showMapperDialog() mpCurrentMapDockWidget = nullptr; // Restore the host's default mapper if it exists - if (pHost->mpDockableMapWidget) { - auto hostMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto hostMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto hostMapper = qobject_cast<dlgMapper*>(hostMapWidget)) { pMap->mpMapper = hostMapper; @@ -4223,8 +4384,8 @@ void mudlet::slot_showMapperDialog() } // If the host already has its default dock widget, hide it to avoid conflicts - if (pHost->mpDockableMapWidget) { - pHost->mpDockableMapWidget->setVisible(false); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + pHost->mpConsole->mpDockableMapWidget->setVisible(false); } // Create a new docked mapper widget for this profile in the main window @@ -4315,8 +4476,8 @@ void mudlet::slot_showMapperDialog() } // Restore the host's default mapper when hiding - if (pHost->mpDockableMapWidget) { - auto hostMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto hostMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto hostMapper = qobject_cast<dlgMapper*>(hostMapWidget)) { pMap->mpMapper = hostMapper; @@ -6078,11 +6239,23 @@ Host* mudlet::loadProfile(const QString& profile_name, const bool playOnline, co const QString folder = getMudletPath(enums::profileXmlFilesPath, profile_name); QDir dir(folder); dir.setSorting(QDir::Time); - QStringList entries = dir.entryList(QDir::Files, QDir::Time); + // Only consider profile saves (*.xml): a crash during a save can leave behind + // an empty QSaveFile temporary (e.g. "2026-01-01#12-00-00.xml.AbCdEf") as the + // newest file, and loading that instead of the newest real save presents the + // profile with all of its triggers/scripts seemingly wiped out + QStringList entries = dir.entryList(QStringList{qsl("*.xml")}, QDir::Files, QDir::Time); // pre-install packages when loading this profile for the first time bool preInstallPackages = false; pHost->hideMudletsVariables(); - if (entries.isEmpty()) { + // NB: an explicitly requested saveFileName is honored even when no *.xml + // is present - failing to open it then reports a proper load error rather + // than silently starting a fresh profile: + if (entries.isEmpty() && saveFileName.isEmpty()) { + if (!dir.entryList(QDir::Files | QDir::NoDotAndDotDot).isEmpty()) { + qWarning().nospace().noquote() << "mudlet::loadProfile(" << profile_name << ", ...) WARNING - profile directory \"" << folder + << "\" contains files but no completed (*.xml) save; treating the profile as new. An interrupted save may have left " + "a recoverable QSaveFile temporary behind."; + } preInstallPackages = true; pHost->mLoadedOk = true; pHost->mMapInfoContributors.insert(qsl("Short")); @@ -7330,30 +7503,36 @@ void mudlet::refreshTabBar() // doesn't make sense to make it static since it modifies a class variable void mudlet::setupPreInstallPackages(const QString& gameUrl, const QString& profileName) { + if (mSkipDefaultPackageInstall) { + return; + } + const QHash<QString, QStringList> defaultScripts = { // clang-format off // scripts to pre-install for a profile games this applies to, * means all games - {qsl(":/run-lua-code.mpackage"), {qsl("*")}}, - {qsl(":/echo.mpackage"), {qsl("*")}}, - {qsl(":/deleteOldProfiles.mpackage"), {qsl("*")}}, - {qsl(":/enable-accessibility.mpackage"), {qsl("*")}}, - {qsl(":/mpkg.mpackage"), {qsl("*")}}, - {qsl(":/mudlet-lua/lua/gui-drop/gui-drop.mpackage"), {qsl("*")}}, - {qsl(":/CF-loader.xml"), {qsl("carrionfields.net")}}, - {qsl(":/icesus-loader.xml"), {qsl("icesus.org")}}, - {qsl(":/mg-loader.xml"), {qsl("mg.mud.de"), - qsl("mud.morgengrauen.info"), - qsl("mg.morgengrauen.info"), - qsl("morgengrauen.info")}}, - {qsl(":/run-tests.xml"), {qsl("mudlet.org")}}, - {qsl(":/mudlet-lua/lua/stressinator/StressinatorDisplayBench.xml"), {qsl("mudlet.org")}}, - {qsl(":/mudlet-mapper.xml"), {qsl("aetolia.com"), - qsl("achaea.com"), - qsl("lusternia.com"), - qsl("imperian.com"), - qsl("starmourn.com"), - qsl("stickmud.com")}}, - {qsl(":/MedBootstrap.xml"), {qsl("medievia.com")}} + {qsl(":/packages/run-lua-code/run-lua-code.mpackage"), {qsl("*")}}, + {qsl(":/packages/echo/echo.mpackage"), {qsl("*")}}, + {qsl(":/packages/deleteOldProfiles/deleteOldProfiles.mpackage"), {qsl("*")}}, + {qsl(":/packages/enable-accessibility/enable-accessibility.mpackage"), {qsl("*")}}, + {qsl(":/packages/mpkg/mpkg.mpackage"), {qsl("*")}}, + {qsl(":/packages/gui-drop/gui-drop.mpackage"), {qsl("*")}}, + {qsl(":/packages/CF-loader/CF-loader.mpackage"), {qsl("carrionfields.net")}}, + {qsl(":/packages/icesus-loader/icesus-loader.mpackage"), {qsl("icesus.org")}}, + {qsl(":/packages/mg-loader/mg-loader.mpackage"), {qsl("mg.mud.de"), + qsl("mud.morgengrauen.info"), + qsl("mg.morgengrauen.info"), + qsl("morgengrauen.info")}}, + {qsl(":/packages/run-tests/run-tests.mpackage"), {qsl("mudlet.org")}}, + {qsl(":/packages/StressinatorDisplayBench/StressinatorDisplayBench.mpackage"), {qsl("mudlet.org")}}, + // the IRE mapper is maintained upstream and published as an xml, so it + // is the one preinstall that is not packaged - see update-3rdparty.yml + {qsl(":/mudlet-mapper.xml"), {qsl("aetolia.com"), + qsl("achaea.com"), + qsl("lusternia.com"), + qsl("imperian.com"), + qsl("starmourn.com"), + qsl("stickmud.com")}}, + {qsl(":/packages/MedBootstrap/MedBootstrap.mpackage"), {qsl("medievia.com")}} // clang-format on }; @@ -7366,7 +7545,7 @@ void mudlet::setupPreInstallPackages(const QString& gameUrl, const QString& prof } if (!mudlet::self()->mPackagesToInstallList.contains(qsl(":/mudlet-mapper.xml"))) { - mudlet::self()->mPackagesToInstallList.append(qsl(":/mudlet-lua/lua/generic-mapper/generic_mapper.mpackage")); + mudlet::self()->mPackagesToInstallList.append(qsl(":/packages/generic_mapper/generic_mapper.mpackage")); } // A modest starter UI that adapts to whatever any game provides, only for @@ -7377,12 +7556,12 @@ void mudlet::setupPreInstallPackages(const QString& gameUrl, const QString& prof // connect time are handled at runtime instead - the starter UI stands // aside when one installs. if (!mudlet::self()->experiencedMudletPlayer() && !TGameDetails::gameProvidesOwnUi(gameUrl)) { - mudlet::self()->mPackagesToInstallList.append(qsl(":/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage")); + mudlet::self()->mPackagesToInstallList.append(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage")); } // Don't play tutorial for every connection to localhost. There are legit other reasons to connect there. if (profileName == qsl("Mudlet Tutorial") && gameUrl == qsl("localhost")) { - mudlet::self()->mPackagesToInstallList.append(qsl(":/mudlet-tutorial.mpackage")); + mudlet::self()->mPackagesToInstallList.append(qsl(":/packages/mudlet-tutorial/mudlet-tutorial.mpackage")); } } @@ -7470,6 +7649,18 @@ void mudlet::onlyShowProfiles(const QStringList& predefinedProfiles) void mudlet::armForceClose() { QTimer::singleShot(0ms, this, [this]() { + // Deferring by one event loop iteration is meant to land outside Lua, + // but the pump runs the event loop from inside Lua, so it can land + // right back in it. Retrying terminates: the pump is capped at 30s. + for (auto pHost : mHostManager) { + if (pHost->getLuaInterpreter()->pumpingEvents()) { + qWarning() << "mudlet::armForceClose() - the test-mode event pump is running, waiting for it to finish"; + QTimer::singleShot(50ms, this, [this]() { + armForceClose(); + }); + return; + } + } forceClose(); }); } @@ -7504,8 +7695,67 @@ void mudlet::showedCharacterModeWarning() mCharacterModeWarningsShown = std::min(mCharacterModeWarningsShown + 1, mCharacterModeWarningsMax); } -// returns true if the Mudlet player is considered 'experienced' and doesn't need to be shown the basic -// tutorial tips, such as splitscreen cancel shortcut +static const QLatin1String settingsKeyFirstLaunch("firstLaunchDate"); +static constexpr int experiencedPlayerMonths = 6; + +static bool anyProfilesExist(const QString& profilesPath) +{ + const QDir profiles(profilesPath); + if (!profiles.exists()) { + return false; + } + if (!QFileInfo(profilesPath).isReadable()) { + // Unlistable reads as empty, which would stamp an existing user with today as their first launch + qWarning() << "anyProfilesExist() WARNING - the profiles directory exists but cannot be read:" << profilesPath + << "- assuming it holds profiles, so an existing user is not mistaken for a new one."; + return true; + } + return !profiles.entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty(); +} + +// Settings count as well as profiles: someone who kept their Mudlet.ini but not +// their profiles is still not on their first run. +static bool mudletUsedBefore(const QSettings& settings, const QString& profilesPath) +{ + return anyProfilesExist(profilesPath) || !settings.allKeys().isEmpty(); +} + +// Called only from init(), before anything of this run has been written. Where +// there is a trace of earlier use the start date is unrecoverable - no timestamp +// survives Mudlet's own writes, nor a copy to another machine - so nothing is +// recorded and evaluateExperiencedPlayer() falls back. +/*static*/ void mudlet::rememberFirstLaunch(QSettings& settings, const QString& profilesPath, const QDateTime& now) +{ + // Not conditioned on the value parsing: re-recording would restart the clock today + if (settings.contains(settingsKeyFirstLaunch) || mudletUsedBefore(settings, profilesPath)) { + return; + } + + settings.setValue(settingsKeyFirstLaunch, now.toUTC().toString(Qt::ISODate)); + settings.sync(); + if (settings.status() != QSettings::NoError) { + qWarning() << "mudlet::rememberFirstLaunch() WARNING - could not record the first launch date in" << settings.fileName() << "- QSettings status:" << settings.status() + << "- this installation will later be taken for an experienced user's."; + } +} + +/*static*/ bool mudlet::evaluateExperiencedPlayer(const QSettings& settings, const QString& profilesPath, const QDateTime& now) +{ + const QString recorded = settings.value(settingsKeyFirstLaunch).toString(); + const QDateTime firstLaunch = QDateTime::fromString(recorded, Qt::ISODate); + if (firstLaunch.isValid()) { + return firstLaunch <= now.addMonths(-experiencedPlayerMonths); + } + if (!recorded.isEmpty()) { + qWarning().nospace().noquote() << "evaluateExperiencedPlayer() WARNING - \"" << settingsKeyFirstLaunch << "\" holds \"" << recorded + << "\", which is not ISO 8601 - falling back to looking for signs of earlier use."; + } + + // Erring towards 'experienced' is deliberate: interrupting a veteran with a + // beginner tour is worse than a newcomer missing one. + return mudletUsedBefore(settings, profilesPath); +} + bool mudlet::experiencedMudletPlayer() { static std::optional<bool> cachedResult; @@ -7513,19 +7763,15 @@ bool mudlet::experiencedMudletPlayer() return cachedResult.value(); } - // crude metric to check if the player is experienced in Mudlet: see if any of the profiles is more than 6mo old - QDir profilesDir(mudlet::getMudletPath(enums::profilesPath)); - QFileInfoList entries = profilesDir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); - QDateTime sixMonthsAgo = QDateTime::currentDateTime().addMonths(-6); - - for (const QFileInfo& entry : std::as_const(entries)) { - if (entry.lastModified() < sixMonthsAgo) { - cachedResult = true; - return true; - } + const auto* settings = getQSettings(); + if (!settings) { + // Not cached: a guess, and caching it would pin every gate for the process + qWarning() << "mudlet::experiencedMudletPlayer() WARNING - called before setupConfig(), so assuming an experienced player and showing no first-run guidance."; + return true; } - cachedResult = false; - return false; + + cachedResult = evaluateExperiencedPlayer(*settings, getMudletPath(enums::profilesPath), QDateTime::currentDateTime()); + return cachedResult.value(); } dlgTriggerEditor* mudlet::createMudletEditor() @@ -7642,20 +7888,37 @@ void mudlet::slot_detachedWindowClosed(const QString& profileName) updateMainWindowTitle(); // Properly close the host to avoid dangling connections - Host* pHost = mHostManager.getHost(profileName); - if (pHost) { - if (pHost->requestClose()) { - QTimer::singleShot(0ms, this, [this, profileName] { - closeHost(profileName); - // Check to see if there are any profiles left... - if (!mHostManager.getHostCount() && !mIsGoingDown) { - disableToolbarButtons(); - slot_showConnectionDialog(); - setWindowTitle(scmVersion); - } - }); + closeHostOfClosedDetachedWindow(profileName); + } +} + +// Unlike the tab-close slots, the window and its bookkeeping are already gone by +// the time we get here, so dropping the close while the pump runs would leave +// the profile loaded with no way to reach it. Wait the pump out instead. +void mudlet::closeHostOfClosedDetachedWindow(const QString& profileName) +{ + Host* pHost = mHostManager.getHost(profileName); + if (!pHost) { + return; + } + + if (closeHeldOffByEventPump(pHost)) { + QTimer::singleShot(50ms, this, [this, profileName]() { + closeHostOfClosedDetachedWindow(profileName); + }); + return; + } + + if (pHost->requestClose()) { + QTimer::singleShot(0ms, this, [this, profileName] { + closeHost(profileName); + // Check to see if there are any profiles left... + if (!mHostManager.getHostCount() && !mIsGoingDown) { + disableToolbarButtons(); + slot_showConnectionDialog(); + setWindowTitle(scmVersion); } - } + }); } } @@ -8686,8 +8949,8 @@ void mudlet::updateMainWindowDockWidgetVisibilityForProfile(const QString& profi // Restore host's default mapper for the other profile if (auto pHost = mHostManager.getHost(dockProfileName)) { if (auto pMap = pHost->mpMap.data()) { - if (pHost->mpDockableMapWidget) { - auto hostMapWidget = pHost->mpDockableMapWidget->widget(); + if (pHost->mpConsole && pHost->mpConsole->mpDockableMapWidget) { + auto hostMapWidget = pHost->mpConsole->mpDockableMapWidget->widget(); if (auto hostMapper = qobject_cast<dlgMapper*>(hostMapWidget)) { pMap->mpMapper = hostMapper; diff --git a/src/mudlet.h b/src/mudlet.h index f40826bd2..68458ed4b 100644 --- a/src/mudlet.h +++ b/src/mudlet.h @@ -60,7 +60,9 @@ class QAction; class QCloseEvent; +class QDateTime; class QDir; +class QKeyEvent; class QMediaDevices; class QMediaPlayer; class QMenu; @@ -200,6 +202,7 @@ public: void setupConfig(); void activateProfile(Host*); void switchToProfileTab(int index); + bool profileSwitchShortcutMatches(const QKeyEvent*) const; void takeOwnershipOfInstanceCoordinator(std::unique_ptr<MudletInstanceCoordinator>); MudletInstanceCoordinator* getInstanceCoordinator(); void addConsoleForNewHost(Host*); @@ -238,6 +241,7 @@ public: // operating without either menubar or main toolbar showing. bool isControlsVisible() const; bool isGoingDown() { return mIsGoingDown; } + bool closeHeldOffByEventPump(Host*) const; Host* loadProfile(const QString&, const bool, const QString& saveFileName = QString()); bool loadReplay(Host*, const QString&, QString* pErrMsg = nullptr); bool loadWindowLayout(); @@ -327,7 +331,12 @@ public: void showedMuteAllMediaTutorial(); bool showCharacterModeWarning(); void showedCharacterModeWarning(); + // True if the player has used Mudlet long enough not to need the tutorial + // tips, the interface tour or the starter UI. Memoised. bool experiencedMudletPlayer(); + // The two below are public only so they can be tested + static void rememberFirstLaunch(QSettings& settings, const QString& profilesPath, const QDateTime& now); + static bool evaluateExperiencedPlayer(const QSettings& settings, const QString& profilesPath, const QDateTime& now); // Telnet URI handling void handleTelnetUri(const QString& uri); @@ -360,6 +369,9 @@ public: QStringList mOnlyShownPredefinedProfiles; QPointer<dlgAboutDialog> mpAboutDlg; QStringList mPackagesToInstallList; + // Test-only: PipelineBenchmark sets this so its profile measures the + // pipeline rather than the shipped default packages. + bool mSkipDefaultPackageInstall = false; QPointer<dlgConnectionProfiles> mpConnectionDialog; QPointer<Host> mpCurrentActiveHost; // Options dialog when there's no active host @@ -779,6 +791,7 @@ private: QPointer<QDockWidget> mpCurrentMapDockWidget; // Helper methods for detached windows + void closeHostOfClosedDetachedWindow(const QString& profileName); void detachTab(int tabIndex, const QPoint& position); void reattachTab(const QString& profileName, int insertIndex = -1); TMainConsole* removeConsoleFromSplitter(const QString& profileName); diff --git a/src/mudlet.qrc b/src/mudlet.qrc index c6af22231..dba4841aa 100644 --- a/src/mudlet.qrc +++ b/src/mudlet.qrc @@ -3,11 +3,11 @@ <file alias="materiaMagicaIcon">icons/logo_mm-120x30px-verticalBlackBgd.png</file> <file alias="translation-stats.json">../translations/translated/translation-stats.json</file> <file>app-build.txt</file> - <file>CF-loader.xml</file> - <file>deleteOldProfiles.mpackage</file> - <file>echo.mpackage</file> - <file>mpkg.mpackage</file> - <file>MedBootstrap.xml</file> + <file>packages/CF-loader/CF-loader.mpackage</file> + <file>packages/deleteOldProfiles/deleteOldProfiles.mpackage</file> + <file>packages/echo/echo.mpackage</file> + <file>packages/mpkg/mpkg.mpackage</file> + <file>packages/MedBootstrap/MedBootstrap.mpackage</file> <file>edbee_defaults/Lua.tmLanguage</file> <file>edbee_defaults/Mudlet.tmTheme</file> <file>icons/120x30RoDLogo.png</file> @@ -220,22 +220,22 @@ <file>icons/window-close.png</file> <file>icons/wotmudicon.png</file> <file>icons/zombiemud.png</file> - <file>icesus-loader.xml</file> + <file>packages/icesus-loader/icesus-loader.mpackage</file> <file>lua-function-list.json</file> - <file>mg-loader.xml</file> - <file>mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage</file> - <file>mudlet-lua/lua/generic-mapper/generic_mapper.mpackage</file> - <file>mudlet-lua/lua/gui-drop/gui-drop.mpackage</file> - <file>enable-accessibility.mpackage</file> - <file>mudlet-lua/lua/stressinator/StressinatorDisplayBench.xml</file> + <file>packages/mg-loader/mg-loader.mpackage</file> + <file>packages/mudlet-base-ui/mudlet-base-ui.mpackage</file> + <file>packages/generic_mapper/generic_mapper.mpackage</file> + <file>packages/gui-drop/gui-drop.mpackage</file> + <file>packages/enable-accessibility/enable-accessibility.mpackage</file> + <file>packages/StressinatorDisplayBench/StressinatorDisplayBench.mpackage</file> <file>mudlet-lua/lua/utf8_filenames.lua</file> <file>mudlet-mapper.xml</file> <file>splash/Mudlet_splashscreen_main.png</file> - <file>run-lua-code.mpackage</file> - <file>run-tests.xml</file> + <file>packages/run-lua-code/run-lua-code.mpackage</file> + <file>packages/run-tests/run-tests.mpackage</file> <file>shaders/vertex.glsl</file> <file>shaders/fragment.glsl</file> - <file>mudlet-tutorial.mpackage</file> + <file>packages/mudlet-tutorial/mudlet-tutorial.mpackage</file> <file>ui/custom_lines.ui</file> <file>ui/custom_lines_properties.ui</file> <file>ui/delete_profile_confirmation.ui</file> diff --git a/src/packages/CF-loader/CF-loader.mpackage b/src/packages/CF-loader/CF-loader.mpackage new file mode 100644 index 000000000..8ed1a5f90 Binary files /dev/null and b/src/packages/CF-loader/CF-loader.mpackage differ diff --git a/src/CF-loader.xml b/src/packages/CF-loader/CF-loader.xml similarity index 100% rename from src/CF-loader.xml rename to src/packages/CF-loader/CF-loader.xml diff --git a/src/packages/CF-loader/config.lua b/src/packages/CF-loader/config.lua new file mode 100644 index 000000000..9b0666a26 --- /dev/null +++ b/src/packages/CF-loader/config.lua @@ -0,0 +1,16 @@ +mpackage = [[CF_Loader]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Downloads the Carrion Fields interface when you first connect.]] +description = [[### Description + +Preinstalled on new Carrion Fields profiles. On your first connection it downloads +CFGUI, the interface maintained by the Carrion Fields team, installs it and removes +itself. + +### See Also + +* [CFGUI on GitHub](https://github.com/carrionfields/CFGUI) +]] +version = [[1]] +created = "2026-08-04T00:00:00+00:00" diff --git a/src/packages/MedBootstrap/MedBootstrap.mpackage b/src/packages/MedBootstrap/MedBootstrap.mpackage new file mode 100644 index 000000000..a962ff698 Binary files /dev/null and b/src/packages/MedBootstrap/MedBootstrap.mpackage differ diff --git a/src/MedBootstrap.xml b/src/packages/MedBootstrap/MedBootstrap.xml similarity index 100% rename from src/MedBootstrap.xml rename to src/packages/MedBootstrap/MedBootstrap.xml diff --git a/src/packages/MedBootstrap/config.lua b/src/packages/MedBootstrap/config.lua new file mode 100644 index 000000000..86f0951fa --- /dev/null +++ b/src/packages/MedBootstrap/config.lua @@ -0,0 +1,16 @@ +mpackage = [[MedBootstrap]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Installs and updates the Medievia interface.]] +description = [[### Description + +Preinstalled on new Medievia profiles. It installs mpkg if needed, then installs +MedUI - the interface maintained by the Medievia team - and keeps it up to date on +later connections. + +### See Also + +* [MedUI in the package repository](https://packages.mudlet.org/packages#pkg-MedUI) +]] +version = [[1]] +created = "2026-08-04T00:00:00+00:00" diff --git a/src/packages/README.md b/src/packages/README.md new file mode 100644 index 000000000..052338b7f --- /dev/null +++ b/src/packages/README.md @@ -0,0 +1,35 @@ +# Default packages + +Packages Mudlet preinstalls into new profiles. Each one gets a directory holding +its `.mpackage` archive plus the sources that archive is built from: + +``` +src/packages/echo/ + config.lua package metadata: name, author, version, description, icon + echo.xml the triggers, aliases and scripts, as Mudlet exports them + echo.mpackage what actually ships - a zip of the two files above and the icon +``` + +The archive is what Mudlet installs, so **editing a source file does nothing +until the archive is rebuilt**: + +```bash +cd src/packages/echo +zip echo.mpackage config.lua echo.xml +``` + +That updates the members in place and leaves the icon under `.mudlet/Icon/` +alone. `CI/check-mpackage-sync.lua` fails the build if the two ever disagree. + +Bump `version` in `config.lua` whenever a package changes. Several of these are +published to the [package repository](https://github.com/Mudlet/mudlet-package-repository), +which syncs them weekly and only offers players an update when the version goes +up - the same check enforces this. + +Two packages here are maintained elsewhere and synced in by +`.github/workflows/update-3rdparty.yml`: `mpkg` (from the package repository) +and the IRE mapper, which upstream publishes as a bare `src/mudlet-mapper.xml` +rather than a package. + +Which games get which package is decided in `mudlet::setupPreInstallPackages()`, +and every archive needs an entry in `src/mudlet.qrc` to be compiled into Mudlet. diff --git a/src/packages/StressinatorDisplayBench/StressinatorDisplayBench.mpackage b/src/packages/StressinatorDisplayBench/StressinatorDisplayBench.mpackage new file mode 100644 index 000000000..52009d174 Binary files /dev/null and b/src/packages/StressinatorDisplayBench/StressinatorDisplayBench.mpackage differ diff --git a/src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.xml b/src/packages/StressinatorDisplayBench/StressinatorDisplayBench.xml similarity index 100% rename from src/mudlet-lua/lua/stressinator/StressinatorDisplayBench.xml rename to src/packages/StressinatorDisplayBench/StressinatorDisplayBench.xml diff --git a/src/packages/StressinatorDisplayBench/config.lua b/src/packages/StressinatorDisplayBench/config.lua new file mode 100644 index 000000000..a71454c1c --- /dev/null +++ b/src/packages/StressinatorDisplayBench/config.lua @@ -0,0 +1,9 @@ +mpackage = [[StressinatorDisplayBench]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[A benchmark test for triggers in Mudlet.]] +description = [[### Description + +A benchmark test for triggers in Mudlet by reading text from The Count of Monte Cristo, by Alexandre Dumas.]] +version = [[1]] +created = "2025-01-19T13:53:46+04:00" diff --git a/src/packages/deleteOldProfiles/config.lua b/src/packages/deleteOldProfiles/config.lua new file mode 100644 index 000000000..31b626c22 --- /dev/null +++ b/src/packages/deleteOldProfiles/config.lua @@ -0,0 +1,34 @@ +mpackage = [[deleteOldProfiles]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Remove excess backup files.]] +description = [[# deleteOldProfiles Package + +Mudlet continuiously creates backups of important data. This can result in a lot +of files. This package deletes old profiles, maps and modules in the +"current", "map" and "moduleBackups" folders of the Mudlet home directory that are +no longer required. + +The commands are; + +``` +> delete old profiles [days] +> delete old maps [days] +> delete old modules[days] +``` + +Days is optional, the default is 31 days. + +The following files are NOT deleted: + +- Files newer than the amount of days specified, or 31 days if not specified. +- One file for every month before that. Specifically: The first available file of every month prior to this. + +``` +-- Examples: +> delete old profiles -- deletes profiles older than 31 days +> delete old maps 10 -- deletes maps older than 10 days +``` +]] +version = [[1]] +created = "2024-08-24T08:26:45+02:00" diff --git a/src/deleteOldProfiles.mpackage b/src/packages/deleteOldProfiles/deleteOldProfiles.mpackage similarity index 100% rename from src/deleteOldProfiles.mpackage rename to src/packages/deleteOldProfiles/deleteOldProfiles.mpackage diff --git a/src/packages/deleteOldProfiles/deleteOldProfiles.xml b/src/packages/deleteOldProfiles/deleteOldProfiles.xml new file mode 100644 index 000000000..0ee21a7e7 --- /dev/null +++ b/src/packages/deleteOldProfiles/deleteOldProfiles.xml @@ -0,0 +1,100 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>delete old profiles</name> + <script>deleteOldProfiles(matches[3], matches[2]) + +--Syntax examples: "delete old profiles" -> deletes profiles older than 31 days +-- "delete old maps 10" -> deletes maps older than 10 days</script> + <command></command> + <packageName></packageName> + <regex>^delete old (profiles|maps|modules)(?: (\d+))?$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage> + <Script isActive="yes" isFolder="no"> + <name>deleteOldProfiles script</name> + <packageName></packageName> + <script>function deleteOldProfiles(keepdays_arg, delete_folder) + --[[ + Deletes old profiles/maps/modules in the "current"/"map"/"moduleBackups" folders of the Mudlet home directory. + The following files are NOT deleted: + - Files newer than the amount of days specified as an argument to deleteOldProfiles(), or 31 days if not specified. + - One file for every month before that. Specifically: The first available file of every month prior to this. + Setting the second argument to true will delete maps instead of profiles. (e.g. deleteOldProfiles(10, true)) + --]] + + -- Ensure correct value is passed for second argument + assert(type(delete_folder) == "string", "Wrong type for delete_folder; expected string, got " .. type(delete_folder)) + assert(table.contains({"profiles", "maps", "modules"}, delete_folder), "delete_folder must be profiles, maps or modules") + + local keepdays = tonumber(keepdays_arg) or 31 + local profile_table = {} + local used_last_mod_months = {} + local slash = (string.char(getMudletHomeDir():byte()) == "/") and "/" or "\\" + local delnum = 0 + + local to_folder = { + profiles = "current", + maps = "map", + } + + local dirpath = delete_folder == "modules" + and getMudletHomeDir()..slash..".."..slash..".."..slash.."moduleBackups" + or getMudletHomeDir()..slash..to_folder[delete_folder] + + -- Traverse the profiles folder and create a table of files: + for filename in lfs.dir(dirpath) do + if filename~="." and filename~=".." then + profile_table[#profile_table+1] = { + name = filename, + last_mod = lfs.attributes(dirpath..slash..filename, "modification") + } + end + end + + -- Sort the table according to last modification date from old to new: + table.sort(profile_table, function (a,b) return a.last_mod < b.last_mod end) + + echo(string.format( + "\nDeleting old %s. Files newer than %d days and one for every month before that will be kept.", + delete_folder, + keepdays + )) + + for i, v in ipairs(profile_table) do + local days = math.floor(os.difftime(os.time(), v.last_mod) / 86400) + local last_mod_month = os.date("%Y/%m", v.last_mod) + if days > keepdays then + -- For profiles older than X days, check if we already kept a table for this month: + if not table.contains(used_last_mod_months, last_mod_month) then + -- If not, do nothing and mark this month as "kept". + used_last_mod_months[#used_last_mod_months+1] = last_mod_month + else + -- Otherwise remove the file: + local success, errorstring = os.remove(dirpath..slash..v.name) + if success then + delnum = delnum + 1 + else + cecho("\n<red>ERROR: "..errorstring) + end + end + end + end + + echo(string.format("\nDeletion complete. %d/%d files were removed successfully.", delnum, #profile_table)) +end +</script> + <eventHandlerList /> + </Script> + </ScriptPackage> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/packages/echo/config.lua b/src/packages/echo/config.lua new file mode 100644 index 000000000..bb5f3222d --- /dev/null +++ b/src/packages/echo/config.lua @@ -0,0 +1,57 @@ +mpackage = [[echo]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[A set of aliases to test triggers on the command line.]] +description = [[# Echo Package + +The echo package provides a means of testing triggers via the command line with four command aliases; +`` `echo, `cecho, `decho, `hecho``. + +All act as if the given text came from the game itself and will fire any matching triggers. + +See [Triggers](https://wiki.mudlet.org/w/Manual:Introduction#Triggers) for further information on matching text. + +## `echo Alias + +Displays text on the screen and tells all matching triggers to fire. For coloring use one +of the other functions mentioned below. + +``` +-- examples +> `echo text - displays text on the main screen and tells all matching triggers to fire +> `echo This is a sample line from the game$$And this is a new line. +``` +See [echo](https://wiki.mudlet.org/w/Manual:Lua_Functions#echo), [feedTriggers](https://wiki.mudlet.org/w/Manual:Lua_Functions#feedTriggers), + +## `cecho Alias + +Like echo, but you can add color information using color names and ANSI values. + +``` +-- example: color format is <foreground:background> +> `cecho <green:red>green on red<r> reset$$<124:100>foreground of ANSI124 and background of ANSI100<r> +``` +See [cecho](https://wiki.mudlet.org/w/Manual:Lua_Functions#cecho), [cfeedTriggers](https://wiki.mudlet.org/w/Manual:Lua_Functions#cfeedTriggers). + +## `decho Alias + +Like cecho, but you can add color information using <r,g,b> format. + +``` +-- example +> `decho <0,128,0:128,0,0>green on red<r> reset +``` +See [decho](https://wiki.mudlet.org/w/Manual:Lua_Functions#decho), [dfeedTriggers](https://wiki.mudlet.org/w/Manual:Lua_Functions#dfeedTriggers). + +## `hecho Alias + +Like cecho, but you can add color information using hex #RRGGBB format. + +``` +-- example +> `hecho #008000,800000green on red#r reset +``` +See [hecho](https://wiki.mudlet.org/w/Manual:Lua_Functions#hecho), [hfeedTriggers](https://wiki.mudlet.org/w/Manual:Lua_Functions#hfeedTriggers). +]] +version = [[1]] +created = "2024-08-24T08:27:19+02:00" diff --git a/src/echo.mpackage b/src/packages/echo/echo.mpackage similarity index 100% rename from src/echo.mpackage rename to src/packages/echo/echo.mpackage diff --git a/src/echo.xml b/src/packages/echo/echo.xml similarity index 95% rename from src/echo.xml rename to src/packages/echo/echo.xml index 030ec11e2..5eaeb9512 100644 --- a/src/echo.xml +++ b/src/packages/echo/echo.xml @@ -52,7 +52,7 @@ echo("\n")</script> <ActionPackage /> <ScriptPackage /> <KeyPackage /> - <HelpPackage> - <helpURL></helpURL> - </HelpPackage> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> </MudletPackage> diff --git a/src/packages/enable-accessibility/config.lua b/src/packages/enable-accessibility/config.lua new file mode 100644 index 000000000..5a58919a3 --- /dev/null +++ b/src/packages/enable-accessibility/config.lua @@ -0,0 +1,32 @@ +mpackage = [[enable-accessibility]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Configuration for visually impaired users.]] +description = [[# enable-accessibility Package + +This package provides two aliases for visually impaired users. + +``` +> mudlet access on +> mudlet access reader +``` + +## mudlet access on + +Configures the following settings; + +- clears the command line after sending the command to the game +- does not echo the commands sent on the main screen +- adds a shortcut to switch between input line and main window, default Ctrl+Tab +- removes blank lines on Windows OS + +## mudlet access reader + +VoiceOver is text-to-speech (TTS) for Mac OS, but will skip reading text when there's lots of it coming on. + +This command configures a third-party TTS plugin called [mudlet-reader](https://github.com/tspivey/mudlet-reader) to alleviate this issue. + +See [Accessibility on OSX](https://wiki.mudlet.org/w/Accessibility_on_OSX) for more information. +]] +version = [[2]] +created = "2025-06-07T20:44:12-04:00" diff --git a/src/enable-accessibility.mpackage b/src/packages/enable-accessibility/enable-accessibility.mpackage similarity index 100% rename from src/enable-accessibility.mpackage rename to src/packages/enable-accessibility/enable-accessibility.mpackage diff --git a/src/mudlet-lua/lua/enable-accessibility/enable-accessibility.xml b/src/packages/enable-accessibility/enable-accessibility.xml similarity index 96% rename from src/mudlet-lua/lua/enable-accessibility/enable-accessibility.xml rename to src/packages/enable-accessibility/enable-accessibility.xml index 002739c32..effd33bba 100644 --- a/src/mudlet-lua/lua/enable-accessibility/enable-accessibility.xml +++ b/src/packages/enable-accessibility/enable-accessibility.xml @@ -21,9 +21,6 @@ echo("Disabling visual auto complete in the code editor ✓\n") setConfig("caretShortcut", "ctrltab") echo("Shortcut to switch between input line and main window set to Ctrl+Tab. You can also change it to either Tab or F6 in settings.\n") -setConfig("enableBlinkText", false) -echo("Blinking text disabled ✓\n") - if not getConfig("f3SearchEnabled") then setConfig("f3SearchEnabled", true) end diff --git a/src/packages/generic_mapper/config.lua b/src/packages/generic_mapper/config.lua new file mode 100644 index 000000000..361038792 --- /dev/null +++ b/src/packages/generic_mapper/config.lua @@ -0,0 +1,28 @@ +mpackage = [[generic_mapper]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Semi-automatic mapping, designed to work with many MUDs.]] +description = [[# generic_mapper Package + +This script allows for semi-automatic mapping using the included triggers. +While different games can have dramatically different ways of displaying +information, some effort has been put into giving the script a wide range of +potential patterns to look for, so that it can work with minimal effort in +many cases. + +generic_mapper looks at a combination of room titles, descriptions and exits +to locate and follow your character around maps you can make yourself, share +and download for your MUD. + +Two commands to get started are; +``` +> map basics +> map help +``` + +See [this forum thread](https://forums.mudlet.org/viewtopic.php?f=13&t=6105) for further assistance. + +See [this forum thread](https://forums.mudlet.org/search.php?keywords=mapping+script&terms=all&author=&sc=1&sf=titleonly&sr=topics&sk=t&sd=d&st=0&ch=400&t=0&submit=Search&pk_vid=08fcc4383ef3530916874145245184da) for more mapping scripts. +]] +version = [[2.1.9]] +created = "2026-07-18T12:00:00+00:00" diff --git a/src/mudlet-lua/lua/generic-mapper/generic_mapper.mpackage b/src/packages/generic_mapper/generic_mapper.mpackage similarity index 100% rename from src/mudlet-lua/lua/generic-mapper/generic_mapper.mpackage rename to src/packages/generic_mapper/generic_mapper.mpackage diff --git a/src/mudlet-lua/lua/generic-mapper/generic_mapper.xml b/src/packages/generic_mapper/generic_mapper.xml similarity index 100% rename from src/mudlet-lua/lua/generic-mapper/generic_mapper.xml rename to src/packages/generic_mapper/generic_mapper.xml diff --git a/src/mudlet-lua/lua/generic-mapper/versions.lua b/src/packages/generic_mapper/versions.lua similarity index 100% rename from src/mudlet-lua/lua/generic-mapper/versions.lua rename to src/packages/generic_mapper/versions.lua diff --git a/src/packages/gui-drop/config.lua b/src/packages/gui-drop/config.lua new file mode 100644 index 000000000..203e00cb9 --- /dev/null +++ b/src/packages/gui-drop/config.lua @@ -0,0 +1,18 @@ +mpackage = [[gui-drop]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Drag and drop images onto the main window to turn into a label and container.]] +description = [[Allow a user to drag and drop an image on the main screen which will turn it into a label and AdjustableContainer. + +### Description + +This packages allows a user to drag and drop an image on the main screen which will turn it into a label and AdjustableContainer. +The resultant script can be found in GUIDropManager which can then be tailored further as per normal scripting rules. The images +are copied to %user-profile/GUIDropImages/ + +### Usage + +Just drop an image file into the main window. It will be converted into a label inside an AdjustableContainer. +]] +version = [[1.1]] +created = "2025-01-19T13:10:54+04:00" diff --git a/src/mudlet-lua/lua/gui-drop/gui-drop.mpackage b/src/packages/gui-drop/gui-drop.mpackage similarity index 100% rename from src/mudlet-lua/lua/gui-drop/gui-drop.mpackage rename to src/packages/gui-drop/gui-drop.mpackage diff --git a/src/mudlet-lua/lua/gui-drop/gui-drop.xml b/src/packages/gui-drop/gui-drop.xml similarity index 100% rename from src/mudlet-lua/lua/gui-drop/gui-drop.xml rename to src/packages/gui-drop/gui-drop.xml diff --git a/src/packages/icesus-loader/config.lua b/src/packages/icesus-loader/config.lua new file mode 100644 index 000000000..7d0d5dd1a --- /dev/null +++ b/src/packages/icesus-loader/config.lua @@ -0,0 +1,15 @@ +mpackage = [[icesus-loader]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Downloads the Icesus interface when you first connect.]] +description = [[### Description + +Preinstalled on new Icesus profiles. On your first connection it downloads the +Icesus Mudlet package, maintained by the Icesus team, installs it and removes itself. + +### See Also + +* [Icesus Mudlet package on GitHub](https://github.com/Icesus-mud/mudlet-package) +]] +version = [[1]] +created = "2026-08-04T00:00:00+00:00" diff --git a/src/packages/icesus-loader/icesus-loader.mpackage b/src/packages/icesus-loader/icesus-loader.mpackage new file mode 100644 index 000000000..9bcba74bc Binary files /dev/null and b/src/packages/icesus-loader/icesus-loader.mpackage differ diff --git a/src/icesus-loader.xml b/src/packages/icesus-loader/icesus-loader.xml similarity index 100% rename from src/icesus-loader.xml rename to src/packages/icesus-loader/icesus-loader.xml diff --git a/src/packages/mg-loader/config.lua b/src/packages/mg-loader/config.lua new file mode 100644 index 000000000..1087dcec3 --- /dev/null +++ b/src/packages/mg-loader/config.lua @@ -0,0 +1,16 @@ +mpackage = [[mg-loader]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Laedt das MorgenGrauen-Interface beim ersten Verbinden herunter.]] +description = [[### Beschreibung + +Wird auf neuen MorgenGrauen-Profilen vorinstalliert. Beim ersten Verbinden laedt es +das vom MorgenGrauen-Team gepflegte Mudlet-Paket herunter, installiert es und +entfernt sich selbst. + +### Siehe auch + +* [MorgenGrauen-Mudlet-Paket auf GitHub](https://github.com/MorgenGrauen/mg-mudlet) +]] +version = [[1]] +created = "2026-08-04T00:00:00+00:00" diff --git a/src/packages/mg-loader/mg-loader.mpackage b/src/packages/mg-loader/mg-loader.mpackage new file mode 100644 index 000000000..bad59182a Binary files /dev/null and b/src/packages/mg-loader/mg-loader.mpackage differ diff --git a/src/mg-loader.xml b/src/packages/mg-loader/mg-loader.xml similarity index 100% rename from src/mg-loader.xml rename to src/packages/mg-loader/mg-loader.xml diff --git a/src/mpkg.mpackage b/src/packages/mpkg/mpkg.mpackage similarity index 100% rename from src/mpkg.mpackage rename to src/packages/mpkg/mpkg.mpackage diff --git a/src/mudlet-lua/lua/base-ui/config.lua b/src/packages/mudlet-base-ui/config.lua similarity index 98% rename from src/mudlet-lua/lua/base-ui/config.lua rename to src/packages/mudlet-base-ui/config.lua index c920ad2a7..24324c303 100644 --- a/src/mudlet-lua/lua/base-ui/config.lua +++ b/src/packages/mudlet-base-ui/config.lua @@ -23,5 +23,5 @@ Commands: When a game installs an interface of its own, this one quietly stands aside - `baseui show` brings it back if you prefer it. ]] -version = [[1.0.0]] +version = [[1.2.0]] created = "2026-07-25T12:00:00+00:00" diff --git a/src/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage b/src/packages/mudlet-base-ui/mudlet-base-ui.mpackage similarity index 84% rename from src/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage rename to src/packages/mudlet-base-ui/mudlet-base-ui.mpackage index 5fdcbfe60..1e7f0c53f 100644 Binary files a/src/mudlet-lua/lua/base-ui/mudlet-base-ui.mpackage and b/src/packages/mudlet-base-ui/mudlet-base-ui.mpackage differ diff --git a/src/mudlet-lua/lua/base-ui/mudlet-base-ui.xml b/src/packages/mudlet-base-ui/mudlet-base-ui.xml similarity index 65% rename from src/mudlet-lua/lua/base-ui/mudlet-base-ui.xml rename to src/packages/mudlet-base-ui/mudlet-base-ui.xml index e3103ad38..e7527037b 100644 --- a/src/mudlet-lua/lua/base-ui/mudlet-base-ui.xml +++ b/src/packages/mudlet-base-ui/mudlet-base-ui.xml @@ -1,7 +1,370 @@ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE MudletPackage> <MudletPackage version="1.001"> - <TriggerPackage /> + <TriggerPackage> + <TriggerGroup isActive="no" isFolder="yes" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>Mudlet base UI chat capture</name> + <script>-- A gate, then the shapes - the pattern to copy for triggers that have to +-- watch every line. Each gate matches substrings, no regex engine, and Mudlet +-- only offers a line to a trigger's children once its own pattern matched. A +-- trigger with no pattern, like this folder, passes everything through. +-- +-- Substring patterns are case-sensitive, so every literal a child's regex +-- needs must appear in its gate's pattern list, spelled the same way. Miss +-- that and the child silently never sees a line. +-- +-- It is also why the gates spell out "says" and "You say" rather than the +-- "say" they share: a gate hit costs a capture list and a script call before +-- any child regex runs, and "say" would hand all that to every "essay". +-- The channel gate cannot be narrowed like that, since a bare "[", "(" or +-- "<" is all a tagged line has in common. +-- +-- This folder ships inactive and is the only thing the "Mudlet base UI" +-- script switches: it enables the folder on load and disables it again when +-- the game turns out to send its chat over GMCP. Nothing reaches a gate while +-- the folder is off, so one switch retires the whole layer.</script> + <triggerType>0</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList /> + <regexCodePropertyList /> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>BaseUI chat: tells</name> + <script></script> + <triggerType>0</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>tells you</string> + <string>tells the</string> + <string>You tell</string> + <string>whispers to you</string> + </regexCodeList> + <regexCodePropertyList> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + </regexCodePropertyList> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>someone tells you</name> + <script>BaseUI.routeChatLine("tells")</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\w[\w'-]* tells you\b[^,:]{0,30}(?::|,?\s*['"])</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>you tell someone</name> + <script>BaseUI.routeChatLine("tells")</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^You tell [\w'-]+[^,:]{0,30}(?::|,?\s*['"])</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>you tell the group</name> + <script>BaseUI.routeChatLine("tells")</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^You tell the (?:group|formation)\b</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>someone whispers to you</name> + <script>BaseUI.routeChatLine("tells")</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\w[\w'-]* whispers to you\b[^,:]{0,30}(?::|,?\s*['"])</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>someone tells the group</name> + <script>BaseUI.routeChatLine("tells")</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\w[\w'-]* tells the (?:group|formation)\b</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>BaseUI chat: speech</name> + <script></script> + <triggerType>0</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>says</string> + <string>asks</string> + <string>exclaims</string> + <string>yells</string> + <string>shouts</string> + <string>You say</string> + <string>You ask</string> + <string>You exclaim</string> + <string>You yell</string> + <string>You shout</string> + </regexCodeList> + <regexCodePropertyList> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + <integer>0</integer> + </regexCodePropertyList> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>someone says</name> + <script>BaseUI.routeChatLine()</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\w[\w'-]* (?:says|asks|exclaims)[^,:]{0,20},?\s*['"]</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>you say</name> + <script>BaseUI.routeChatLine()</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^You (?:say|ask|exclaim)[^,:]{0,20},?\s*['"]</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>someone yells</name> + <script>BaseUI.routeChatLine()</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\w[\w'-]* (?:yells|shouts)[^,:]{0,20},?\s*['"]</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>you yell</name> + <script>BaseUI.routeChatLine()</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^You (?:yell|shout)[^,:]{0,20},?\s*['"]</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>BaseUI chat: channel tags</name> + <script></script> + <triggerType>2</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>[</string> + <string>(</string> + <string><</string> + </regexCodeList> + <regexCodePropertyList> + <integer>2</integer> + <integer>2</integer> + <integer>2</integer> + </regexCodePropertyList> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>[tag] channel line</name> + <script>BaseUI.routeTaggedChatLine(matches[2])</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\[([\w][\w -]{0,18})\]</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>(tag) channel line</name> + <script>BaseUI.routeTaggedChatLine(matches[2])</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^\(([\w -]{1,18})\)[:\s]</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + <Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> + <name>< tag | channel line</name> + <script>BaseUI.routeTaggedChatLine(matches[2])</script> + <triggerType>1</triggerType> + <conditonLineDelta>0</conditonLineDelta> + <mStayOpen>0</mStayOpen> + <mCommand></mCommand> + <packageName></packageName> + <mFgColor>#ff0000</mFgColor> + <mBgColor>#ffff00</mBgColor> + <mSoundFile></mSoundFile> + <colorTriggerFgColor>#000000</colorTriggerFgColor> + <colorTriggerBgColor>#000000</colorTriggerBgColor> + <regexCodeList> + <string>^< ?([\w -]{1,18}) ?\|</string> + </regexCodeList> + <regexCodePropertyList> + <integer>1</integer> + </regexCodePropertyList> + </Trigger> + </Trigger> + </TriggerGroup> + </TriggerPackage> <TimerPackage /> <AliasPackage> <Alias isActive="yes" isFolder="no"> @@ -37,7 +400,6 @@ BaseUI = BaseUI or {} BaseUI.gauges = BaseUI.gauges or {} BaseUI.usedGaugeSlots = BaseUI.usedGaugeSlots or 0 -BaseUI.chatTriggerIds = BaseUI.chatTriggerIds or {} BaseUI.recentCaptures = BaseUI.recentCaptures or {} BaseUI.vitalsTriggerIds = BaseUI.vitalsTriggerIds or {} BaseUI.shapeSightings = BaseUI.shapeSightings or {} @@ -221,14 +583,17 @@ BaseUI.chatChannelNames = { yell = true, form = true, town = true, } --- entries with tagged = true capture a channel tag that must pass the list --- above; family routes a line into the Tells or Channels tab as well as All +-- The same shapes as the "Mudlet base UI chat capture" trigger tree, in the +-- same order (StarterUiTriggerCostTest compares them index by index): the tree +-- routes chat, this copy tells the vitals layer which lines are conversation. +-- tagged = true means the shape captures a channel tag that must pass the +-- list above. local chatPatterns = { - { regex = [=[^\w[\w'-]* tells you\b[^,:]{0,30}(?::|,?\s*['"])]=], family = "tells" }, - { regex = [=[^You tell [\w'-]+[^,:]{0,30}(?::|,?\s*['"])]=], family = "tells" }, - { regex = [=[^You tell the (?:group|formation)\b]=], family = "tells" }, - { regex = [=[^\w[\w'-]* whispers to you\b[^,:]{0,30}(?::|,?\s*['"])]=], family = "tells" }, - { regex = [=[^\w[\w'-]* tells the (?:group|formation)\b]=], family = "tells" }, + { regex = [=[^\w[\w'-]* tells you\b[^,:]{0,30}(?::|,?\s*['"])]=] }, + { regex = [=[^You tell [\w'-]+[^,:]{0,30}(?::|,?\s*['"])]=] }, + { regex = [=[^You tell the (?:group|formation)\b]=] }, + { regex = [=[^\w[\w'-]* whispers to you\b[^,:]{0,30}(?::|,?\s*['"])]=] }, + { regex = [=[^\w[\w'-]* tells the (?:group|formation)\b]=] }, { regex = [=[^\w[\w'-]* (?:says|asks|exclaims)[^,:]{0,20},?\s*['"]]=] }, { regex = [=[^You (?:say|ask|exclaim)[^,:]{0,20},?\s*['"]]=] }, { regex = [=[^\w[\w'-]* (?:yells|shouts)[^,:]{0,20},?\s*['"]]=] }, @@ -245,9 +610,7 @@ local chatTabs = { } function BaseUI.routeChatLine(family) - -- gmcpChat means the trigger layer is retired: killing a trigger does not - -- stop it firing on lines from the batch already being processed - -- several patterns can match the same line - copy it only once + -- several shapes can match the same line - copy it only once if BaseUI.gmcpChat or BaseUI.dormant() or getLineNumber() == BaseUI.lastChatLine then return end @@ -278,8 +641,8 @@ function BaseUI.routeChatLine(family) end end -function BaseUI.routeTaggedChatLine() - local tag = matches[2]:match("(%S+)") +function BaseUI.routeTaggedChatLine(tag) + tag = type(tag) == "string" and tag:match("(%S+)") if not tag then return end @@ -291,27 +654,80 @@ function BaseUI.routeTaggedChatLine() BaseUI.routeChatLine(family) end -function BaseUI.createChatTriggers() - if BaseUI.dormant() or BaseUI.gmcpChat or next(BaseUI.chatTriggerIds) then +-- renaming these in the trigger tree means renaming them here +local chatTree = "Mudlet base UI chat capture" +local chatGates = { "BaseUI chat: tells", "BaseUI chat: speech", "BaseUI chat: channel tags" } + +-- a gate reached through a switched-off folder is not armed whatever its own +-- flag says, and enableTrigger() only reports that it found the name - hence +-- the ancestor check +local function gateArmed(gate) + return isActive(gate, "trigger", true) > 0 +end + +-- enableTrigger and disableTrigger can only name a trigger, and a name is not +-- unique: copying this tree in the editor reproduces every name exactly, so +-- switching ours would switch the player's copy too. Only the folder is ever +-- named here, which keeps a copied gate out of it, and when the folder name +-- itself is shared the layer stops switching altogether - routeChatLine() +-- re-checks gmcpChat and dormant() on every line, so what is captured stays +-- right either way. All that is lost is the scans disarming would have saved. +function BaseUI.chatTreeShared() + return exists(chatTree, "trigger") ~= 1 +end + +function BaseUI.armChatTriggers() + if BaseUI.dormant() or BaseUI.gmcpChat then return end - for _, pattern in ipairs(chatPatterns) do - local handler - if pattern.tagged then - handler = BaseUI.routeTaggedChatLine - else - local family = pattern.family - handler = function() BaseUI.routeChatLine(family) end + if BaseUI.chatTreeShared() then + BaseUI.reportSharedChatTree() + return + end + enableTrigger(chatTree) + for _, gate in ipairs(chatGates) do + if not gateArmed(gate) then + BaseUI.reportDeadChatGate(gate) end - table.insert(BaseUI.chatTriggerIds, tempRegexTrigger(pattern.regex, handler)) end end -function BaseUI.killChatTriggers() - for _, id in ipairs(BaseUI.chatTriggerIds) do - killTrigger(id) +function BaseUI.reportSharedChatTree() + if BaseUI.warnedSharedChatTree then + return end - BaseUI.chatTriggerIds = {} + BaseUI.warnedSharedChatTree = true + debugc("[ Mudlet UI ] more than one trigger is called \"" .. chatTree .. "\", so the chat capture is left switched on rather than risk switching yours") +end + +-- debugc alone would not do: it writes to the editor's error console, which is +-- hidden until the player goes looking for it, and is dropped entirely if the +-- editor was never opened. The player-facing line is once per session, because +-- arming runs again on every reconnect. +function BaseUI.reportDeadChatGate(gate) + debugc("[ Mudlet UI ] chat capture gate \"" .. gate .. "\" did not come up, so those lines will not be captured") + if BaseUI.warnedDeadChatGate then + return + end + BaseUI.warnedDeadChatGate = true + cecho("\n<dim_grey>[ Mudlet UI ] Part of the chat capture is switched off, so some chat may not appear here - type <yellow>baseui<dim_grey> for options.<reset>\n") +end + +function BaseUI.disarmChatTriggers() + if BaseUI.chatTreeShared() then + BaseUI.reportSharedChatTree() + return + end + disableTrigger(chatTree) +end + +function BaseUI.chatTriggersArmed() + for _, gate in ipairs(chatGates) do + if not gateArmed(gate) then + return false + end + end + return true end -- vitals from plain text, for games that offer no protocol at all. Only @@ -337,6 +753,28 @@ end local numberFirstGuard = [=[(?<![\d:=])(?<![:=][ ]|[:=][ ]{2}|[:=][ ]{3}|[:=][ ]{4}|[:=][ ]{5}|[:=][ ]{6}|[:=][ ]{7}|[:=][ ]{8})]=] local labelFirstGuard = [=[(?<![\d%])(?<!\d[ ]|\d[ ]{2}|\d[ ]{3}|\d[ ]{4}|\d[ ]{5}|\d[ ]{6}|\d[ ]{7}|\d[ ]{8})]=] +-- Add label spellings here rather than inline in a shape below: anyVitalsLabel +-- is built from these tables, and a spelling that only exists inline is missing +-- from the prefilter, so the shape never sees a line to read. +local promptLabels = { + hp = [=[hp|health|hits?]=], + mp = [=[mp|mana|sp|magic|energy]=], + mv = [=[mv|moves?|movement|end(?:urance)?|st(?:amina)?]=], + xp = [=[xp|exp|tnl]=], +} +-- percentages and current-only prompts need the conservative spellings: a +-- looser one turns ordinary prose into a reading +local coreLabels = { + hp = [=[hp|health]=], + mp = [=[mp|mana|sp]=], +} +-- what a terse prompt abbreviates to once the number has been read: "523h" +local shortLabels = { + hp = [=[h(?:p|its?)?]=], + mp = [=[m(?:p|ana)?]=], + mv = [=[mv|moves?]=], +} + -- score screens across the codebase families (Diku/Merc/ROM, Circle/tbaMUD, -- SMAUG, LPMud, IRE, Aardwolf...) share a handful of label spellings per -- stat; the sets below feed every score-screen shape. Unlike prompts, score @@ -400,49 +838,68 @@ local function scoreSentence(stat, pair) return youHave .. [=[.*?\b]=] .. pair .. [=[\s*]=] .. sentenceLabels[stat] .. sentenceTail end +-- The single trigger fronting every shape below. Its one contract: it must +-- match every line a shape reads a value from, or that game's gauges silently +-- never appear. Being loose the other way only costs a Lua shape walk, so keep +-- it loose - "the orc hits you for 12 damage" gets through and that is fine. +-- +-- (?<![a-z]) / (?![a-z]) rather than \b: a label may abut its number +-- ("hp100/120"), which is not a word boundary. The optional "points" tail is +-- for score sentences that run the two together ("100/120 manapoints"). +local anyVitalsLabel = table.concat({ + promptLabels.hp, promptLabels.mp, promptLabels.mv, promptLabels.xp, + coreLabels.hp, coreLabels.mp, + shortLabels.hp, shortLabels.mp, shortLabels.mv, + scoreLabels.hp, scoreLabels.mp, scoreLabels.mv, scoreLabels.xp, + sentenceLabels.hp, sentenceLabels.mp, sentenceLabels.mv, + -- two prompt shapes spell a lone "m" for mana inline + [=[m]=], +}, "|") +BaseUI.vitalsPrefilter = [=[(?i)(?<![a-z])(?:]=] .. anyVitalsLabel .. [=[)(?:\s*points?)?(?![a-z])]=] + local vitalsLinePatterns = { -- self-sufficient prompt shapes: cur/max with the label after the numbers { stat = "hp", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:hp|health|hits?)\b]=] }, + regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:]=] .. promptLabels.hp .. [=[)\b]=] }, { stat = "mp", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:mp|mana|sp|magic|energy|m)\b]=] }, + regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:]=] .. promptLabels.mp .. [=[|m)\b]=] }, { stat = "mv", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:mv|moves?|movement|end(?:urance)?|st(?:amina)?)\b]=] }, + regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:]=] .. promptLabels.mv .. [=[)\b]=] }, { stat = "xp", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:xp|exp|tnl)\b]=] }, + regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)\s*/\s*(\d+)\s*(?:]=] .. promptLabels.xp .. [=[)\b]=] }, -- self-sufficient prompt shapes: cur/max with the label first. The -- explicit-separator form is trusted anywhere; the separator-less form -- needs the digit lookbehinds so "523/600 hp ..." cannot have its own -- trailing label re-read as the start of a new reading { stat = "hp", kind = "curmax", gated = true, - regex = [=[(?i)(?<!%)\b(?:hp|health|hits?)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)(?<!%)\b(?:]=] .. promptLabels.hp .. [=[)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "hp", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:hp|health|hits?)\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:]=] .. promptLabels.hp .. [=[)\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "mp", kind = "curmax", gated = true, - regex = [=[(?i)(?<!%)\b(?:mp|mana|sp|magic|energy)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)(?<!%)\b(?:]=] .. promptLabels.mp .. [=[)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "mp", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:mp|mana|sp|magic|energy)\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:]=] .. promptLabels.mp .. [=[)\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "mv", kind = "curmax", gated = true, - regex = [=[(?i)(?<!%)\b(?:mv|moves?|movement|end(?:urance)?|st(?:amina)?)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)(?<!%)\b(?:]=] .. promptLabels.mv .. [=[)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "mv", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:mv|moves?|movement|end(?:urance)?|st(?:amina)?)\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:]=] .. promptLabels.mv .. [=[)\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "xp", kind = "curmax", gated = true, - regex = [=[(?i)(?<!%)\b(?:xp|exp|tnl)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)(?<!%)\b(?:]=] .. promptLabels.xp .. [=[)[:=]\s*(\d+)\s*/\s*(\d+)]=] }, { stat = "xp", kind = "curmax", gated = true, - regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:xp|exp|tnl)\s*(\d+)\s*/\s*(\d+)]=] }, + regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:]=] .. promptLabels.xp .. [=[)\s*(\d+)\s*/\s*(\d+)]=] }, -- labelled percentages need no maximum at all { stat = "hp", kind = "percent", gated = true, - regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)%\s*(?:hp|health)\b]=] }, + regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)%\s*(?:]=] .. coreLabels.hp .. [=[)\b]=] }, { stat = "hp", kind = "percent", gated = true, - regex = [=[(?i)(?<!%)\b(?:hp|health)[:=]\s*(\d+)%]=] }, + regex = [=[(?i)(?<!%)\b(?:]=] .. coreLabels.hp .. [=[)[:=]\s*(\d+)%]=] }, { stat = "hp", kind = "percent", gated = true, - regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:hp|health)\s*(\d+)%]=] }, + regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:]=] .. coreLabels.hp .. [=[)\s*(\d+)%]=] }, { stat = "mp", kind = "percent", gated = true, - regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)%\s*(?:mp|mana|sp|m)\b]=] }, + regex = [=[(?i)]=] .. numberFirstGuard .. [=[(\d+)%\s*(?:]=] .. coreLabels.mp .. [=[|m)\b]=] }, { stat = "mp", kind = "percent", gated = true, - regex = [=[(?i)(?<!%)\b(?:mp|mana|sp)[:=]\s*(\d+)%]=] }, + regex = [=[(?i)(?<!%)\b(?:]=] .. coreLabels.mp .. [=[)[:=]\s*(\d+)%]=] }, { stat = "mp", kind = "percent", gated = true, - regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:mp|mana|sp)\s*(\d+)%]=] }, + regex = [=[(?i)]=] .. labelFirstGuard .. [=[\b(?:]=] .. coreLabels.mp .. [=[)\s*(\d+)%]=] }, -- score-screen shapes, all trusted on first sight. A single labelled row, -- with or without a separator, possibly behind table borders: -- "Health : 523/600", "| Hit Points 3,600/3,600 |" @@ -507,22 +964,68 @@ local vitalsLinePatterns = { -- current-only prompt shapes; the lookarounds keep them off cur/max and -- percentage lines, which belong to the shapes above { stat = "hp", kind = "bare", gated = true, - regex = [=[(?i)(?<![\d/.,:%])(\d+)\s*h(?:p|its?)?\b(?!\s*[/%])]=] }, + regex = [=[(?i)(?<![\d/.,:%])(\d+)\s*]=] .. shortLabels.hp .. [=[\b(?!\s*[/%])]=] }, { stat = "hp", kind = "bare", gated = true, - regex = [=[(?i)\b(?:hp|health|hits?)[:=]\s*(\d+)\b(?!\s*[/%.])]=] }, + regex = [=[(?i)\b(?:]=] .. promptLabels.hp .. [=[)[:=]\s*(\d+)\b(?!\s*[/%.])]=] }, { stat = "mp", kind = "bare", gated = true, - regex = [=[(?i)(?<![\d/.,:%])(\d+)\s*m(?:p|ana)?\b(?!\s*[/%])]=] }, + regex = [=[(?i)(?<![\d/.,:%])(\d+)\s*]=] .. shortLabels.mp .. [=[\b(?!\s*[/%])]=] }, { stat = "mp", kind = "bare", gated = true, - regex = [=[(?i)\b(?:mp|mana|sp)[:=]\s*(\d+)\b(?!\s*[/%.])]=] }, + regex = [=[(?i)\b(?:]=] .. coreLabels.mp .. [=[)[:=]\s*(\d+)\b(?!\s*[/%.])]=] }, { stat = "mv", kind = "bare", gated = true, - regex = [=[(?i)(?<![\d/.,:%])(\d+)\s*(?:mv|moves?)\b(?!\s*[/%])]=] }, + regex = [=[(?i)(?<![\d/.,:%])(\d+)\s*(?:]=] .. shortLabels.mv .. [=[)\b(?!\s*[/%])]=] }, } +-- rex.match given a pattern STRING compiles it afresh on every call, and a line +-- reaching this layer is tested against up to 77 shapes. Never pass .regex to +-- rex.match directly. +local compiledPattern = setmetatable({}, { + __index = function(cache, regex) + local ok, compiled = pcall(rex.new, regex) + if not ok then + -- keep the string so rex.match still raises on it rather than the shape + -- vanishing; the error is only precise here, not once per line + debugc("[ Mudlet UI ] a vitals/chat shape would not compile, falling back to per-line compilation: " + .. tostring(compiled) .. " in: " .. regex) + end + local value = ok and compiled or regex + rawset(cache, regex, value) + return value + end, +}) + +-- for the test suite: how many shapes parseVitalsLine walks +function BaseUI.vitalsShapeCount() + return #vitalsLinePatterns +end + +-- for the test suite: the shapes chatLikeLine() walks, so a corpus can be held +-- to cover them all +function BaseUI.chatShapeRegexes() + local regexes = {} + for _, pattern in ipairs(chatPatterns) do + regexes[#regexes + 1] = pattern.regex + end + return regexes +end + +-- for the test suite: the fallback above still works, so only an explicit check +-- notices a shape that fell back to per-line compilation +function BaseUI.shapesArePrecompiled() + for _, list in ipairs({ chatPatterns, vitalsLinePatterns }) do + for _, pattern in ipairs(list) do + if type(compiledPattern[pattern.regex]) ~= "userdata" then + return false + end + end + end + return true +end + -- lines the chat layer would route are never harvested for vitals - a tell -- saying "I am on 100/120 hp" is conversation, not a prompt function BaseUI.chatLikeLine(text) for _, pattern in ipairs(chatPatterns) do - local capture = rex.match(text, pattern.regex) + local capture = rex.match(text, compiledPattern[pattern.regex]) if capture then if not pattern.tagged then return true @@ -564,7 +1067,7 @@ function BaseUI.parseVitalsLine(text) return readings end for index, pattern in ipairs(vitalsLinePatterns) do - local first, second = rex.match(text, pattern.regex) + local first, second = rex.match(text, compiledPattern[pattern.regex]) local reading if pattern.kind == "curmax" and first then local current, max = parseVitalsNumber(first), parseVitalsNumber(second) @@ -597,8 +1100,9 @@ function BaseUI.scoreWindowOpen() return BaseUI.scoreWindowUntil ~= nil and getEpoch() < BaseUI.scoreWindowUntil end --- one handler for every vitals pattern: the first trigger to fire on a line --- processes all shapes on it in one pass, later ones see the marker and stop +-- lastChatLine keeps a line the chat layer routed out of the vitals layer; the +-- refresh after applyVitals is because building the dock rewraps the buffer and +-- renumbers lines, which would otherwise leave the marker on an unrelated line function BaseUI.onVitalsLine() local lineNumber = getLineNumber() if lineNumber == BaseUI.lastVitalsLine or lineNumber == BaseUI.lastChatLine then @@ -635,13 +1139,17 @@ function BaseUI.onVitalsLine() end end +-- applyVitals discards prompt readings once a protocol holds the lock, so the +-- plain-text layer is pure per-line cost from then on +function BaseUI.structuredVitalsOwnGauges() + return BaseUI.vitalsLock >= sourceRanks.msdp +end + function BaseUI.createVitalsTriggers() - if BaseUI.dormant() or next(BaseUI.vitalsTriggerIds) then + if BaseUI.dormant() or BaseUI.structuredVitalsOwnGauges() or next(BaseUI.vitalsTriggerIds) then return end - for _, pattern in ipairs(vitalsLinePatterns) do - table.insert(BaseUI.vitalsTriggerIds, tempRegexTrigger(pattern.regex, BaseUI.onVitalsLine)) - end + BaseUI.vitalsTriggerIds = { tempRegexTrigger(BaseUI.vitalsPrefilter, BaseUI.onVitalsLine) } end function BaseUI.killVitalsTriggers() @@ -714,7 +1222,7 @@ end function BaseUI.standAside(_, packageName) BaseUI.settings.standingAside = packageName or "the game's interface" BaseUI.saveSettings() - BaseUI.killChatTriggers() + BaseUI.disarmChatTriggers() BaseUI.killVitalsTriggers() if BaseUI.container then BaseUI.container:hide() @@ -736,7 +1244,7 @@ function BaseUI.serverGuiRemoved(_, packageName) end BaseUI.settings.standingAside = nil BaseUI.saveSettings() - BaseUI.createChatTriggers() + BaseUI.armChatTriggers() BaseUI.createVitalsTriggers() BaseUI.renderVitals() end) @@ -968,6 +1476,10 @@ function BaseUI.applyVitals(source, readings, snapshot) -- the lock and everything the previous source reported is dropped BaseUI.vitalsData = {} BaseUI.vitalsLock = rank + -- handleDisconnect re-arms these: the next connection may have no protocol + if BaseUI.structuredVitalsOwnGauges() then + BaseUI.killVitalsTriggers() + end end for _, stat in ipairs(vitalsStats) do local reading = readings[stat.key] @@ -1123,7 +1635,9 @@ function BaseUI.handleDisconnect() -- first GMCP chat message will retire them again, and the recentCaptures -- ring already covers the echo race that comes with that) BaseUI.gmcpChat = false - BaseUI.createChatTriggers() + BaseUI.armChatTriggers() + -- only re-arms because the lock was cleared above + BaseUI.createVitalsTriggers() end function BaseUI.addChatMessage() @@ -1134,7 +1648,7 @@ function BaseUI.addChatMessage() -- the game sends chat over GMCP and usually echoes it as plain text too - -- retire the generic triggers for this session so lines are not captured twice BaseUI.gmcpChat = true - BaseUI.killChatTriggers() + BaseUI.disarmChatTriggers() if BaseUI.dormant() then return end @@ -1199,7 +1713,7 @@ end function BaseUI.hide() BaseUI.settings.hidden = true BaseUI.saveSettings() - BaseUI.killChatTriggers() + BaseUI.disarmChatTriggers() BaseUI.killVitalsTriggers() if BaseUI.container then BaseUI.container:hide() @@ -1220,7 +1734,7 @@ function BaseUI.show() end BaseUI.updateVitals() BaseUI.updateMsdpVitals() - BaseUI.createChatTriggers() + BaseUI.armChatTriggers() BaseUI.createVitalsTriggers() BaseUI.renderVitals() if BaseUI.container then @@ -1259,11 +1773,12 @@ function BaseUI.alias(input) end BaseUI.loadSettings() --- the chat triggers exist from the start (unless the UI is hidden) so the --- interface can appear on the first chat line even on games without GMCP; --- the vitals triggers come second so a chat line is routed (and marked as --- chat) before the vitals layer gets a look at it -BaseUI.createChatTriggers() +-- the chat gates are live from the start (unless the UI is dormant) so the +-- interface can appear on the first chat line even on games without GMCP. +-- Their tree is imported ahead of this script, and the vitals trigger is +-- created after it, so a chat line is routed (and marked as chat) before the +-- vitals layer gets a look at it +BaseUI.armChatTriggers() BaseUI.createVitalsTriggers() -- negotiate MSDP up front: it costs nothing on servers without it and gives -- gauges to games that have it but lack GMCP diff --git a/src/packages/mudlet-tutorial/Mudlet Tutorial.xml b/src/packages/mudlet-tutorial/Mudlet Tutorial.xml new file mode 100644 index 000000000..6db413e99 --- /dev/null +++ b/src/packages/mudlet-tutorial/Mudlet Tutorial.xml @@ -0,0 +1,1186 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <AliasGroup isActive="yes" isFolder="yes"> + <name>Mini Quest</name> + <script></script> + <command></command> + <packageName></packageName> + <regex></regex> + <Alias isActive="yes" isFolder="no"> + <name>Directions</name> + <script>if hq.state ~= "PLAYING" then return end + +local dir = matches[2] + +if dir == "n" then dir = "north" end +if dir == "s" then dir = "south" end +if dir == "e" then dir = "east" end +if dir == "w" then dir = "west" end +if dir == "u" then dir = "up" end +if dir == "d" then dir = "down" end + +local exits = getRoomExits(getPlayerRoom()) +local doors = getDoors(getPlayerRoom()) +local doorStatus = {"open", "closed", "locked"} + +-- if there's an exit that's not a door, or the door is already open then +if exits[dir] and not doors[string.cut(dir, 1)] or doors[string.cut(dir, 1)] == 1 then + hq.onRoomExit() + echo(f("You head {dir}.\n\n")) + centerview(exits[dir]) + hq.onRoomEntry() + cecho(hq.prompt) + hq.state = "PLAYING" +elseif doors[string.cut(dir, 1)] then + echo(f("The way {dir} is " .. doorStatus[doors[string.cut(dir, 1)]] .. ".\n")) + if not hq.openDoorHelp then + hq.help("MUDs can have doors which can be <r><b>open<r><dark_sea_green>ed and <r><b>close<r><dark_sea_green>d.\n\nHINT: use the verb in combination with the direction e.g., <r><b>open north<r><green>.\n") + hq.openDoorHelp = true + end + cecho(hq.prompt) +else + echo(f("There is no exit {dir}.\n")) + cecho(hq.prompt) +end</script> + <command></command> + <packageName></packageName> + <regex>^(n|north|s|south|e|east|w|west|u|up|d|down)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Say</name> + <script>if hq.state ~= "PLAYING" then return end + +-- roomID, keyword, reply +local dialog = { { 1, "help", "The Sheriff replies, 'I'm too busy right now.'\n" }, + { 1, "lunch", "The Sheriff replies, 'Yes please. Go buy me an apple from the fruit vendor.'\n" }, + { 1, "hello", "The Sheriff smiles at you warmly.\n" }, + { 5, "hello", "The vendor smiles and says, 'Hello, how can I help you?'\n" }, + { 5, "fruit", "The vendor says, 'Please look at the sign for more information.'\n" } + } + +local talk = matches[2] + +if hq.debugging then hq.debug(matches[2]) end + +echo(f"You say, '{matches[2]}'\n") + +for i = 1, #dialog do + if dialog[i][1] == getPlayerRoom() and dialog[i][2] == talk then + echo(dialog[i][3]) + cecho(hq.prompt) + return + end +end + +echo("No one around responds to your words.\n") +cecho(hq.prompt)</script> + <command></command> + <packageName></packageName> + <regex>^say (.+)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Open</name> + <script>if hq.state ~= "PLAYING" then return end + +local args = matches[2] + +if args == "n" then args = "north" end +if args == "s" then args = "south" end +if args == "e" then args = "east" end +if args == "w" then args = "west" end +if args == "u" then args = "up" end +if args == "d" then args = "down" end + +local doors = getDoors(getPlayerRoom()) +local exits = getRoomExits(getPlayerRoom()) + +if table.contains(doors, string.cut(args, 1)) then + if doors[string.cut(args, 1)] == 3 then --door locked + echo("Door is locked.\n") + cecho(hq.prompt) + return + elseif doors[string.cut(args, 1)] == 2 then -- closed + echo("You open the door.\n") + cecho(hq.prompt) + setDoor(getPlayerRoom(), string.cut(args, 1), 1) + setDoor(exits[args], hq.reverseDirs[string.cut(args, 1)], 1) + return + elseif doors[string.cut(args, 1)] == 1 then -- open + echo("The door is already open.\n") + cecho(hq.prompt) + return + end +end + +echo("You don't see that here.\n") +cecho(hq.prompt)</script> + <command></command> + <packageName></packageName> + <regex>^open (.+)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Close</name> + <script>if hq.state ~= "PLAYING" then return end + +local args = matches[2] + +if args == "n" then args = "north" end +if args == "s" then args = "south" end +if args == "e" then args = "east" end +if args == "w" then args = "west" end +if args == "u" then args = "up" end +if args == "d" then args = "down" end + +local doors = getDoors(getPlayerRoom()) +local exits = getRoomExits(getPlayerRoom()) + +if table.contains(doors, string.cut(args, 1)) then + if doors[string.cut(args, 1)] > 1 then -- door closed, or locked + echo("Door is already closed.\n") + cecho(hq.prompt) + return + elseif doors[string.cut(args, 1)] == 1 then -- closed + echo("You close the door.\n") + cecho(hq.prompt) + setDoor(getPlayerRoom(), string.cut(args, 1), 2) + setDoor(exits[args], hq.reverseDirs[string.cut(args, 1)], 2) + return + end +end + +echo("You don't see that here.\n") +cecho(hq.prompt)</script> + <command></command> + <packageName></packageName> + <regex>^close (.+)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Look Objects</name> + <script>if hq.state ~= "PLAYING" then return end + +local args = matches[3] + +-- Strip leading prepositions and articles for natural English input +args = args:gsub("^at the ", "") +args = args:gsub("^at an ", "") +args = args:gsub("^at a ", "") +args = args:gsub("^at ", "") +args = args:gsub("^the ", "") +args = args:gsub("^an ", "") +args = args:gsub("^a ", "") + +-- Handle "look around" / "look room" as bare look +if args == "around" or args == "room" then + cecho(f"<cyan>{getRoomName(getPlayerRoom())}<r>\n") + cecho(getRoomUserData(getPlayerRoom(), "description")) + echo("\n") + cecho(hq.prompt) + return +end + +local mobiles, items = getRoomUserData(getPlayerRoom(), "mobiles"), + getRoomUserData(getPlayerRoom(), "items") + +if mobiles:len() > 0 then + mobiles = yajl.to_value(mobiles) + + if table.contains(mobiles, args) then + cecho(mobiles[args]) + cecho(hq.prompt) + return + end +end + +if items:len() > 0 then + items = yajl.to_value(items) + + if table.contains(items, args) then + cecho(items[args]) + cecho(hq.prompt) + return + end +end + +-- Try stripping spatial prepositions and retry lookup +local stripped = args:gsub("^under ", "") +stripped = stripped:gsub("^behind ", "") +stripped = stripped:gsub("^inside ", "") +stripped = stripped:gsub("^beneath ", "") +stripped = stripped:gsub("^in ", "") +stripped = stripped:gsub("^on ", "") +if stripped ~= args then + if mobiles and type(mobiles) == "table" and table.contains(mobiles, stripped) then + cecho(mobiles[stripped]) + cecho(hq.prompt) + return + end + if items and type(items) == "table" and table.contains(items, stripped) then + cecho(items[stripped]) + cecho(hq.prompt) + return + end +end + +echo("You don't see that here.\n") +cecho(hq.prompt)</script> + <command></command> + <packageName></packageName> + <regex>^(l|look|examine) (.+)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Look</name> + <script>if hq.state ~= "PLAYING" then return end + +local mobiles, items, onRoomEntry, onRoomHelp = getRoomUserData(getPlayerRoom(), "mobiles"), + getRoomUserData(getPlayerRoom(), "items"), + getRoomUserData(getPlayerRoom(), "onRoomEntry"), + getRoomUserData(getPlayerRoom(), "onRoomHelp") + +cecho(f"<cyan>{getRoomName(getPlayerRoom())}<r>\n") +cecho(getRoomUserData(getPlayerRoom(), "description")) +echo("\n") + +cecho(hq.prompt)</script> + <command></command> + <packageName></packageName> + <regex>^(l|look)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Buy at Fruit Vendor</name> + <script>if hq.state ~= "PLAYING" then return end + +local item = matches[2] +if item then + -- Strip leading articles for natural English input + item = item:gsub("^the ", "") + item = item:gsub("^some ", "") + item = item:gsub("^an ", "") + item = item:gsub("^a ", "") +end + +if getPlayerRoom() == 5 and (item == "apple" or item == "apples" or item == "banana" or item == "bananas") then + if table.contains(hq.char.inv, "some silver coins") then + local fruit = string.gsub(item, "s", "") + echo("You give the vendor some silver coins.\n") + echo(f"The vendor gives you some {fruit}s.\n") + echo("The vendor says, 'Go give that to the Sheriff, and say hello for me.'\n") + echo("The vendor smiles at you.\n") + cecho(hq.prompt) + + table.insert(hq.char.inv, f"some {fruit}s") + table.remove(hq.char.inv, table.index_of(hq.char.inv, "some silver coins")) + + setRoomUserData(5, "onRoomEntry", "The fruit vendor yells, 'Fresh fruit and vegetables for sale!'\n") + clearRoomUserDataItem(5, "onRoomHelp") + + else + echo("The vendor says, 'You don't have enough coins.'\n") + cecho(hq.prompt) + end +else + echo("There is nothing like that to buy here.\n") + cecho(hq.prompt) +end</script> + <command></command> + <packageName></packageName> + <regex>^buy(?: (.+))?$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Inventory</name> + <script>if hq.char.inv and #hq.char.inv > 0 then + echo("You are carrying;\n") + for k, v in pairs(hq.char.inv) do + echo(f" - {v}\n") + end + cecho(hq.prompt) +else + echo("You aren't carrying anything.") + cecho(hq.prompt) +end</script> + <command></command> + <packageName></packageName> + <regex>^(i|inv|inventory)$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Give</name> + <script>if hq.state ~= "PLAYING" then return end + +if getPlayerRoom() == 1 then + local fruit = "" + -- Handle generic terms by checking what fruit is in inventory + if matches[2] == "fruit" or matches[2] == "food" or matches[2] == "lunch" then + if table.contains(hq.char.inv, "some apples") then + fruit = "some apples" + elseif table.contains(hq.char.inv, "some bananas") then + fruit = "some bananas" + else + echo("You aren't carrying any food.\n") + cecho(hq.prompt) + return + end + elseif matches[2] == "apple" or matches[2] == "apples" then + fruit = "some apples" + elseif matches[2] == "banana" or matches[2] == "bananas" then + fruit = "some bananas" + end + + if table.contains(hq.char.inv, fruit) then + echo(f"You give {fruit} to the Sheriff.\n") + echo("The Sheriff says, 'Ah, thank you so much, I'm starving.'\n") + echo(f"The Sheriff scoffs down {fruit}.\n") + table.remove(hq.char.inv, table.index_of(hq.char.inv, fruit)) + clearRoomUserDataItem(1, "onRoomEntry") + echo("The Sheriff says, 'While you were gone the librarian reported she had lost her wedding ring, perhaps you could help her find it?'\n") + echo("The Sheriff says, 'You'll find her at the Library of course, marked with an 'L' on your map.\n") + cecho(hq.prompt) + else + echo("You aren't carrying that.") + if not hq.invHint then + hq.help("To see what items you are carrying use the command <r><b>inventory<r><dark_sea_green> or <r><b>i<r><dark_sea_green> for short.") + hq.invHint = true + end + cecho(hq.prompt) + end + +elseif getPlayerRoom() == 6 and (matches[2] == "ring" or matches[2] == "wedding") then + if table.contains(hq.char.inv, "a wedding ring") then + clearRoomUserDataItem(6, "onRoomEntry") + clearRoomUserDataItem(6, "onRoomHelp") + echo(f"The librarian says, 'Oh, thank you so much {hq.char.name}! I thought it was lost forever. Can you let the Sheriff know you've found it?'\n") + echo("The librarian wipes away a tear and returns to her work.\n") + cecho(hq.prompt) + table.remove(hq.char.inv, table.index_of(hq.char.inv, "a wedding ring")) + hq.ringQuestComplete = true + setRoomUserData(6, "description", "Dusty tomes line towering shelves beneath a vaulted ceiling, where the hush of whispered study and the faint rustle of turning pages echo through the candlelit stillness. A simple woven rug lies on the floor. The librarian smiles as she sees you.\n") + setRoomUserData(6, "mobiles", yajl.to_string({ librarian = "The librarian smiles as she sees you.\n" })) + setRoomUserData(1, "onRoomEntry", f"The Sheriff says, 'Congratulations on finding the wedding ring! You're becoming quite useful around here {hq.char.name}, but alas I am out of jobs for now.'\n") + setRoomUserData(1, "onRoomHelp", "This ends the mini-quest for Mudlet. You've learnt some MUD basics while helping out the citizens of Whitehaven. Feel free to wander around town or use the <r><b>quit<r><dark_sea_green> command to finish up.") + else + echo("You aren't carrying that.\n") + cecho(hq.prompt) + end +end</script> + <command></command> + <packageName></packageName> + <regex>^give (?:the |some |an? )?(apple|apples|banana|bananas|ring|wedding|fruit|food|lunch)(?: (.+))?$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Get</name> + <script>if hq.state ~= "PLAYING" then return end + +if getPlayerRoom() == 6 and not hq.ringQuestRingFound then + table.insert(hq.char.inv, "a wedding ring") + echo("You get a wedding ring.\n") + hq.help("Remember you can check your inventory to see what you are carrying.\n") + cecho(hq.prompt) + hq.ringQuestRingFound = true + setRoomUserData(6, "description", "Dusty tomes line towering shelves beneath a vaulted ceiling, where the hush of whispered study and the faint rustle of turning pages echo through the candlelit stillness. A simple woven rug lies on the floor. The librarian waits patiently for you to give her the ring.\n") + setRoomUserData(6, "mobiles", yajl.to_string({ librarian = "The librarian waits patiently for you to give her the ring.\n" })) + setRoomUserData(6, "items", yajl.to_string({ shelves = "You search the shelves for the ring, but fail to find anything.\n", + shelf = "You search the shelves for the ring, but fail to find anything.\n", + rug = "A simple woven rug lies on the floor.\n", + tomes = "Books, books, old and new. Too many to search through.\n" })) + +else + cecho("You don't see that here.\n") + cecho(hq.prompt) +end</script> + <command></command> + <packageName></packageName> + <regex>^(?:get|take|grab|pick up|pickup|collect) (?:the |a |an )?(?:wedding )?ring(?: .+)?$</regex> + </Alias> + <Alias isActive="yes" isFolder="no"> + <name>Quit</name> + <script>hq.help("Thank you for playing. You are now ready to step into a real MUD to expand on this knowledge further. Mudlet provides a collection of high quality games for you to try out and plenty more can be found through an internet search.") +hq.help("While this tutorial was quite basic, Mudlet can be very powerful and has some advanced features. We recommend reading the introduction on our wiki - it is designed for players new to Mudlet and introduces some more advanced topics.\n") +cechoLink("<deep_sky_blue><u>http://wiki.mudlet.org/Introduction<r>\n", function() openWebPage("https://wiki.mudlet.org/w/Manual:Introduction") end, "Click to open link.", true) +hq.help("Using the toolbar above, go to Games -> Close Profile (Alt+W) to return to the Mudlet start screen.\n")</script> + <command></command> + <packageName></packageName> + <regex>^quit$</regex> + </Alias> + </AliasGroup> + </AliasPackage> + <ActionPackage /> + <ScriptPackage> + <ScriptGroup isActive="yes" isFolder="yes"> + <name>Mini Quest</name> + <packageName></packageName> + <script>uninstallPackage("generic_mapper") + +hq = hq or {} + +clearWindow() +closeMapWidget()</script> + <eventHandlerList /> + <Script isActive="yes" isFolder="no"> + <name>Mini-Game Intro</name> + <packageName></packageName> + <script>hq.prompt = "<yellow>-> <r>" + +function hq.parser(event, command) + + hq.debug("Parsing command: " .. command) + + if hq.state == "GET_CHAR_NAME" then + + if string.len(command) == 0 then + echo("Your name cannot be blank.\n") + cecho(f"What is your name, Mudleteer?\n{hq.prompt}") + return + end + + hq.char = {} + hq.char.name = string.title(command) + + hq.state = "INTRODUCTION" + + echo(f"\nWelcome {hq.char.name}. You have passed the Adventurer's Guild test and set out to stake your claim as a hero.\n") + tempTimer(2, function() echo("\nYou pass through mountain high and valley low, braving rain and the bitter, cold snow.\n") end) + tempTimer(4, function() echo("\nAs you wander into a small village you notice a sign tacked to the gates.\n") end) + tempTimer(6, function() echo("\n\"Assistant Wanted for the Hamlet of Whitehaven. Apply to Sheriff.\"\n") end) + tempTimer(8, function() echo("\nAh, your first true test as a new adventurer! You step through the gates to seek out the sheriff.\n") end) + tempTimer(12, function() echo("\nAs luck would have it you find him immediately and introduce yourself.\n") end) + tempTimer(14, function() echo(f("\nThe Sheriff says, 'Well met {hq.char.name}. I'm looking for an assistant. Think you can handle the job?'\n")) end) + tempTimer(16, function() echo("\nBefore you can respond he shoves a map into your hands.\n") end) + tempTimer(20, function() echo("\nThe Sheriff says, 'Here is a map of the village to help you get aquainted. Welcome aboard!'\n") end) + + tempTimer(22, hq.initMap) + tempTimer(22, hq.showLayout) + tempTimer(22, function() hq.state = "PLAYING" end) + + tempTimer(24, function() hq.help("Mudlet provides a built-in map to help you explore. Some games provide maps to help you learn, some don't to encourage exploration but you can always make your own.\n") end) + tempTimer(25, function() echo("The Sheriff says, 'As your first task I need you to go buy me some lunch from the fruit vendor to the north. It is marked with a 'F' on your map.'\n") end) + tempTimer(25, function() + echo("The Sheriff gives you some coins.\n") + hq.char.inv = {} + table.insert(hq.char.inv, "some silver coins") + end) + + tempTimer(26, function() + hq.help("MUDs are divided into different 'rooms' where each represents a location on the map (the red squares). It could be a house, a section of road, or part of a forest. MUDs allow movement betweens these rooms by typing commands like; <r><b>north<r><dark_sea_green> or <r><b>west<r><dark_sea_green>, <r><b>in<r><dark_sea_green> or <r><b>out<r><dark_sea_green>. See if you can navigate to the fruit vendor ('F') using the commands and following the map.\n\nHINT: it is two rooms north of your current location which is marked with the red circle.") + cecho(hq.prompt) + end) + + + end + + if hq.state == "WALKING" then + denyCurrentSend() + echo("You are already walking.\n") + end + + if hq.state == "PLAYING" then + if string.len(command) == 0 then + echo("You need to type in a command before pressing enter.\n") + cecho(hq.prompt) + else + local suggestion = hq.suggestCommand(command) + if suggestion == "COMMANDS_LIST" then + hq.showCommands() + cecho(hq.prompt) + elseif suggestion then + hq.help(suggestion) + cecho(hq.prompt) + else + echo("I don't understand. Type help for available commands.\n") + cecho(hq.prompt) + end + end + + return + end + +end + +registerNamedEventHandler("mudlet-tutorial", "parser", "sysDataSendRequest", hq.parser, false)</script> + <eventHandlerList /> + </Script> + <Script isActive="yes" isFolder="no"> + <name>Misc Functions</name> + <packageName></packageName> + <script>function hq.debug(str) + + if hq.debugMode == true then + cecho(f("\n<cyan>DEBUG: {str}<reset>\n")) + end + +end + +function hq.help(str) + cecho(f"\n<green>[ HELP ] <dark_sea_green>{str}<r>\n") +end + +function hq.showCommands() + cecho("\n<green>[ HELP ] <dark_sea_green>Available commands:<r>\n") + echo(" north, south, east, west, up, down - Move (or n, s, e, w, u, d)\n") + echo(" look - Look at your surroundings\n") + echo(" look/examine <object> - Examine something or someone\n") + echo(" say <message> - Say something\n") + echo(" open <direction> - Open a door\n") + echo(" close <direction> - Close a door\n") + echo(" get <item> - Pick up an item\n") + echo(" give <item> - Give an item to someone\n") + echo(" buy <item> - Buy an item\n") + echo(" inventory - Check what you're carrying (or i)\n") + echo(" quit - End the tutorial\n") +end + +function hq.suggestCommand(command) + local cmd = command:lower():match("^%s*(.-)%s*$") + if not cmd or cmd == "" then return nil end + + -- Movement verbs: "go north", "walk east", etc. + local dir = cmd:match("^go%s+(.+)$") + or cmd:match("^walk%s+(.+)$") + or cmd:match("^move%s+(.+)$") + or cmd:match("^head%s+(.+)$") + or cmd:match("^run%s+(.+)$") + or cmd:match("^travel%s+(.+)$") + if dir then + local validDirs = {n=true, north=true, s=true, south=true, e=true, east=true, + w=true, west=true, u=true, up=true, d=true, down=true} + dir = dir:gsub("^the ", "") + if validDirs[dir] then + return "In MUDs, you move by typing just the direction. Try: <r><b>" .. dir .. "<r>" + end + end + + -- Talk/speak/tell/ask/greet + target + if cmd:match("^talk%s+to%s+") or cmd:match("^speak%s+to%s+") + or cmd:match("^speak%s+with%s+") or cmd:match("^tell%s+") + or cmd:match("^ask%s+") or cmd:match("^greet%s+") + or cmd:match("^chat%s+to%s+") or cmd:match("^chat%s+with%s+") then + return "To talk, use: <r><b>say <message><r><dark_sea_green> (e.g., <r><b>say hello<r><dark_sea_green>)" + end + + -- Bare talk/speak/chat verbs with no argument + if cmd == "talk" or cmd == "speak" or cmd == "chat" or cmd == "say" then + return "Try: <r><b>say <message><r><dark_sea_green> (e.g., <r><b>say hello<r><dark_sea_green>)" + end + + -- Bare greetings + if cmd == "hello" or cmd == "hi" or cmd == "hey" + or cmd == "howdy" or cmd == "greetings" then + return "To speak in MUDs, use the say command. Try: <r><b>say hello<r>" + end + + -- Read/search/inspect + object -> look + local obj = cmd:match("^read%s+(.+)$") + or cmd:match("^search%s+(.+)$") + or cmd:match("^inspect%s+(.+)$") + or cmd:match("^check%s+(.+)$") + or cmd:match("^study%s+(.+)$") + or cmd:match("^observe%s+(.+)$") + if obj then + obj = obj:gsub("^the ", ""):gsub("^a ", ""):gsub("^an ", "") + return "Try: <r><b>look " .. obj .. "<r>" + end + + -- Bare search (no object, common in library) + if cmd == "search" then + return "Try: <r><b>look<r><dark_sea_green> (or <r><b>look <object><r><dark_sea_green> to examine something specific)" + end + + -- Purchase/order -> buy + local buyItem = cmd:match("^purchase%s+(.+)$") + or cmd:match("^order%s+(.+)$") + if buyItem then + buyItem = buyItem:gsub("^the ", ""):gsub("^some ", ""):gsub("^a ", ""):gsub("^an ", "") + return "Try: <r><b>buy " .. buyItem .. "<r>" + end + + -- Hand/offer/pass -> give + local giveItem = cmd:match("^hand%s+(.+)$") + or cmd:match("^offer%s+(.+)$") + or cmd:match("^pass%s+(.+)$") + or cmd:match("^return%s+(.+)$") + if giveItem then + giveItem = giveItem:gsub("^the ", ""):gsub("^a ", ""):gsub("^an ", "") + return "Try: <r><b>give " .. giveItem .. "<r>" + end + + -- Unlock/knock -> open + local door = cmd:match("^unlock%s+(.+)$") + or cmd:match("^knock%s+on%s+(.+)$") + or cmd:match("^knock%s+(.+)$") + if door then + door = door:gsub("^the ", ""):gsub("door", ""):gsub("^%s+", ""):gsub("%s+$", "") + if door ~= "" then + return "Try: <r><b>open " .. door .. "<r>" + else + return "Try: <r><b>open <direction><r><dark_sea_green> (e.g., <r><b>open north<r><dark_sea_green>)" + end + end + + -- Inventory synonyms + if cmd == "items" or cmd == "bag" or cmd == "bags" + or cmd == "equipment" or cmd == "eq" or cmd == "backpack" + or cmd == "pockets" or cmd == "carrying" or cmd == "stuff" then + return "Try: <r><b>inventory<r><dark_sea_green> (or just: <r><b>i<r><dark_sea_green>)" + end + + -- Lift/flip/pull/push/turn + object -> look + local moveObj = cmd:match("^lift%s+(.+)$") + or cmd:match("^flip%s+(.+)$") + or cmd:match("^pull%s+(.+)$") + or cmd:match("^push%s+(.+)$") + or cmd:match("^turn%s+(.+)$") + or cmd:match("^pick up%s+(.+)$") + or cmd:match("^pickup%s+(.+)$") + if moveObj then + moveObj = moveObj:gsub("^the ", ""):gsub("^a ", ""):gsub("^an ", "") + return "Try: <r><b>look " .. moveObj .. "<r>" + end + + -- Help/commands + if cmd == "help" or cmd == "commands" or cmd == "?" + or cmd == "hint" or cmd == "hints" or cmd == "info" + or cmd:match("^help%s") then + return "COMMANDS_LIST" + end + + -- Exit/leave synonyms + if cmd == "exit" or cmd == "leave" or cmd == "q" + or cmd == "logout" or cmd == "logoff" or cmd == "bye" + or cmd == "end" or cmd == "stop" or cmd == "finish" then + return "Try: <r><b>quit<r>" + end + + -- Common MUD verbs not available in tutorial + if cmd:match("^kill") or cmd:match("^attack") or cmd:match("^fight") + or cmd:match("^hit ") or cmd:match("^cast") or cmd:match("^wield") + or cmd:match("^wear") or cmd:match("^remove%s") + or cmd:match("^eat") or cmd:match("^drink") + or cmd:match("^drop") or cmd:match("^throw") + or cmd:match("^sleep") or cmd:match("^wake") or cmd:match("^rest$") + or cmd:match("^sit$") or cmd:match("^stand$") + or cmd == "score" or cmd == "who" or cmd == "map" + or cmd == "stats" or cmd == "status" + or cmd:match("^whisper") or cmd:match("^shout") or cmd:match("^yell") then + return "That command isn't available in this tutorial. Type <r><b>help<r><dark_sea_green> for available commands." + end + + return nil +end</script> + <eventHandlerList /> + </Script> + <Script isActive="yes" isFolder="no"> + <name>GUI Tutorial</name> + <packageName></packageName> + <script>hq.fgColor = "white" +hq.bgColor = "grey" +hq.fontSize = 14 +hq.labelCSS = [[ margin: 10px; background-color: black; border: 5px solid white; ]] + +function hq.guiTutorialStart() + + Geyser.hideAll() + + hq.mainMenuContainer = Geyser.Container:new({ + name = "hq.mainMenuContainer", + x = "25%", y = "25%", + width = "50%", height = "50%" + }) + + hq.mainMenuLabel = Geyser.Label:new({ + name = "hq.mainMenuLabel", + x = "0%", y = "0%", + width = "100%", height = "100%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[<center> + <p>Welcome Adventurer! This is the Mudlet Tutorial and mini-quest.</p> + + This tutorial will coach you through learning how to play<br> + MUDs (Multi-User Dungeons) while exploring some of Mudlet's<br> + basic features.<br> + + <p>Click next to continue.<p> + <p>(next)</p></center>]] + + }, hq.mainMenuContainer) + hq.mainMenuLabel:setClickCallback(hq.guiTutorialStep1) + hq.mainMenuLabel:setStyleSheet(hq.labelCSS) + + tempTimer(0.2, function() clearWindow() end) + +end + +function hq.guiTutorialStep1() + + Geyser.hideAll() + + hq.guiStep1Container = Geyser.Container:new({ + name = "hq.guiStep1Container", + x = "0%", y = "0%", + width = "100%", height = "100%" + }) + + hq.guiStep1Label = Geyser.Label:new({ + name = "hq.guiStep1Label", + x = "25%", y = "25%", + width = "50%", height = "50%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[ + <p>This large area is called the 'main window'.</p> + It displays text coming from the game such as;<br> + - your location in the game world,<br> + - chat messages from other players,<br> + - progress and score,<br> + - and lots more depending on the game.<br> + + <p>(next)</p>]] + }, hq.guiStep1Container) + hq.guiStep1Label:setClickCallback(hq.guiTutorialStep2) + hq.guiStep1Label:setStyleSheet(hq.labelCSS) + + tempTimer(1, function() hq.guiStep1Container:flash(0.2) end) + tempTimer(1.5, function() hq.guiStep1Container:flash(0.2) end) + tempTimer(2, function() hq.guiStep1Container:flash(0.2) end) + + tempTimer(3, function() echo("This is the main window text.\n") end) + tempTimer(4, function() echo("Anything coming from the game will show up here.\n") end) + tempTimer(5, function() cecho("<cyan>It may also show in colour if your game supports this :)<r>\n") end) +-- tempTimer(6, function() echo("New game text will display on the next line and once it reaches the bottom\n") end) +-- tempTimer(7, function() echo("it will scroll up - just like a live text document.\n") end) +-- tempTimer(8, function() echo("You can use your mouse to scroll up and down to read anything you missed.\n") end) + +end + +function hq.guiTutorialStep2() + + Geyser.hideAll() + + hq.guiStep2Container = Geyser.Container:new({ + name = "hq.guiStep2Container", + x = "0%", y = "0%", + width = "100%", height = "100%" + }) + + hq.guiStep2Label = Geyser.Label:new({ + name = "hq.guiStep2Label", + x = "5%", y = "80%", + width = "45%", height = "20%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[ + <p>The area below is called the 'command line'.</p> + <p>It is used for sending commands via your keyboard<br> + to the game to control your character. (next)</p>]] + }, hq.guiStep2Container) + hq.guiStep2Label:setClickCallback(hq.guiTutorialStep3) + hq.guiStep2Label:setStyleSheet(hq.labelCSS) + + tempTimer(1, function() setCmdLineStyleSheet("main", [[ QPlainTextEdit { background-color: grey; }]]) end) + tempTimer(1.2, function() setCmdLineStyleSheet("main", [[ QPlainTextEdit { background-color: black; }]]) end) + tempTimer(1.5, function() setCmdLineStyleSheet("main", [[ QPlainTextEdit { background-color: grey; }]]) end) + tempTimer(1.7, function() setCmdLineStyleSheet("main", [[ QPlainTextEdit { background-color: black; }]]) end) + tempTimer(2, function() setCmdLineStyleSheet("main", [[ QPlainTextEdit { background-color: grey; }]]) end) + tempTimer(2.2, function() setCmdLineStyleSheet("main", [[ QPlainTextEdit { background-color: black; }]]) end) + +end + +function hq.guiTutorialStep3() + + Geyser.hideAll() + + hq.guiStep3Container = Geyser.Container:new({ + name = "hq.guiStep3Container", + x = "0%", y = "0%", + width = "100%", height = "100%" + }) + + hq.guiStep3Label = Geyser.Label:new({ + name = "hq.guiStep3Label", + x = "50%", y = "80%", + width = "45%", height = "20%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[ + <p>The below buttons are used for searching,<br> + showing of timestamps and logging facilities.</p> + + <p>We can learn more about them later. (next)</p>]] + }, hq.guiStep3Container) + hq.guiStep3Label:setClickCallback(hq.guiTutorialStep4) + hq.guiStep3Label:setStyleSheet(f"{hq.labelCSS} qproperty-alignment: AlignRight;") + +end + +function hq.guiTutorialStep4() + + Geyser.hideAll() + + hq.guiStep4Container = Geyser.Container:new({ + name = "hq.guiStep4Container", + x = "0%", y = "0%", + width = "100%", height = "100%" + }) + + hq.guiStep4Label = Geyser.Label:new({ + name = "hq.guiStep4Label", + x = "5%", y = "0%", + width = "45%", height = "20%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[<p> + The above menu bar contains options to connect and<br> + disconnect from your game, preferences and other tools.</p> + + <p>We can learn more about them later. (next)</p>]] + }, hq.guiStep4Container) + hq.guiStep4Label:setClickCallback(hq.guiTutorialStepFinal) + hq.guiStep4Label:setStyleSheet(hq.labelCSS) + +end + +function hq.guiTutorialStepFinal() + + Geyser.hideAll() + clearWindow() + + hq.guiStepFinalContainer = Geyser.Container:new({ + name = "hq.guiStepFinalContainer", + x = "25%", y = "15%", + width = "50%", height = "70%" + }) + + hq.guiStepFinalLabel = Geyser.Label:new({ + name = "hq.guiStepFinalLabel", + x = "0%", y = "0%", + width = "100%", height = "100%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[<center> + <p>That covers the basics of Mudlet's screen. More in depth<br> + information can be found on the wiki at https://wiki.mudlet.org</p> + <p>Let's move on to the fun stuff, playing a MUD!</p> + + <p>During this tutorial you will play the role of a young<br> + adventurer who has just arrived in the Halmet of Whitehaven where<br> + the Sheriff is looking for help, hopefully you can assist him.</p> + + <p>You will need to complete his requests to gain experience<br> + and be rewarded with fame and respect from the townsfolk.</p> + + <p>You will learn how to interact with MUDs during this<br> + simulation by waiting for the game to send you information<br> + on the main window and answering with commands from the<br> + command line. Good luck!</p> + + <p>Now follow along in the main window. (next)</p></center>]] + }, hq.guiStepFinalContainer) + hq.guiStepFinalLabel:setClickCallback(hq.newGameScreen) + hq.guiStepFinalLabel:setStyleSheet(hq.labelCSS) + +end + +function hq.newGameScreen() + + Geyser.hideAll() + clearWindow() + closeMapWidget() + deleteMap() + + hq.state = "GET_CHAR_NAME" + + echo("Welcome to The Mudlet MUD Tutorial!\n\n") + + hq.help("Information to help you learn will be displayed in green.") + hq.help("Game text will appear as white, just like the question below asking for your name.") + hq.help("Most games will have some sort of prompt to let you know it is ready for your instructions. In this simulation we will use the prompt <yellow>-><r>") + hq.help("Answer the following question on the command line and press enter.\n\n") + + cecho(f"What is your name, Mudleteer?\n{hq.prompt}") + +end</script> + <eventHandlerList /> + </Script> + <Script isActive="yes" isFolder="no"> + <name>Set up Map</name> + <packageName></packageName> + <script>hq.reverseDirs = { ["n"] = "s", + ["s"] = "n", + ["e"] = "w", + ["w"] = "e", + ["u"] = "d", + ["d"] = "u" } + +function doSpeedWalk() + + echo("You point to your map but it fails to magically transport you anywhere.\n") + +end + +function hq.initMapperWindow() + + hq.mapperWindow = Adjustable.Container:new({ + name = "hq.mapperWindow", + x = "65%", y = "5%", + width = "30%", height = "40%", + --buttonFontSize = 12, + --buttonsize = 20, + titleText = "Map", + titleTxtColor = "white", + padding = 10 + }) + + hq.mapper = Geyser.Mapper:new({ + name = "hq.mapper", + x = 0, y = 0, + width = "100%", height = "100%" + }, hq.mapperWindow) + + disableMapInfo("Short") + disableMapInfo("Full") + enableMapInfo("None") + +end + +function hq.initMap() + + --hq.initMapperWindow() + hq.mapperWindow:show() + --openMapWidget() + + -- create map of the village + hq.initVillage() + hq.drawMapHint() + +end + +function hq.drawMapHint() + + hq.mapHintContainer = Geyser.Container:new({ + name = "hq.mapHintContainer", + x = "25%", y = "25%", + width = "50%", height = "50%" + }) + + hq.mapHintLabel = Geyser.Label:new({ + name = "hq.mapHintLabel", + x = "0%", y = "0%", + width = "100%", height = "100%", + fgColor = hq.fgColor, + fontSize = hq.fontSize, + color = hq.bgColor, + message = [[<center> + <p>The map window is shown in the upper right corner.</p> + + <p>Like other windows it can be moved and resized by<br> + mousing over the edges.<br> + Click and hold inside the map window to pan.<br> + The mouse wheel is used to zoom in and out.</p> + + <p>(next)</p></center>]] + + }, hq.mapHintContainer) + hq.mapHintLabel:setClickCallback(function() hq.mapHintContainer:hide() end) + hq.mapHintLabel:setStyleSheet(hq.labelCSS) + tempTimer(1, function() hq.mapperWindow:flash(0.2) end) + tempTimer(1.5, function() hq.mapperWindow:flash(0.2) end) + tempTimer(2, function() hq.mapperWindow:flash(0.2) end) + +end + +function hq.initVillage() + + deleteMap() + + local hamletAreaID = addAreaName("Whitehaven Hamlet") or 1 + + hq.debug("new area ID = " .. hamletAreaID) + + hq.addRoom("Inside the Gates of Whitehaven", 1, hamletAreaID, 0, 0, 0) + setRoomUserData(1, "description", "You stand beside the weathered gates of a modest town, where dusty roads meet wary eyes and quiet promise. The Sheriff stands nearby.\n") + + hq.addRoom("Outside the Tavern", 2, hamletAreaID, -1, 0, 0) + setRoomUserData(2, "description", "Laughter and the clink of mugs spill from the tavern’s heavy wooden door, while the scent of ale and roasted meat wafts into the lantern-lit street.") + hq.addRoom("Outside the Fruit & Vegetable Shoppe", 3, hamletAreaID, 0, 1, 0) + setRoomUserData(3, "description", "The scent of ripe produce drifts from the open-air shoppe to the north, where crates of colorful fruits and vegetables spill onto the cobbled street in cheerful disarray.\n") + + hq.addRoom("Outside The Library", 4, hamletAreaID, -1, 1, 0) + setRoomUserData(4, "description", "Tall stone columns frame the entrance to the quiet library, where the scent of old parchment drifts through the air and a carved sign creaks gently in the breeze.\n") + + hq.addRoom("The Fruit & Vegetable Shoppe", 5, hamletAreaID, 0, 2, 0, "F") + setRoomUserData(5, "description", "Wooden shelves brim with vibrant produce, their earthy scents mingling in the cozy air as a cheerful vendor chats with passersby beneath hanging bundles of herbs. There is a small sign here.\n") + + hq.addRoom("The Library", 6, hamletAreaID, -1, 2, 0, "L") + setRoomUserData(6, "description", "Dusty tomes line towering shelves beneath a vaulted ceiling, where the hush of whispered study and the faint rustle of turning pages echo through the candlelit stillness. A simple woven rug lies on the floor. The librarian is here searching frantically for something.\n") + + hq.addRoom("Outside The Inn", 7, hamletAreaID, -2, 1, 0) + setRoomUserData(7, "description", "A flickering lantern casts a warm glow over the inn’s timbered facade, where weary travelers pause beneath the signboard swinging gently in the evening breeze.") + + hq.addRoom("The Tavern", 8, hamletAreaID, -1, -1, 0, "T") + setRoomUserData(8, "description", "The tavern hums with warmth and revelry, its smoky air thick with laughter, clinking mugs, and the scent of spiced stew simmering over the hearth.") + + hq.addRoom("The Inn", 9, hamletAreaID, -2, 2, 0, "I") + setRoomUserData(9, "description", "Soft candlelight flickers across polished wood and worn rugs, as quiet conversation and the scent of fresh bread fill the air of this welcoming refuge for weary travelers.") + + hq.addRoom("A Road in the Halmet", 10, hamletAreaID, -2, 0, 0) + setRoomUserData(10, "description", "A narrow dirt road winds between humble cottages and overgrown gardens, where chickens peck at the dust and the scent of woodsmoke lingers in the quiet air.") + + hq.addRoom("A Road in the Halmet", 12, hamletAreaID, -3, 0, 0) + setRoomUserData(12, "description", "A modest wooden door stands beneath a sloped roof, flanked by flowerpots and a worn path that hints at years of quiet comings and goings.") + + hq.addRoom("Inside a House", 13, hamletAreaID, -3, -1, 0) + setRoomUserData(13, "description", "The cozy interior is filled with the scent of hearthfire and aged wood, where simple furnishings and personal trinkets speak of quiet lives and long-forgotten stories.") + + hq.linkRoom(1, 2, "w") + hq.linkRoom(1, 3, "n") + hq.linkRoom(3, 4, "w") + hq.linkRoom(3, 5, "n") + hq.linkRoom(4, 6, "n", true) + hq.linkRoom(4, 7, "w") + hq.linkRoom(2, 8, "s", true) + hq.linkRoom(7, 9, "n", true) + hq.linkRoom(2, 10, "w") + hq.linkRoom(10, 12, "w") + hq.linkRoom(12, 13, "s", true, true) + + + -- the sheriff + setRoomUserData(1, "mobiles", yajl.to_string({ sheriff = "The Sheriff stands tall and proud, looking over the village of Whitehaven.\n" })) + setRoomUserData(1, "onRoomEntry", "The Sheriff says, 'Did you find me something for lunch?'\n") + + -- the fruit vendor + setRoomUserData(5, "mobiles", yajl.to_string({ vendor = "A vendor stands here waiting patiently to sell you fruit.\n" })) + setRoomUserData(5, "items", yajl.to_string({ sign = "Apples - 5 silver\nBananas - 10 silver\nTo purchase an item simply type <b>buy apple<r> or <b>buy banana<r>\n" })) + setRoomUserData(5, "onRoomEntry", "The fruit vendor says, 'Fresh fruit and vegetables for sale.'\nThe fruit vendor says, 'Oh, you must be new in town. Let me guess; the Sheriff forgot his lunch again?'\nThe vendor points to the sign.\n") + setRoomUserData(5, "onRoomHelp", "MUDs allow interaction with the game world through various commands using verbs like <r><b>look<r><dark_sea_green>, <r><b>say<r><dark_sea_green>, <r><b>buy<r><dark_sea_green> or <r><b>give<r><dark_sea_green>.\nHINT: Try looking at the sign and the vendor.\n") + setRoomUserData(5, "onRoomExit", "The fruit vendor says, 'See you next time.'\n") + + -- the librarian + setRoomUserData(6, "mobiles", yajl.to_string({ librarian = "The librarian looks to be in a frantic state, searching high and low for something.\n" })) + setRoomUserData(6, "items", yajl.to_string({ shelves = "You search the shelves for the ring, but fail to find anything.\n", + shelf = "You search the shelves for the ring, but fail to find anything.\n", + rug = "You lift the rug and peer underneath. Huzzah! You found the wedding ring!\n", + tomes = "Books, books, old and new. Too many to search through.\n" })) + setRoomUserData(6, "onRoomEntry", "The librarian says, 'Oh, please you must help me. I've lost my wedding ring! Can you help me find it by taking a look around?'\n") + setRoomUserData(6, "onRoomHelp", "HINT: Search the room using the look command on various objects. If you can find it use the <r><b>get<r><dark_sea_green> command to pick it up.\n") + + setMapZoom(7) + centerview(1) + +end + +function hq.addRoom(name, vnum, area, x, y, z, char) + + addRoom(vnum) + setRoomName(vnum, name) + setRoomArea(vnum, area) + setRoomCoordinates(vnum, x, y, z) + + if char then setRoomChar(vnum, char) end + +end + +function hq.linkRoom(source, destination, direction, door, locked) + + setExit(source, destination, direction) + setExit(destination, source, hq.reverseDirs[direction]) + + if door then + if locked then + setDoor(source, direction, 3) + setDoor(destination, hq.reverseDirs[direction], 3) + else + setDoor(source, direction, 2) + setDoor(destination, hq.reverseDirs[direction], 2) + end + end + +end</script> + <eventHandlerList /> + </Script> + <Script isActive="yes" isFolder="no"> + <name>Room Scripts</name> + <packageName></packageName> + <script>function hq.onRoomEntry() + + local mobiles, items, onRoomEntry, onRoomHelp = getRoomUserData(getPlayerRoom(), "mobiles"), + getRoomUserData(getPlayerRoom(), "items"), + getRoomUserData(getPlayerRoom(), "onRoomEntry"), + getRoomUserData(getPlayerRoom(), "onRoomHelp") + + cecho(f"<cyan>{getRoomName(getPlayerRoom())}<r>\n") + cecho(getRoomUserData(getPlayerRoom(), "description")) + echo("\n") + + if string.len(onRoomEntry) > 0 then + cecho(onRoomEntry) + end + + if string.len(onRoomHelp) > 0 then + hq.help(onRoomHelp) + end + +end + +function hq.onRoomExit() + + local onRoomExit = getRoomUserData(getPlayerRoom(), "onRoomExit") + + if string.len(onRoomExit) > 0 then + cecho(onRoomExit) + end + +end +</script> + <eventHandlerList /> + </Script> + <Script isActive="yes" isFolder="no"> + <name>Init</name> + <packageName></packageName> + <script>function hq.hideLayout() + hq.mapperWindow:hide() +end + +function hq.showLayout() + hq.mapperWindow:show() +end + +local function beginTutorial() + hq.initMapperWindow() + hq.guiTutorialStart() + hq.hideLayout() + clearWindow() +end + +-- when Mudlet's UI tour is about to run, hold the lesson until it finishes +if mudlet and mudlet.uiTourPending then + registerAnonymousEventHandler("sysUiTourFinished", beginTutorial, true) +else + tempTimer(0, beginTutorial) +end</script> + <eventHandlerList /> + </Script> + </ScriptGroup> + </ScriptPackage> + <KeyPackage> + <Key isActive="yes" isFolder="no"> + <name>Reset Profile</name> + <packageName></packageName> + <script>Geyser.hideAll() +resetProfile()</script> + <command></command> + <keyCode>16777275</keyCode> + <keyModifier>0</keyModifier> + </Key> + </KeyPackage> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/packages/mudlet-tutorial/config.lua b/src/packages/mudlet-tutorial/config.lua new file mode 100644 index 000000000..2c48fe778 --- /dev/null +++ b/src/packages/mudlet-tutorial/config.lua @@ -0,0 +1,21 @@ +mpackage = [[Mudlet Tutorial]] +author = [[Zooka]] +icon = [[mudlet.png]] +title = [[An offline tutorial and mini-game for Mudlet.]] +description = [[ +### Description + +Mudlet provides a simple tutorial to help you get familiar with playing MUDs using Mudlet. + +You play a young adventurer who has recently graduated from the adventuring school. You must aid the Sheriff in a series of tasks for the townfolk and become a local hero. + +### Usage + +After installing, just following along with the onscreen commands. + +### See Also + +* https://mudlet.org +* https://wiki.mudlet.org]] +version = [[3]] +created = "2025-09-07T10:32:00+07:00" diff --git a/src/mudlet-tutorial.mpackage b/src/packages/mudlet-tutorial/mudlet-tutorial.mpackage similarity index 100% rename from src/mudlet-tutorial.mpackage rename to src/packages/mudlet-tutorial/mudlet-tutorial.mpackage diff --git a/src/packages/run-lua-code/config.lua b/src/packages/run-lua-code/config.lua new file mode 100644 index 000000000..de9dbf495 --- /dev/null +++ b/src/packages/run-lua-code/config.lua @@ -0,0 +1,18 @@ +mpackage = [[run-lua-code]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[Run Lua code directly from the command line.]] +description = [[# run-lua-code + +A simple package that provides a `lua` alias that allows the user to +run Lua code from the command line. + +``` +-- examples +> lua echo("Lua from the command line") -- runs the Lua echo function displaying text on the main screen +> lua send("look") -- send the command 'look' to the game server +> lua showColors() -- display a color palette +``` +]] +version = [[5]] +created = "2024-08-27T05:32:00+02:00" diff --git a/src/run-lua-code.mpackage b/src/packages/run-lua-code/run-lua-code.mpackage similarity index 100% rename from src/run-lua-code.mpackage rename to src/packages/run-lua-code/run-lua-code.mpackage diff --git a/src/packages/run-lua-code/run-lua-code.xml b/src/packages/run-lua-code/run-lua-code.xml new file mode 100644 index 000000000..6c225e9c3 --- /dev/null +++ b/src/packages/run-lua-code/run-lua-code.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> + <TriggerPackage /> + <TimerPackage /> + <AliasPackage> + <Alias isActive="yes" isFolder="no"> + <name>run lua code</name> + <script>local f, e = loadstring("return "..matches[2]) +if not f then + f, e = assert(loadstring(matches[2])) +end + +local r = + function(...) + if not table.is_empty({...}) then + display(...) + end + end +r(f())</script> + <command></command> + <packageName></packageName> + <regex>^lua (.*)$</regex> + </Alias> + </AliasPackage> + <ActionPackage /> + <ScriptPackage /> + <KeyPackage /> + <VariablePackage> + <HiddenVariables /> + </VariablePackage> +</MudletPackage> diff --git a/src/packages/run-tests/config.lua b/src/packages/run-tests/config.lua new file mode 100644 index 000000000..7da1a869e --- /dev/null +++ b/src/packages/run-tests/config.lua @@ -0,0 +1,17 @@ +mpackage = [[run-tests]] +author = [[Mudlet Default Package]] +icon = [[mudlet.png]] +title = [[A unit test framework for Mudlet, using Busted.]] +description = [[### Description + +A unit test framework for Mudlet, using Busted. All spec files can be found in the Mudlet source. + +### Usage + +See the README in the package. + +### See Also + +* [Busted homepage](https://lunarmodules.github.io/busted/)]] +version = [[1]] +created = "2025-01-19T14:05:54+04:00" diff --git a/src/run-tests.mpackage b/src/packages/run-tests/run-tests.mpackage similarity index 93% rename from src/run-tests.mpackage rename to src/packages/run-tests/run-tests.mpackage index e594273a1..685710145 100644 Binary files a/src/run-tests.mpackage and b/src/packages/run-tests/run-tests.mpackage differ diff --git a/src/run-tests.xml b/src/packages/run-tests/run-tests.xml similarity index 100% rename from src/run-tests.xml rename to src/packages/run-tests/run-tests.xml diff --git a/src/run-lua-code.xml b/src/run-lua-code.xml deleted file mode 100644 index 4a4d58684..000000000 --- a/src/run-lua-code.xml +++ /dev/null @@ -1,32 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE MudletPackage> -<MudletPackage version="1.001"> - <TriggerPackage/> - <TimerPackage/> - <AliasPackage> - <Alias isActive="yes" isFolder="no"> - <name>run lua code</name> - <script>local f, e = loadstring("return "..matches[2]) -if not f then - f, e = assert(loadstring(matches[2])) -end - -local r = - function(...) - if not table.is_empty({...}) then - display(...) - end - end -r(f())</script> - <command></command> - <packageName></packageName> - <regex>^lua (.*)$</regex> - </Alias> - </AliasPackage> - <ActionPackage/> - <ScriptPackage/> - <KeyPackage/> - <HelpPackage> - <helpURL/> - </HelpPackage> -</MudletPackage> diff --git a/src/ui/actions_main_area.ui b/src/ui/actions_main_area.ui index 4d9a34c96..0b8ab41a6 100644 --- a/src/ui/actions_main_area.ui +++ b/src/ui/actions_main_area.ui @@ -7,7 +7,7 @@ <x>0</x> <y>0</y> <width>625</width> - <height>300</height> + <height>322</height> </rect> </property> <property name="sizePolicy"> @@ -22,7 +22,7 @@ <height>0</height> </size> </property> - <layout class="QVBoxLayout" name="verticalLayout_actions_main_area" stretch="0,0,1,0"> + <layout class="QVBoxLayout" name="verticalLayout_actions_main_area" stretch="0,0,1"> <property name="leftMargin"> <number>2</number> </property> @@ -64,6 +64,9 @@ <property name="alignment"> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> </property> + <property name="buddy"> + <cstring>lineEdit_action_name</cstring> + </property> </widget> </item> <item> @@ -174,7 +177,7 @@ </sizepolicy> </property> <property name="text"> - <string>Number of columns/rows (depending on orientation):</string> + <string>Number of rows:</string> </property> <property name="alignment"> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> @@ -187,7 +190,30 @@ <item row="0" column="1"> <widget class="QSpinBox" name="spinBox_action_bar_columns"/> </item> - <item row="1" column="0" colspan="2"> + <item row="1" column="0"> + <widget class="QLabel" name="label_action_bar_offsetToFirstButton"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Offset of first button:</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <property name="buddy"> + <cstring>spinBox_action_bar_offsetToFirstButton</cstring> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QSpinBox" name="spinBox_action_bar_offsetToFirstButton"> + <property name="enabled"> + <bool>false</bool> + </property> + </widget> + </item> + <item row="2" column="0" colspan="2"> <widget class="QComboBox" name="comboBox_action_bar_orientation"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> @@ -207,7 +233,7 @@ </item> </widget> </item> - <item row="2" column="0" colspan="2"> + <item row="3" column="0" colspan="2"> <widget class="QComboBox" name="comboBox_action_bar_location"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Fixed"> @@ -266,6 +292,9 @@ <property name="alignment"> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> </property> + <property name="buddy"> + <cstring>comboBox_action_button_rotation</cstring> + </property> </widget> </item> <item row="0" column="1"> @@ -308,6 +337,9 @@ <property name="alignment"> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> </property> + <property name="buddy"> + <cstring>lineEdit_action_button_command_down</cstring> + </property> </widget> </item> <item row="2" column="1"> @@ -328,6 +360,9 @@ <property name="alignment"> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> </property> + <property name="buddy"> + <cstring>lineEdit_action_button_command_up</cstring> + </property> </widget> </item> <item row="3" column="1"> @@ -340,6 +375,32 @@ </property> </widget> </item> + <item row="4" column="0"> + <widget class="QLabel" name="label_action_icon"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="text"> + <string>Icon</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + <property name="buddy"> + <cstring>lineEdit_action_icon</cstring> + </property> + </widget> + </item> + <item row="4" column="1"> + <widget class="QLineEdit" name="lineEdit_action_icon"> + <property name="enabled"> + <bool>false</bool> + </property> + <property name="cursor"> + <cursorShape>ForbiddenCursor</cursorShape> + </property> + </widget> + </item> </layout> </widget> </item> @@ -403,25 +464,24 @@ </layout> </widget> </item> - <item> - <widget class="QLineEdit" name="lineEdit_action_icon"> - <property name="enabled"> - <bool>false</bool> - </property> - <property name="maximumSize"> - <size> - <width>0</width> - <height>0</height> - </size> - </property> - </widget> - </item> </layout> - <zorder>lineEdit_action_icon</zorder> <zorder>widget_top</zorder> <zorder>groupBox_css</zorder> <zorder>widget_middle</zorder> </widget> + <tabstops> + <tabstop>lineEdit_action_name</tabstop> + <tabstop>spinBox_action_bar_columns</tabstop> + <tabstop>spinBox_action_bar_offsetToFirstButton</tabstop> + <tabstop>comboBox_action_bar_orientation</tabstop> + <tabstop>comboBox_action_bar_location</tabstop> + <tabstop>comboBox_action_button_rotation</tabstop> + <tabstop>checkBox_action_button_isPushDown</tabstop> + <tabstop>lineEdit_action_button_command_down</tabstop> + <tabstop>lineEdit_action_button_command_up</tabstop> + <tabstop>lineEdit_action_icon</tabstop> + <tabstop>plainTextEdit_action_css</tabstop> + </tabstops> <resources/> <connections/> </ui> diff --git a/src/ui/connection_profiles.ui b/src/ui/connection_profiles.ui index 38edb2246..15b8ee660 100644 --- a/src/ui/connection_profiles.ui +++ b/src/ui/connection_profiles.ui @@ -101,20 +101,27 @@ <number>0</number> </property> <item> - <widget class="QListWidget" name="listWidget_profiles"> - <property name="minimumSize"> - <size> - <width>365</width> - <height>0</height> - </size> + <layout class="QVBoxLayout" name="verticalLayout_gamesList"> + <property name="spacing"> + <number>0</number> </property> - <property name="accessibleName"> - <string>profiles list</string> - </property> - <property name="resizeMode"> - <enum>QListView::Adjust</enum> - </property> - </widget> + <item> + <widget class="QListWidget" name="listWidget_profiles"> + <property name="minimumSize"> + <size> + <width>365</width> + <height>0</height> + </size> + </property> + <property name="accessibleName"> + <string>profiles list</string> + </property> + <property name="resizeMode"> + <enum>QListView::Adjust</enum> + </property> + </widget> + </item> + </layout> </item> <item> <widget class="QFrame" name="notificationArea"> diff --git a/src/ui/dlgPackageExporter.ui b/src/ui/dlgPackageExporter.ui index eac7c264d..0c757b3a4 100644 --- a/src/ui/dlgPackageExporter.ui +++ b/src/ui/dlgPackageExporter.ui @@ -360,6 +360,29 @@ Further reading material. e.g. a link to the Mudlet wiki, forums, Github package </widget> </item> <item row="5" column="0"> + <widget class="QLabel" name="label_helpUrl"> + <property name="toolTip"> + <string>Webpage where users can find help for this package. It is shown by the "Module Help" button in the Module Manager.</string> + </property> + <property name="text"> + <string>Help URL</string> + </property> + <property name="buddy"> + <cstring>lineEdit_helpUrl</cstring> + </property> + </widget> + </item> + <item row="5" column="1"> + <widget class="QLineEdit" name="lineEdit_helpUrl"> + <property name="toolTip"> + <string>Webpage where users can find help for this package. It is shown by the "Module Help" button in the Module Manager.</string> + </property> + <property name="placeholderText"> + <string notr="true">https://...</string> + </property> + </widget> + </item> + <item row="6" column="0"> <widget class="QLabel" name="label_requiredPackages"> <property name="toolTip"> <string>Does this package make use of other packages? List them here as requirements.</string> @@ -372,7 +395,7 @@ Further reading material. e.g. a link to the Mudlet wiki, forums, Github package </property> </widget> </item> - <item row="5" column="1"> + <item row="6" column="1"> <widget class="QWidget" name="widget_requiredPackages" native="true"> <property name="sizePolicy"> <sizepolicy hsizetype="Expanding" vsizetype="Preferred"> diff --git a/src/ui/profile_preferences.ui b/src/ui/profile_preferences.ui index e079fc822..422c4be76 100644 --- a/src/ui/profile_preferences.ui +++ b/src/ui/profile_preferences.ui @@ -369,6 +369,19 @@ </property> </widget> </item> + <item row="2" column="0" colspan="2"> + <widget class="QCheckBox" name="checkBox_enableOSC8Hyperlinks"> + <property name="toolTip"> + <string><p>OSC 8 lets a game server put clickable links in its output, which can send commands, pre-fill your input line, or open a web page. Uncheck to ignore them and to stop telling servers that Mudlet supports them.</p></string> + </property> + <property name="accessibleDescription"> + <string>When checked, clickable OSC 8 hyperlinks from the game server are shown and Mudlet advertises support for them. When unchecked, the sequences are ignored and the capability is not advertised.</string> + </property> + <property name="text"> + <string>Enable OSC 8 hyperlinks from the server</string> + </property> + </widget> + </item> </layout> </widget> </item> @@ -5185,6 +5198,7 @@ you can use it but there could be issues with aligning columns of text</string> <tabstop>pushButton_chooseProtocols</tabstop> <tabstop>acceptServerGUI</tabstop> <tabstop>acceptServerMedia</tabstop> + <tabstop>checkBox_enableOSC8Hyperlinks</tabstop> <tabstop>mIsToLogInHtml</tabstop> <tabstop>lineEdit_logFileFolder</tabstop> <tabstop>comboBox_logFileNameFormat</tabstop> diff --git a/src/updater.cpp b/src/updater.cpp index cc741ea26..bb7af5d7b 100644 --- a/src/updater.cpp +++ b/src/updater.cpp @@ -22,6 +22,7 @@ #include "updater/Feed.h" #include "updater/UpdateDialog.h" +#include <QCoreApplication> #include <QDateTime> #include <QMessageBox> #include <QPushButton> @@ -89,19 +90,24 @@ Updater::Updater(QObject* parent, QSettings* settings, bool testVersion) feed.reset(new dblsqd::Feed(this)); feed->setRepo(qsl("Mudlet"), qsl("Mudlet"), testVersion); mPeriodicCheck = std::make_unique<QTimer>(); -} -Updater::~Updater() -{ #if !defined(Q_OS_MACOS) - // QPointer::data() returns null if Qt already deleted the dialog; only - // delete if it hasn't been cleaned up yet. - if (updateDialog) { - delete updateDialog; - } + // The update dialog must not be deleted in ~Updater: this Updater is + // parented to the application object (so it can offer an update after the + // last window closes, #9388), which means ~Updater only runs inside the + // application's own destructor - after ~QApplication has torn down all + // widget infrastructure. Deleting a QWidget that late corrupts the heap on + // Windows (#9122). aboutToQuit fires as the event loop exits, after the + // dialog's last-window-closed flow has finished but while the application + // is still fully alive, so destroy it there instead. + connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() { + delete updateDialog.data(); + }); #endif } +Updater::~Updater() = default; + void Updater::checkUpdatesOnStart() { #if defined(Q_OS_MACOS) @@ -138,9 +144,25 @@ void Updater::checkUpdatesOnStart() mPeriodicCheck->start(); } +// Whether the platform updater is set up and can answer for itself. On macOS +// that only happens in checkUpdatesOnStart(), so anything reaching the Updater +// before then - the preferences dialog above all - has to ask first. Elsewhere +// the automatic-update flag lives in QSettings and is readable straight away. +bool Updater::ready() const +{ +#if defined(Q_OS_MACOS) + return msparkleUpdater != nullptr; +#else + return true; +#endif +} + void Updater::setAutomaticUpdates(const bool state) { #if defined(Q_OS_MACOS) + if (!ready()) { + return; + } msparkleUpdater->setAutomaticallyDownloadsUpdates(state); #else dblsqd::UpdateDialog::enableAutoDownload(state, mSettings); @@ -153,6 +175,9 @@ void Updater::setAutomaticUpdates(const bool state) bool Updater::updateAutomatically() const { #if defined(Q_OS_MACOS) + if (!ready()) { + return false; + } return msparkleUpdater->automaticallyDownloadsUpdates(); #else return dblsqd::UpdateDialog::autoDownloadEnabled(true, mSettings); @@ -162,6 +187,9 @@ bool Updater::updateAutomatically() const void Updater::manuallyCheckUpdates() { #if defined(Q_OS_MACOS) + if (!ready()) { + return; + } msparkleUpdater->checkForUpdates(); #else if (mManualCheckInProgress) { @@ -237,7 +265,7 @@ bool Updater::downloadReleaseIfValid(const dblsqd::Release& release) } return false; } - feed->downloadRelease(release); + feed->downloadRelease(release, /*requireChecksums=*/true); return true; } @@ -291,8 +319,16 @@ void Updater::setupPlatformUpdater() }); connect(feed.get(), &dblsqd::Feed::downloadError, this, [this](const QString& error) { + // Only a check the user started reaches the console. An automatic one + // runs twice a day whether or not anybody is interested, so its failures + // would just repeat in red; once the update dialog is listening it + // reports them itself. + if (mManualCheckInProgress) { + qWarning() << "Manual update download failed:" << error; + emit signal_updateCheckFailed(error); + return; + } qWarning() << "Automatic update download failed:" << error; - emit signal_updateCheckFailed(error); }); } #endif // !Q_OS_MACOS diff --git a/src/updater.h b/src/updater.h index 285d4a148..37dfb4631 100644 --- a/src/updater.h +++ b/src/updater.h @@ -58,12 +58,14 @@ public: void setAutomaticUpdates(bool state); bool updateAutomatically() const; bool shouldShowChangelog(); + bool ready() const; private: std::unique_ptr<dblsqd::Feed> feed; - // Non-owning: Qt parent-child system or explicit deletion in ~Updater handles lifetime. - // QPointer<T> is used so that if Qt deletes the dialog (e.g. on last window closed), - // the pointer automatically becomes null and ~Updater's delete becomes a no-op. + // Owned, but deleted on QCoreApplication::aboutToQuit rather than in + // ~Updater: the Updater is parented to the application object, so its + // destructor runs during application teardown - too late to destroy a + // QWidget (#9122). QPointer nulls itself once the dialog is destroyed. QPointer<dblsqd::UpdateDialog> updateDialog; #if !defined(Q_OS_MACOS) QPushButton* mpInstallOrRestart; @@ -100,7 +102,9 @@ private: #elif defined(Q_OS_WINDOWS) QString mDownloadedInstallerPath; #elif defined(Q_OS_MACOS) - SparkleUpdater* msparkleUpdater; + // Only exists once checkUpdatesOnStart() has run - every use must cope with + // it still being null, see ready() + SparkleUpdater* msparkleUpdater = nullptr; #endif diff --git a/src/updater/Feed.cpp b/src/updater/Feed.cpp index 283d859bf..a8460a93c 100644 --- a/src/updater/Feed.cpp +++ b/src/updater/Feed.cpp @@ -100,12 +100,30 @@ QList<Release> Feed::getReleases() const } QList<Release> Feed::getUpdates(const Release& currentRelease) const +{ + return selectUpdates(mReleases, currentRelease); +} + +QList<Release> Feed::selectUpdates(const QList<Release>& releases, const Release& currentRelease) { QList<Release> updates; - for (const auto& release : mReleases) { - if (currentRelease.getVersion().toLower() != release.getVersion().toLower() && currentRelease < release) { - updates << release; + for (const auto& release : releases) { + if (currentRelease.getVersion().toLower() == release.getVersion().toLower() || !(currentRelease < release)) { + continue; } + const QUrl downloadUrl = release.getDownloadUrl(); + if (!downloadUrl.isValid() || downloadUrl.isEmpty()) { + continue; + } + // Release warns about the missing binary itself; nothing else notices a + // release that has one but cannot be verified, and the user is only + // told they are up to date + const QUrl checksumsUrl = release.getChecksumsUrl(); + if (!checksumsUrl.isValid() || checksumsUrl.isEmpty()) { + qWarning() << "Release" << release.getVersion() << "publishes no checksums to verify its download against - passing it over"; + continue; + } + updates << release; } return updates; } @@ -169,13 +187,55 @@ void Feed::downloadRelease(const Release& release, bool requireChecksums) if (checksumsUrl.isValid() && !checksumsUrl.isEmpty()) { fetchChecksums(checksumsUrl); } else if (requireChecksums) { - //: Error shown when a manual update cannot be verified as safe to install - emit downloadError(tr("Could not verify the integrity of the download. Please try again later.")); + qWarning() << "Release" << release.getVersion() << "publishes no checksums - refusing to install an unverifiable download"; + //: Error shown when the release publishes no checksums at all, so the download cannot be verified as safe to install + emit downloadError(tr("This update does not publish the checksums needed to verify it. Please try again later, or download it from https://www.mudlet.org/download/")); } else { + qCritical() << "Release" << release.getVersion() << "publishes no checksums - download will proceed without integrity verification"; makeDownloadRequest(downloadUrl); } } +QString Feed::findChecksum(const QString& checksumData, const QString& downloadFilename, int* entriesParsed) +{ + if (entriesParsed) { + *entriesParsed = 0; + } + if (downloadFilename.isEmpty()) { + return QString(); + } + + // SHA256 hex digest is 64 characters; search for separator after that + static const QRegularExpression separatorRx(qsl("[\\s*]+")); + static const QRegularExpression hexRx(qsl("^[0-9a-fA-F]{64}$")); + + QString match; + const QStringList lines = checksumData.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + for (const auto& line : lines) { + // Format: "hash filename" or "hash *filename" + const int separatorPos = line.indexOf(separatorRx, 64); + if (separatorPos <= 0) { + continue; + } + const QString hash = line.left(separatorPos).trimmed(); + if (!hexRx.match(hash).hasMatch()) { + continue; + } + if (entriesParsed) { + ++*entriesParsed; + } + // Compare the whole name, not a substring of it: SHA256SUMS.txt accumulates + // entries across builds, so a longer name that happens to contain this one + // would otherwise hand back the wrong hash. The generators write bare + // basenames, but tolerate a path in case one ever stops. + const QString filename = line.mid(separatorPos).trimmed().remove(QLatin1Char('*')); + if (match.isEmpty() && filename.section(QLatin1Char('/'), -1).compare(downloadFilename, Qt::CaseInsensitive) == 0) { + match = hash; + } + } + return match; +} + void Feed::fetchChecksums(const QUrl& checksumsUrl) { QNetworkRequest request(checksumsUrl); @@ -187,45 +247,36 @@ void Feed::fetchChecksums(const QUrl& checksumsUrl) connect(reply, &QNetworkReply::finished, this, [this, reply]() { if (reply->error() != QNetworkReply::NoError) { if (mRequireChecksums) { + qWarning() << "Failed to fetch checksums:" << reply->errorString() << "- refusing to install an unverifiable download"; reply->deleteLater(); - //: Error shown when a manual update cannot be verified as safe to install - emit downloadError(tr("Could not verify the integrity of the download. Please try again later.")); + //: Error shown when the checksums needed to verify the update could not be downloaded + emit downloadError(tr("Could not download the checksums needed to verify this update. Please try again later.")); return; } qWarning() << "Failed to fetch checksums:" << reply->errorString() << "- download will proceed without integrity verification"; } else { - const QString checksumData = QString::fromUtf8(reply->readAll()); - const QStringList lines = checksumData.split(QLatin1Char('\n'), Qt::SkipEmptyParts); - // SHA256 hex digest is 64 characters; search for separator after that - static const QRegularExpression separatorRx(qsl("[\\s*]+")); - static const QRegularExpression hexRx(qsl("^[0-9a-fA-F]{64}$")); - for (const auto& line : lines) { - // Format: "hash filename" or "hash *filename" - const int separatorPos = line.indexOf(separatorRx, 64); - if (separatorPos <= 0) { - continue; - } - const QString hash = line.left(separatorPos).trimmed(); - if (!hexRx.match(hash).hasMatch()) { - continue; - } - const QString filename = line.mid(separatorPos).trimmed().remove(QLatin1Char('*')); - - // Match against the download URL filename - const QString downloadFilename = mCurrentDownload.getDownloadUrl().fileName(); - if (!downloadFilename.isEmpty() && filename.contains(downloadFilename, Qt::CaseInsensitive)) { - mCurrentDownload.setDownloadSHA256(hash); - break; - } - } + const QString downloadFilename = mCurrentDownload.getDownloadUrl().fileName(); + const QByteArray checksumData = reply->readAll(); + int entriesParsed = 0; + mCurrentDownload.setDownloadSHA256(findChecksum(QString::fromUtf8(checksumData), downloadFilename, &entriesParsed)); if (mCurrentDownload.getDownloadSHA256().isEmpty()) { + qWarning() << "Checksum file has no entry for" << downloadFilename << "- parsed" << entriesParsed << "entries from" << checksumData.size() << "bytes"; if (mRequireChecksums) { reply->deleteLater(); - //: Error shown when a manual update cannot be verified as safe to install - emit downloadError(tr("Could not verify the integrity of the download. Please try again later.")); + if (entriesParsed == 0) { + // Nothing parsed means the payload was not a checksum file at + // all - a truncated transfer, or an error page served as 200 - + // rather than a release that forgot one platform + //: Error shown when the checksum file for the update was downloaded but could not be read + emit downloadError(tr("The checksums for this update could not be read, so it cannot be verified. Please try again later.")); + } else { + //: Error shown when the release publishes checksums but none of them cover this platform's download + emit downloadError( + tr("This update is missing a checksum for your platform, so it cannot be verified. Please try again later, or download it from https://www.mudlet.org/download/")); + } return; } - qCritical() << "Checksum file downloaded but no matching hash found for" << mCurrentDownload.getDownloadUrl().fileName() << "- download will proceed without integrity verification"; + qCritical() << "Proceeding without integrity verification for" << downloadFilename; } } reply->deleteLater(); diff --git a/src/updater/Feed.h b/src/updater/Feed.h index d8a10fd92..aceba86f3 100644 --- a/src/updater/Feed.h +++ b/src/updater/Feed.h @@ -50,7 +50,22 @@ public: void load(); void downloadRelease(const Release& release, bool requireChecksums = false); + // Returns the SHA256 that sha256sum-style output lists for downloadFilename, + // comparing the whole filename case-insensitively, or an empty string when no + // line covers it. entriesParsed, when given, receives the number of well-formed + // lines seen, which tells "this release forgot my platform" apart from "that was + // not a checksum file". + static QString findChecksum(const QString& checksumData, const QString& downloadFilename, int* entriesParsed = nullptr); + + // The releases newer than currentRelease that this platform can install. A + // release with no asset for this platform - a build job that failed, or + // assets that are still uploading - or no SHA256SUMS.txt to verify the + // download against cannot be installed, so offering it only produces a + // download error the user can do nothing about. The changelog is built from + // getReleases() instead, and still covers them. QList<Release> getUpdates(const Release& currentRelease) const; + static QList<Release> selectUpdates(const QList<Release>& releases, const Release& currentRelease); + QList<Release> getReleases() const; QString getDownloadFilePath() const; bool isReady() const; diff --git a/src/utils.h b/src/utils.h index 8157326b2..d2bb3e1cf 100644 --- a/src/utils.h +++ b/src/utils.h @@ -74,11 +74,64 @@ public: return copyLen; } + // As copyString(), but for UTF-8 data that has to stay valid UTF-8: the copy + // stops at the last character that fits whole rather than at the last byte, + // so no trailing half-character is left behind. Use it wherever a truncated + // copy is handed on to something that decodes it - Discord discards an + // entire presence frame whose JSON payload carries an incomplete sequence. + // Returns the number of bytes copied (excluding the null terminator). + static size_t copyUtf8String(char* dest, size_t destSize, const char* src, size_t srcLen) + { + if (destSize == 0) { + return 0; + } + size_t copyLen = (srcLen < destSize) ? srcLen : destSize - 1; + // Every byte after the first of a multi-byte character has the form + // 10xxxxxx, so a cut in front of one is a cut inside a character: walk + // back to where that character starts. A cut that took everything (or + // that landed on a character start) needs no adjustment. + while (copyLen > 0 && copyLen < srcLen && (static_cast<unsigned char>(src[copyLen]) & 0xC0u) == 0x80u) { + --copyLen; + } + std::memcpy(dest, src, copyLen); + dest[copyLen] = '\0'; + return copyLen; + } + // This construct will be very useful for formatting tooltips and by // defining a static function/method here we can save using the same // qsl all over the place: static QString richText(const QString& text) { return qsl("<p>%1</p>").arg(text); } + // Call this in the destructor of a window class that connects any of its + // own widgets to its own slots - keep it first, so that nothing else the + // destructor does can deliver a child's signal either. + // + // A visible window is taken off the screen while the base-class + // destructors unwind: ~QDialog hides it explicitly, and any other window + // class gets closed by ~QWidget. That moves the keyboard focus away from + // whichever child widget holds it, and an editing widget reacts to the + // focus-out by emitting - QLineEdit (once its text has been touched, which + // includes any setText()), QAbstractSpinBox and QKeySequenceEdit all emit + // editingFinished() there. Qt then tries to deliver that to a slot of a + // window whose derived part has already been destroyed, which aborts with + // "Called object is not of the correct type (class destructor may have + // already run)" (#9574). In a release build the assert is compiled out and + // the slot runs against destroyed members instead. + // + // A window that is being destroyed cannot do anything useful with a + // signal from its own widgets, so every one of them is severed rather + // than just the widget types that emit during teardown today. Note that + // this only reaches connections whose receiver is the window: a + // connect(child, &Signal, [this]{...}) written without a context object + // survives it and brings the crash back, so always pass the context: + static void disconnectChildSignals(QWidget* window) + { + for (QObject* child : window->findChildren<QObject*>()) { + QObject::disconnect(child, nullptr, window, nullptr); + } + } + // Qt 6.9 deprecated QDateTime::setOffsetFromUtc(int) and made it hard to // replicate the exact strings that we had before: static QString dateStamp() { @@ -118,43 +171,96 @@ public: struct ConfigDirResolution { QString path; - // True only in the migration-guard case: XDG_CONFIG_HOME is set but - // $XDG_CONFIG_HOME/mudlet is not (yet) Mudlet's, so an existing legacy - // dir is used instead. The caller can then hint the user how to migrate. + // XDG_CONFIG_HOME is set, but an existing legacy dir was used anyway, so + // the caller can hint at the migration bool migrationPending = false; + // legacyDefault, when it holds profiles that the chosen dir now hides. The + // caller has to name it, or those profiles read as gone. + QString shadowedProfilesPath; }; - // Resolve Mudlet's config root honoring XDG_CONFIG_HOME, with a migration - // guard. The caller handles portable.txt first (it still wins); this covers - // the rest: - // - XDG_CONFIG_HOME unset/empty/relative -> legacyDefault (~/.config/mudlet) - // - $XDG_CONFIG_HOME/mudlet is Mudlet's -> it (already migrated / opt-in) - // - not Mudlet's but legacyDefault exists -> legacyDefault, so exporting - // XDG_CONFIG_HOME never strands existing profiles - // - neither is usable -> $XDG_CONFIG_HOME/mudlet (fresh) - // "Mudlet's" means the dir holds a Mudlet.ini or profiles/, or is an empty - // opt-in dir a test harness pre-created. This deliberately ignores the stale - // $XDG_CONFIG_HOME/mudlet/Mudlet.conf that pre-4.19 Mudlet wrote there (its - // NativeFormat settings) while profiles stayed in ~/.config/mudlet - treating - // that leftover as the config root would hide such a user's profiles. + // How strongly a directory claims to be Mudlet's config root; the stronger + // claim wins in xdgConfigDir(), so the order is the contract. + enum class ConfigDirClaim { + absent = 0, + // Exists, but holds nothing Mudlet put there - including the stale + // Mudlet.conf pre-4.19 Mudlet left in $XDG_CONFIG_HOME/mudlet while its + // profiles stayed in ~/.config/mudlet + unclaimed = 1, + settings = 2, + profiles = 3, + }; + + // A directory that cannot be listed must never read as "nothing here": that + // inference is what hides profiles, so assume the strongest content instead. + static bool configDirHoldsProfiles(const QString& dir) + { + if (!QDir(dir).exists()) { + return false; + } + if (!QFileInfo(dir).isReadable()) { + return true; + } + const QDir profiles(qsl("%1/profiles").arg(dir)); + if (!profiles.exists()) { + return false; + } + // Counted as mudlet.cpp's anyProfilesExist() does, so the two cannot disagree + return !QFileInfo(profiles.path()).isReadable() || !profiles.entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty(); + } + + static ConfigDirClaim configDirClaim(const QString& dir) + { + if (!QDir(dir).exists()) { + return ConfigDirClaim::absent; + } + if (configDirHoldsProfiles(dir)) { + return ConfigDirClaim::profiles; + } + if (QFileInfo::exists(qsl("%1/Mudlet.ini").arg(dir))) { + return ConfigDirClaim::settings; + } + return ConfigDirClaim::unclaimed; + } + + // $XDG_CONFIG_HOME/mudlet claims more than it holds, because creating + // profiles/ there is the deliberate opt-in into an isolated config root. The + // legacy dir gets no such credit: an empty profiles/ left behind by deleting + // the last profile would otherwise outrank a config root in active use. + static ConfigDirClaim xdgConfigDirClaim(const QString& dir) + { + if (QDir(qsl("%1/profiles").arg(dir)).exists()) { + return ConfigDirClaim::profiles; + } + return configDirClaim(dir); + } + + // cleanPath() is not enough: a symlinked ~/.config gives one directory two + // spellings, and dotfile managers produce exactly that + static QString configDirIdentity(const QString& dir) + { + const QString canonical = QFileInfo(dir).canonicalFilePath(); + return canonical.isEmpty() ? QDir::cleanPath(dir) : canonical; + } + + // Resolve Mudlet's config root honoring XDG_CONFIG_HOME; the caller handles + // portable.txt first, which still wins. $XDG_CONFIG_HOME/mudlet takes a tie so + // that a fresh install lands there. static ConfigDirResolution xdgConfigDir(const QString& legacyDefault) { const QString xdgConfigHome = qEnvironmentVariable("XDG_CONFIG_HOME"); // The XDG base-dir spec requires an absolute path; a relative (or empty) // value must be ignored, which also avoids a surprising CWD-relative root. if (xdgConfigHome.isEmpty() || !QDir::isAbsolutePath(xdgConfigHome)) { - return {legacyDefault, false}; + return {legacyDefault, false, QString()}; } const QString xdgTarget = QDir::cleanPath(qsl("%1/mudlet").arg(xdgConfigHome)); - const QDir xdgDir(xdgTarget); - const bool xdgIsMudlets = xdgDir.exists() && (QFileInfo::exists(qsl("%1/Mudlet.ini").arg(xdgTarget)) || QDir(qsl("%1/profiles").arg(xdgTarget)).exists() || xdgDir.isEmpty()); - if (xdgIsMudlets) { - return {xdgTarget, false}; + if (xdgConfigDirClaim(xdgTarget) < configDirClaim(legacyDefault)) { + return {legacyDefault, true, QString()}; } - if (QDir(legacyDefault).exists()) { - return {legacyDefault, true}; - } - return {xdgTarget, false}; + // XDG_CONFIG_HOME=$HOME/.config makes both candidates one directory + const bool shadowing = configDirIdentity(legacyDefault) != configDirIdentity(xdgTarget) && configDirHoldsProfiles(legacyDefault); + return {xdgTarget, false, shadowing ? legacyDefault : QString()}; } inline static const auto scmfileSystemUnsafeChars = QRegularExpression(qsl(R"REGEX([/\\:*?"<>|])REGEX")); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b2d4d0881..23fbea6a4 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -7,11 +7,26 @@ if(NOT WIN32) include(${CMAKE_SOURCE_DIR}/src/cmake/EnableSanitizers.cmake) endif() -find_package(Qt6 6.8.2 REQUIRED COMPONENTS Test) +find_package(Qt6 6.8.2 REQUIRED COMPONENTS Test Network Widgets) + +# On Windows Qt diverts QTest's stdout to OutputDebugString unless it believes +# stderr has a console attached, which an MSYS2 shell or a CI runner does not give +# it, so a failing test reports an exit code and nothing else (#9747). Deferred so +# that tests registered further down the file are covered too; TESTS does not +# descend into subdirectories, hence the second call in functional_tests. +function(restore_windows_test_output) + get_property(registeredTests DIRECTORY PROPERTY TESTS) + foreach(testName ${registeredTests}) + set_property(TEST ${testName} APPEND PROPERTY ENVIRONMENT "QT_ASSUME_STDERR_HAS_CONSOLE=1") + endforeach() +endfunction() +cmake_language(DEFER CALL restore_windows_test_output) set(UNIT_TESTS TEntityResolverTest TEntityHandlerTest + LuaLiteralTest + UntrustedTextTest TLinkStoreTest TMxpTagParserTest TMxpSendTagHandlerTest @@ -24,6 +39,7 @@ set(UNIT_TESTS TMxpEdgeCasesTest TMxpElementDefinitionHandlerTest TLuaInterfaceTest + EventLoopPumpTest TVariableEditorTest SecureStringUtilsTest CredentialManagerTest @@ -37,18 +53,40 @@ set(UNIT_TESTS TEncodingHelperTest PasswordMigrationTest TMediaPathTraversalTest + ProfileNameValidationTest ) foreach(test_name ${UNIT_TESTS}) add_executable(${test_name} ${test_name}.cpp) add_dependencies(${test_name} ${LIB_MUDLET_TARGET}) - target_link_libraries(${test_name} PRIVATE Qt6::Test ${LIB_MUDLET_TARGET}) + # mudlet_lsan_hooks has to be linked explicitly, see src/CMakeLists.txt + target_link_libraries(${test_name} PRIVATE Qt6::Test ${LIB_MUDLET_TARGET} mudlet_lsan_hooks) add_test(NAME ${test_name} COMMAND $<TARGET_FILE:${test_name}>) set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" ) endforeach() +# A regression in what EventLoopPumpTest covers hangs rather than fails, so cap +# it well under ctest's default 25 minutes. +set_tests_properties(EventLoopPumpTest PROPERTIES TIMEOUT 60) + +# TKeySequenceEditTest's focus traversal cases need an active window, which an X +# server with no window manager never gives them, so a plain `xvfb-run ctest` +# reported two failures that meant nothing and cost the activation timeout twice +# (#9575). The offscreen platform synthesises activation, and is already how the +# Linux CI job runs the whole suite. Only on X11: macOS and Windows have a real +# window manager, and running there natively is the only coverage of platform +# focus traversal there is. APPEND so the sanitizer setting above survives. +if(UNIX AND NOT APPLE) + set_property(TEST TKeySequenceEditTest APPEND PROPERTY ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endif() + +# Every ctest run has a display that can activate a window, one way or the other, +# so a skipped traversal case there is a regression and not an environment. Only +# somebody running the binary by hand on a bare X server is allowed the skip. +set_property(TEST TKeySequenceEditTest APPEND PROPERTY ENVIRONMENT "MUDLET_REQUIRE_WINDOW_ACTIVATION=1") + # DiscordTest checks the Lua API permission gating contract by scanning the source target_compile_definitions(DiscordTest PRIVATE MUDLET_SRC_DIR="${CMAKE_SOURCE_DIR}/src") @@ -62,4 +100,132 @@ set_tests_properties(CMakeListsConsistencyTest PROPERTIES ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" ) +# Pairing of a release's assets with its SHA256SUMS.txt. Built from the updater +# sources rather than linked against the Mudlet library, because the library only +# contains them when configured with USE_UPDATER. +add_executable(ReleaseChecksumPairingTest + ReleaseChecksumPairingTest.cpp + ${CMAKE_SOURCE_DIR}/src/updater/Feed.cpp + ${CMAKE_SOURCE_DIR}/src/updater/Release.cpp + ${CMAKE_SOURCE_DIR}/src/updater/SemVer.cpp +) +target_link_libraries(ReleaseChecksumPairingTest PRIVATE Qt6::Test Qt6::Network Qt6::Widgets) +add_test(NAME ReleaseChecksumPairingTest COMMAND $<TARGET_FILE:ReleaseChecksumPairingTest>) +set_tests_properties(ReleaseChecksumPairingTest PROPERTIES + ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" +) + +# Which releases the updater offers as an update. Built from the updater sources +# rather than linked against the Mudlet library, because the library only +# contains them when configured with USE_UPDATER. +add_executable(ReleasePlatformAssetTest + ReleasePlatformAssetTest.cpp + ${CMAKE_SOURCE_DIR}/src/updater/Feed.cpp + ${CMAKE_SOURCE_DIR}/src/updater/Release.cpp + ${CMAKE_SOURCE_DIR}/src/updater/SemVer.cpp +) +target_link_libraries(ReleasePlatformAssetTest PRIVATE Qt6::Test Qt6::Network Qt6::Widgets) +add_test(NAME ReleasePlatformAssetTest COMMAND $<TARGET_FILE:ReleasePlatformAssetTest>) +set_tests_properties(ReleasePlatformAssetTest PROPERTIES + ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" +) + +# The $XDG_CONFIG_HOME opt-in recipe the tests isolate themselves with. Reads the +# test sources at runtime, so like CMakeListsConsistencyTest it links nothing. +add_executable(XdgRecipeConsistencyTest XdgRecipeConsistencyTest.cpp) +target_link_libraries(XdgRecipeConsistencyTest PRIVATE Qt6::Test) +target_compile_definitions(XdgRecipeConsistencyTest PRIVATE MUDLET_TEST_DIR="${CMAKE_CURRENT_SOURCE_DIR}") +add_test(NAME XdgRecipeConsistencyTest COMMAND $<TARGET_FILE:XdgRecipeConsistencyTest>) +set_tests_properties(XdgRecipeConsistencyTest PROPERTIES + ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0" +) + +# Checks the release-publishing scripts that keep SHA256SUMS.txt covering every +# release binary - a binary without an entry is one the updater refuses to install +if(NOT WIN32) + add_test(NAME ReleaseChecksumsTest + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/ci/release-checksums-test.sh + ) +endif() + +# The updater reads its version from the release tag, so a tag like "Mudlet-5.0" +# stops every existing user being offered the release, silently +if(NOT WIN32) + add_test(NAME ReleaseTagVersionTest + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/ci/release-tag-version-test.sh + ) +endif() + +# Checks the milestone lookup that add-milestone runs - the one that matched a +# title that no longer existed and assigned nothing for months, without ever +# failing. gh is stubbed, so there is no network and no token +if(NOT WIN32) + add_test(NAME MilestoneResolutionTest + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/ci/milestone-resolution-test.sh + ) +endif() + add_subdirectory(functional_tests) + +# CI runners are elevated, so nothing there catches a test executable Windows +# refuses to launch for looking like an installer (#9748) - hence this gate, +# whose message spells the heuristic out. +function(mudlet_reject_uac_installer_name name) + string(TOLOWER "${name}" lowercaseName) + if(lowercaseName MATCHES "install|setup|update|patch") + message(FATAL_ERROR + "'${name}' cannot be used as a test name: Windows takes an unsigned executable whose name " + "contains '${CMAKE_MATCH_0}' for an installer and refuses to launch it without elevation " + "(#9748), so ctest reports BAD_COMMAND for it in an ordinary developer shell. Rename the " + "source file and its target to describe what the test asserts, watching for install, setup, " + "update and patch as substrings - Dispatch carries one.") + endif() +endfunction() + +function(mudlet_reject_uac_installer_target_names directory) + get_property(targets DIRECTORY "${directory}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(target ${targets}) + get_target_property(targetType ${target} TYPE) + if(NOT targetType STREQUAL "EXECUTABLE") + continue() + endif() + set_property(GLOBAL APPEND PROPERTY mudletUacCheckedExecutables "${target}") + get_target_property(executableName ${target} OUTPUT_NAME) + if(NOT executableName) + set(executableName ${target}) + endif() + mudlet_reject_uac_installer_name("${executableName}") + endforeach() + + get_property(subdirectories DIRECTORY "${directory}" PROPERTY SUBDIRECTORIES) + foreach(subdirectory ${subdirectories}) + mudlet_reject_uac_installer_target_names("${subdirectory}") + endforeach() +endfunction() + +# Both passes are needed. Targets carry the name that actually reaches disk, +# OUTPUT_NAME included, but only for the tests this configuration builds: +# NewReleaseDialogTeardownTest, for one, is registered only under USE_UPDATER. +# Source names are what the registration loops derive executable names from, +# and are present whatever the configuration. +function(mudlet_check_test_executable_names directory) + mudlet_reject_uac_installer_target_names("${directory}") + + get_property(checkedExecutables GLOBAL PROPERTY mudletUacCheckedExecutables) + if(NOT checkedExecutables) + message(FATAL_ERROR "The test executable name check found no executables under ${directory}, so it is checking nothing.") + endif() + + file(GLOB_RECURSE testSources "${directory}/*.cpp") + if(NOT testSources) + message(FATAL_ERROR "The test executable name check found no sources under ${directory}, so it is checking nothing.") + endif() + foreach(testSource ${testSources}) + get_filename_component(sourceName "${testSource}" NAME_WE) + mudlet_reject_uac_installer_name("${sourceName}") + endforeach() +endfunction() + +# Deferred rather than called outright, so that targets registered below this +# line, and in subdirectories added below it, are checked as well +cmake_language(DEFER CALL mudlet_check_test_executable_names "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/test/DiscordTest.cpp b/test/DiscordTest.cpp index 3a145c4e9..aa088569c 100644 --- a/test/DiscordTest.cpp +++ b/test/DiscordTest.cpp @@ -19,17 +19,17 @@ #include <discord.h> #include <Host.h> +#include <utils.h> #include <QFile> #include <QtTest/QtTest> -class DiscordTest : public QObject { +class DiscordTest : public QObject +{ Q_OBJECT private slots: - void initTestCase() - { - } + void initTestCase() {} // Test that convert() returns nullptr for empty string fields void testConvertNullIfEmpty() @@ -155,14 +155,79 @@ private slots: void testStringTruncation() { localDiscordPresence presence; - // Details buffer is 128 bytes - test with a string longer than that + // Discord documents details as holding 128 bytes, so a longer string is + // cut down to exactly that - the buffer allows for its own terminator + // rather than spending one of those 128 bytes on it (#9634). QString longString(200, QChar('A')); presence.setDetailText(longString); DiscordRichPresence converted = presence.convert(); QVERIFY(converted.details != nullptr); - // Should be truncated but not crash - QVERIFY(strlen(converted.details) < 128); + QCOMPARE(strlen(converted.details), size_t{128}); + } + + // A field of exactly the documented length has to arrive whole: an asset key + // that loses its last character resolves to no icon at all (#9634). + void testFullLengthFieldsSurviveWhole() + { + localDiscordPresence presence; + presence.setLargeImageKey(QString(32, QChar('a'))); + presence.setStateText(QString(128, QChar('s'))); + + DiscordRichPresence converted = presence.convert(); + QCOMPARE(strlen(converted.largeImageKey), size_t{32}); + QCOMPARE(strlen(converted.state), size_t{128}); + } + + // Truncation has to fall between characters. A field cut through the middle + // of a multi-byte one is no longer valid UTF-8, and Discord discards the + // whole presence frame carrying it rather than just that field (#9634). + void testTruncationKeepsUtf8Intact() + { + localDiscordPresence presence; + // 65 two-byte characters: 130 bytes, so the cut has to fall inside the + // 65th and take all of it. + presence.setDetailText(QString(65, QChar(0x00E9))); + // 17 of the same in a 32 byte field, which holds 16 of them. + presence.setLargeImageKey(QString(17, QChar(0x00E9))); + + DiscordRichPresence converted = presence.convert(); + QCOMPARE(QByteArray(converted.details), QString(64, QChar(0x00E9)).toUtf8()); + QCOMPARE(QByteArray(converted.largeImageKey), QString(16, QChar(0x00E9)).toUtf8()); + // A three-byte character has two ways to be cut in half, so check the + // other one too: 43 of them are 129 bytes. + presence.setStateText(QString(43, QChar(0x4F60))); + converted = presence.convert(); + QCOMPARE(QByteArray(converted.state), QString(42, QChar(0x4F60)).toUtf8()); + + // And an emoji, the four-byte case, where the walk-back has to step + // over three continuation bytes: 33 of them are 132 bytes. + const char32_t grinningFace = 0x1F600; + const QString emoji = QString::fromUcs4(&grinningFace, 1); + presence.setDetailText(emoji.repeated(33)); + converted = presence.convert(); + QCOMPARE(QByteArray(converted.details), emoji.repeated(32).toUtf8()); + } + + // The truncation itself, at boundaries the fixed-size presence fields + // cannot reach. + void testCopyUtf8StringEdgeCases() + { + char buffer[8]; + // Nothing to copy, and a destination too small even to terminate: + QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), "", 0), size_t{0}); + QCOMPARE(utils::copyUtf8String(buffer, 0, "abc", 3), size_t{0}); + // Exactly filling the usable space is not a truncation, so there is + // nothing to walk back from: + QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), "abcdefg", 7), size_t{7}); + QCOMPARE(QByteArray(buffer), QByteArray("abcdefg")); + // One byte too many, cut between characters: + QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), "abcdefgh", 8), size_t{7}); + // Input that is nothing but continuation bytes cannot be cut anywhere + // valid, so an empty field is what comes out - never a broken sequence. + const char continuationBytes[] = "\x80\x80\x80\x80\x80\x80\x80\x80\x80"; + QCOMPARE(utils::copyUtf8String(buffer, sizeof(buffer), continuationBytes, 9), size_t{0}); + QCOMPARE(QByteArray(buffer), QByteArray()); } // Test that Discord username comparison is case-insensitive. @@ -229,13 +294,10 @@ private slots: ++checked; } } - // All 22 Discord Lua API functions should have been categorised: QVERIFY2(checked >= 22, qPrintable(qsl("only categorised %1 Discord Lua functions - has the source moved?").arg(checked))); } - void cleanupTestCase() - { - } + void cleanupTestCase() {} }; #include "DiscordTest.moc" diff --git a/test/EventLoopPumpTest.cpp b/test/EventLoopPumpTest.cpp new file mode 100644 index 000000000..a83dc93a2 --- /dev/null +++ b/test/EventLoopPumpTest.cpp @@ -0,0 +1,141 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QtTest/QtTest> + +#include <QElapsedTimer> +#include <QTimer> + +#include "EventLoopPump.h" + +/* + * The macOS CI legs are what make pumpingFromInsideATimerCallback() worth + * having: that is the position a nested QEventLoop::exec() stops seeing Qt + * timers in (issue #9670), and no other platform reproduces it. + */ +class EventLoopPumpTest : public QObject +{ + Q_OBJECT + +private slots: + void runsOutTheClockWithNoCondition(); + void makesOnePassForAZeroTimeout(); + void stopsAsSoonAsTheConditionHolds(); + void stopsWithoutPumpingWhenTheConditionAlreadyHolds(); + void deliversATimerThatComesDueWhilePumping(); + void pumpingFromInsideATimerCallback(); + +private: + bool mDelivered = false; +}; + +void EventLoopPumpTest::runsOutTheClockWithNoCondition() +{ + QElapsedTimer elapsed; + elapsed.start(); + QVERIFY(!EventLoopPump::pumpFor(120)); + QVERIFY2(elapsed.elapsed() >= 110, qPrintable(QString::number(elapsed.elapsed()))); +} + +void EventLoopPumpTest::makesOnePassForAZeroTimeout() +{ + bool delivered = false; + QMetaObject::invokeMethod( + this, + [&delivered]() { + delivered = true; + }, + Qt::QueuedConnection); + + QVERIFY(!EventLoopPump::pumpFor(0)); + QVERIFY2(delivered, "a zero timeout did not deliver the already-posted event"); +} + +void EventLoopPumpTest::stopsAsSoonAsTheConditionHolds() +{ + bool done = false; + QTimer::singleShot(50, this, [&done]() { + done = true; + }); + + QElapsedTimer elapsed; + elapsed.start(); + QVERIFY(EventLoopPump::pumpFor(5000, [&done]() { + return done; + })); + QVERIFY2(elapsed.elapsed() < 2000, qPrintable(QString::number(elapsed.elapsed()))); +} + +void EventLoopPumpTest::stopsWithoutPumpingWhenTheConditionAlreadyHolds() +{ + mDelivered = false; + QMetaObject::invokeMethod( + this, + [this]() { + mDelivered = true; + }, + Qt::QueuedConnection); + + QVERIFY(EventLoopPump::pumpFor(1000, []() { + return true; + })); + QVERIFY2(!mDelivered, "a condition that already held should not have pumped anything"); + + // Drain the still-queued event so it cannot turn up mid-way through the + // next test. + QVERIFY(!EventLoopPump::pumpFor(20)); + QVERIFY(mDelivered); +} + +void EventLoopPumpTest::deliversATimerThatComesDueWhilePumping() +{ + bool fired = false; + QTimer::singleShot(40, this, [&fired]() { + fired = true; + }); + + QVERIFY(!EventLoopPump::pumpFor(300)); + QVERIFY2(fired, "a timer that came due during the pump did not fire"); +} + +void EventLoopPumpTest::pumpingFromInsideATimerCallback() +{ + // A regression here hangs rather than fails: nothing ever completes the + // outer callback. + bool innerFired = false; + bool firedDuringPump = false; + bool outerDone = false; + + QTimer::singleShot(0, this, [&]() { + QTimer::singleShot(40, this, [&innerFired]() { + innerFired = true; + }); + QVERIFY(!EventLoopPump::pumpFor(300)); + firedDuringPump = innerFired; + outerDone = true; + }); + + QVERIFY(EventLoopPump::pumpFor(5000, [&outerDone]() { + return outerDone; + })); + QVERIFY2(firedDuringPump, "a timer did not fire while pumping from inside a timer callback"); +} + +QTEST_MAIN(EventLoopPumpTest) +#include "EventLoopPumpTest.moc" diff --git a/test/LuaLiteralTest.cpp b/test/LuaLiteralTest.cpp new file mode 100644 index 000000000..787470842 --- /dev/null +++ b/test/LuaLiteralTest.cpp @@ -0,0 +1,189 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "LuaLiteral.h" + +#include <QtTest/QtTest> + +#include <memory> + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lauxlib.h> +#include <lua5.1/lua.h> +#include <lua5.1/lualib.h> +#else +#include <lauxlib.h> +#include <lua.h> +#include <lualib.h> +#endif +} + +/* + * A game server controls the payload of an OSC 8 send:/prompt:/http: URI, and + * Mudlet turns that payload into Lua source that is executed when the user + * clicks the link. If the payload can terminate the string literal it is + * embedded in, the remainder of the payload is executed as code. These tests + * assert the property that matters: whatever the payload, evaluating the + * generated literal yields the payload back and runs nothing else. + */ +class LuaLiteralTest : public QObject +{ + Q_OBJECT + +private: + // Evaluates "return <literal>" in a fresh Lua 5.1 state. Returns the + // resulting string, or a null QString if the chunk did not compile or did + // not produce exactly one string. + static QString evaluate(const QString& literal) + { + std::unique_ptr<lua_State, decltype(&lua_close)> state(luaL_newstate(), &lua_close); + if (!state) { + return QString(); + } + + const QByteArray chunk = QString(QLatin1String("return ") + literal).toUtf8(); + if (luaL_loadbuffer(state.get(), chunk.constData(), chunk.size(), "literal") != 0) { + return QString(); + } + if (lua_pcall(state.get(), 0, LUA_MULTRET, 0) != 0) { + return QString(); + } + if (lua_gettop(state.get()) != 1 || !lua_isstring(state.get(), -1)) { + return QString(); + } + + size_t length = 0; + const char* value = lua_tolstring(state.get(), -1, &length); + return QString::fromUtf8(value, static_cast<int>(length)); + } + +private slots: + void initTestCase() {} + + void testRoundTrip_data() + { + QTest::addColumn<QString>("payload"); + + QTest::newRow("plain command") << QStringLiteral("look"); + QTest::newRow("empty") << QStringLiteral(""); + QTest::newRow("spaces") << QStringLiteral("cast fireball at troll"); + QTest::newRow("leading newline") << QStringLiteral("\nlook"); + QTest::newRow("trailing newline") << QStringLiteral("look\n"); + QTest::newRow("embedded newline") << QStringLiteral("look\nnorth"); + QTest::newRow("single close bracket") << QStringLiteral("a]b"); + // A payload ending in "]" merges with the closer appended after it and + // shuts the literal one character early. Ordinary MUD commands and bare + // IPv6 URLs end this way. + QTest::newRow("trailing close bracket") << QStringLiteral("look north]"); + QTest::newRow("bare close bracket") << QStringLiteral("]"); + QTest::newRow("ooc tag") << QStringLiteral("say [OOC]"); + QTest::newRow("inventory slot") << QStringLiteral("get sword from bag[1]"); + QTest::newRow("ipv6 url") << QStringLiteral("http://[::1]"); + QTest::newRow("trailing closer prefix level 1") << QStringLiteral("a]]b]="); + QTest::newRow("trailing closer prefix level 2") << QStringLiteral("a]]b]=]c]=="); + QTest::newRow("level 0 breakout") << QStringLiteral("]],false) os.execute([[touch /tmp/pwned]]) --"); + QTest::newRow("level 1 breakout") << QStringLiteral("]=],false) os.execute([=[x]=]) --"); + QTest::newRow("both levels") << QStringLiteral("a]]b]=]c"); + QTest::newRow("nested open bracket") << QStringLiteral("a[[b"); + QTest::newRow("nested open bracket level 1") << QStringLiteral("a[[b]]c[=[d"); + QTest::newRow("quotes and backslashes") << QStringLiteral("say \"hi\\there\""); + QTest::newRow("percent markers") << QStringLiteral("say %1 %2 %%"); + QTest::newRow("url with fragment") << QStringLiteral("https://example.com/a?b=c#d"); + QTest::newRow("utf8") << QStringLiteral("say éè你好"); + } + + void testRoundTrip() + { + QFETCH(QString, payload); + + const QString literal = LuaLiteral::quote(payload); + const QString result = evaluate(literal); + + QVERIFY2(!result.isNull(), qPrintable(QStringLiteral("literal did not compile to a single string: %1").arg(literal))); + QCOMPARE(result, payload); + } + + void testBreakoutDoesNotExecute() + { + // The classic payload: close the literal, close the send() call, run + // arbitrary code, comment out the tail. Building the same call shape + // Mudlet builds must produce a chunk that assigns the payload as data. + const QString payload = QStringLiteral("]],false) BREAKOUT = 1 --"); + const QString chunkSource = QStringLiteral("captured = %1").arg(LuaLiteral::quote(payload)); + + std::unique_ptr<lua_State, decltype(&lua_close)> state(luaL_newstate(), &lua_close); + QVERIFY(state); + + const QByteArray chunk = chunkSource.toUtf8(); + QCOMPARE(luaL_loadbuffer(state.get(), chunk.constData(), chunk.size(), "chunk"), 0); + QCOMPARE(lua_pcall(state.get(), 0, 0, 0), 0); + + lua_getglobal(state.get(), "BREAKOUT"); + QVERIFY2(lua_isnil(state.get(), -1), "payload escaped the literal and executed"); + lua_pop(state.get(), 1); + + lua_getglobal(state.get(), "captured"); + QVERIFY(lua_isstring(state.get(), -1)); + QCOMPARE(QString::fromUtf8(lua_tostring(state.get(), -1)), payload); + } + + // The hand-picked rows above only catch payload shapes someone thought of; + // the trailing-']' case survived review precisely because nobody did. Every + // string over the bracket alphabet is cheap enough to just enumerate. + void testExhaustiveBracketAlphabet() + { + const QList<QChar> alphabet = {QLatin1Char('['), QLatin1Char(']'), QLatin1Char('='), QLatin1Char('a')}; + + QStringList current = {QString()}; + int checked = 0; + for (int length = 1; length <= 5; ++length) { + QStringList next; + for (const QString& prefix : std::as_const(current)) { + for (const QChar letter : std::as_const(alphabet)) { + next.append(prefix + letter); + } + } + current = next; + + for (const QString& payload : std::as_const(current)) { + const QString result = evaluate(LuaLiteral::quote(payload)); + if (result.isNull() || result != payload) { + QFAIL(qPrintable(QStringLiteral("payload %1 did not round-trip; literal was %2").arg(payload, LuaLiteral::quote(payload)))); + } + ++checked; + } + } + + QCOMPARE(checked, 1364); + } + + void testLevelEscalation() + { + // Spelling is an implementation detail, but the escalation rule is + // worth pinning: a payload that cannot terminate level 0 must not pay + // for a higher level. + QVERIFY(LuaLiteral::quote(QStringLiteral("look")).startsWith(QStringLiteral("[["))); + QVERIFY(LuaLiteral::quote(QStringLiteral("a]]b")).startsWith(QStringLiteral("[=["))); + QVERIFY(LuaLiteral::quote(QStringLiteral("a]]b]=]c")).startsWith(QStringLiteral("[==["))); + } +}; + +QTEST_MAIN(LuaLiteralTest) +#include "LuaLiteralTest.moc" diff --git a/test/OAuthClientFlowTest.cpp b/test/OAuthClientFlowTest.cpp index d0bf149c5..af18ff718 100644 --- a/test/OAuthClientFlowTest.cpp +++ b/test/OAuthClientFlowTest.cpp @@ -19,7 +19,6 @@ #include <OAuthClientFlow.h> #include <QtTest/QtTest> -#include <QDesktopServices> #include <QJsonDocument> #include <QJsonObject> #include <QRegularExpression> @@ -96,11 +95,9 @@ class OAuthClientFlowTest : public QObject Q_OBJECT public slots: - void captureBrowserUrl(const QUrl& url) { mBrowserUrl = url; } + void captureAuthorizationUrl(const QUrl& url) { mAuthorizationUrl = url; } private slots: - void initTestCase(); - void cleanupTestCase(); void init(); void testCodeVerifierFormat(); void testCodeVerifierUnique(); @@ -116,22 +113,12 @@ private slots: void testEmptyCodeFailsFlow(); private: - QUrl mBrowserUrl; + QUrl mAuthorizationUrl; }; -void OAuthClientFlowTest::initTestCase() -{ - QDesktopServices::setUrlHandler(QStringLiteral("http"), this, "captureBrowserUrl"); -} - -void OAuthClientFlowTest::cleanupTestCase() -{ - QDesktopServices::unsetUrlHandler(QStringLiteral("http")); -} - void OAuthClientFlowTest::init() { - mBrowserUrl.clear(); + mAuthorizationUrl.clear(); } @@ -199,15 +186,16 @@ void OAuthClientFlowTest::testFullFlowCapturesAuthorizationCode() { MiniDiscoveryServer discovery(QStringLiteral("http://127.0.0.1:1/authorize")); OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy capturedSpy(&flow, &OAuthClientFlow::authorizationCaptured); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); - QSignalSpy openedSpy(&flow, &OAuthClientFlow::browserOpened); + QSignalSpy urlReadySpy(&flow, &OAuthClientFlow::authorizationUrlReady); flow.start(discovery.discoveryUrl(), QStringLiteral("test-client"), {QStringLiteral("openid")}, true); - QTRY_VERIFY(!mBrowserUrl.isEmpty()); - QCOMPARE(openedSpy.count(), 1); + QTRY_VERIFY(!mAuthorizationUrl.isEmpty()); + QCOMPARE(urlReadySpy.count(), 1); - const QUrlQuery query(mBrowserUrl); + const QUrlQuery query(mAuthorizationUrl); QCOMPARE(query.queryItemValue(QStringLiteral("response_type")), QStringLiteral("code")); QCOMPARE(query.queryItemValue(QStringLiteral("client_id")), QStringLiteral("test-client")); QCOMPARE(query.queryItemValue(QStringLiteral("scope"), QUrl::FullyDecoded), QStringLiteral("openid")); @@ -231,6 +219,7 @@ void OAuthClientFlowTest::testFullFlowCapturesAuthorizationCode() QCOMPARE(args.at(0).toString(), QStringLiteral("test-auth-code")); QCOMPARE(OAuthClientFlow::codeChallengeS256(args.at(1).toString()), challenge); QCOMPARE(args.at(2).toString(), redirectUri.toString()); + QCOMPARE(args.at(3).toString(), query.queryItemValue(QStringLiteral("nonce"))); QCOMPARE(failedSpy.count(), 0); // Wait for the full status line (peek does not consume) so a split packet cannot yield a partial read. @@ -241,12 +230,13 @@ void OAuthClientFlowTest::testStateMismatchFailsFlow() { MiniDiscoveryServer discovery(QStringLiteral("http://127.0.0.1:1/authorize")); OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy capturedSpy(&flow, &OAuthClientFlow::authorizationCaptured); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); flow.start(discovery.discoveryUrl(), QStringLiteral("test-client"), {QStringLiteral("openid")}, false); - QTRY_VERIFY(!mBrowserUrl.isEmpty()); - const QUrlQuery query(mBrowserUrl); + QTRY_VERIFY(!mAuthorizationUrl.isEmpty()); + const QUrlQuery query(mAuthorizationUrl); const QUrl redirectUri(query.queryItemValue(QStringLiteral("redirect_uri"), QUrl::FullyDecoded)); QTcpSocket browser; @@ -265,12 +255,13 @@ void OAuthClientFlowTest::testNonRedirectRequestIgnored() { MiniDiscoveryServer discovery(QStringLiteral("http://127.0.0.1:1/authorize")); OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy capturedSpy(&flow, &OAuthClientFlow::authorizationCaptured); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); flow.start(discovery.discoveryUrl(), QStringLiteral("test-client"), {QStringLiteral("openid")}, false); - QTRY_VERIFY(!mBrowserUrl.isEmpty()); - const QUrlQuery query(mBrowserUrl); + QTRY_VERIFY(!mAuthorizationUrl.isEmpty()); + const QUrlQuery query(mAuthorizationUrl); const QUrl redirectUri(query.queryItemValue(QStringLiteral("redirect_uri"), QUrl::FullyDecoded)); // A browser side-request (no code/error) must be answered without ending the flow. @@ -294,34 +285,41 @@ void OAuthClientFlowTest::testNonRedirectRequestIgnored() void OAuthClientFlowTest::testDiscoveryFetchFailureFailsFlow() { OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); // A server that accepts the connection then closes it immediately guarantees the discovery // fetch fails deterministically, rather than relying on a host refusing a particular port. MiniClosingServer discovery; flow.start(discovery.discoveryUrl(), QStringLiteral("test-client"), {QStringLiteral("openid")}, false); QTRY_COMPARE_WITH_TIMEOUT(failedSpy.count(), 1, 15000); + // A failed discovery fetch must not have produced an authorization URL. + QVERIFY(mAuthorizationUrl.isEmpty()); } void OAuthClientFlowTest::testNonLoopbackHttpDiscoveryUrlRejected() { OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); // Plain http is only acceptable for loopback hosts; anything else must be refused // before any network activity happens. flow.start(QUrl(QStringLiteral("http://example.com/.well-known/openid-configuration")), QStringLiteral("test-client"), {QStringLiteral("openid")}, false); QCOMPARE(failedSpy.count(), 1); + // The rejected discovery URL must not have produced an authorization URL. + QVERIFY(mAuthorizationUrl.isEmpty()); } void OAuthClientFlowTest::testProviderErrorFailsFlow() { MiniDiscoveryServer discovery(QStringLiteral("http://127.0.0.1:1/authorize")); OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy capturedSpy(&flow, &OAuthClientFlow::authorizationCaptured); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); flow.start(discovery.discoveryUrl(), QStringLiteral("test-client"), {QStringLiteral("openid")}, false); - QTRY_VERIFY(!mBrowserUrl.isEmpty()); - const QUrlQuery query(mBrowserUrl); + QTRY_VERIFY(!mAuthorizationUrl.isEmpty()); + const QUrlQuery query(mAuthorizationUrl); const QUrl redirectUri(query.queryItemValue(QStringLiteral("redirect_uri"), QUrl::FullyDecoded)); QTcpSocket browser; @@ -337,12 +335,13 @@ void OAuthClientFlowTest::testEmptyCodeFailsFlow() { MiniDiscoveryServer discovery(QStringLiteral("http://127.0.0.1:1/authorize")); OAuthClientFlow flow; + connect(&flow, &OAuthClientFlow::authorizationUrlReady, this, &OAuthClientFlowTest::captureAuthorizationUrl); QSignalSpy capturedSpy(&flow, &OAuthClientFlow::authorizationCaptured); QSignalSpy failedSpy(&flow, &OAuthClientFlow::flowFailed); flow.start(discovery.discoveryUrl(), QStringLiteral("test-client"), {QStringLiteral("openid")}, false); - QTRY_VERIFY(!mBrowserUrl.isEmpty()); - const QUrlQuery query(mBrowserUrl); + QTRY_VERIFY(!mAuthorizationUrl.isEmpty()); + const QUrlQuery query(mAuthorizationUrl); const QUrl redirectUri(query.queryItemValue(QStringLiteral("redirect_uri"), QUrl::FullyDecoded)); // A redirect with a matching state but an empty code (code= present but blank) must fail, not diff --git a/test/ProfileNameValidationTest.cpp b/test/ProfileNameValidationTest.cpp new file mode 100644 index 000000000..6938e0f34 --- /dev/null +++ b/test/ProfileNameValidationTest.cpp @@ -0,0 +1,187 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "dlgConnectionProfiles.h" +#include "utils.h" + +#include <QDir> +#include <QtTest/QtTest> + +/* + * Tests for the profile name character validation used by the connection + * dialog. Guards the character-set half of the fix for profile folders + * duplicated outside of Mudlet (e.g. a file manager appending " (2)" to a + * copied folder), which Mudlet must not reject - doing so greys out the + * Connect/Offline buttons - and the rule that a name has to address a folder + * of its own, as "." and ".." name the profiles directory and Mudlet's + * configuration directory instead. The on-disk exemption half is covered by + * ProfileFolderNameTest, and the deletion path by ProfileDeletionSafetyTest. + */ +class ProfileNameValidationTest : public QObject +{ + Q_OBJECT + +private slots: + void acceptableNames_data() + { + QTest::addColumn<QString>("name"); + + QTest::newRow("plain") << qsl("Achaea"); + QTest::newRow("leading digit") << qsl("3Scapes"); + QTest::newRow("default new name") << qsl("new profile name"); + QTest::newRow("parenthesised copy suffix") << qsl("test (2)"); + QTest::newRow("parenthesised word") << qsl("StickMUD (backup)"); + QTest::newRow("windows copy suffix") << qsl("test - Copy"); + QTest::newRow("all punctuation") << qsl("a.b_c-d#e&f (g)"); + QTest::newRow("empty") << QString(); + } + + void acceptableNames() + { + QFETCH(QString, name); + QVERIFY(dlgConnectionProfiles::firstInvalidProfileNameChar(name).isNull()); + } + + void rejectedNames_data() + { + QTest::addColumn<QString>("name"); + QTest::addColumn<QChar>("badChar"); + + QTest::newRow("path separator") << qsl("test/2") << QChar('/'); + QTest::newRow("windows path separator") << qsl("test\\2") << QChar('\\'); + QTest::newRow("first of several invalid") << qsl("a/b:c") << QChar('/'); + QTest::newRow("windows drive colon") << qsl("test:2") << QChar(':'); + QTest::newRow("double quote") << qsl("test\"2") << QChar('"'); + QTest::newRow("tab") << qsl("test\t2") << QChar('\t'); + QTest::newRow("non-ascii") << qsl("café") << QChar(0x00e9); + } + + void rejectedNames() + { + QFETCH(QString, name); + QFETCH(QChar, badChar); + QCOMPARE(dlgConnectionProfiles::firstInvalidProfileNameChar(name), badChar); + } + + // Folders created outside of Mudlet keep their name only if the rest of + // Mudlet can work with it - notably CredentialManager, which returns an + // empty path rather than a sanitised one for these, leaving the profile + // unable to store or retrieve its password. + void usableAsIs_data() + { + QTest::addColumn<QString>("name"); + QTest::addColumn<bool>("usable"); + + QTest::newRow("plain") << qsl("Achaea") << true; + QTest::newRow("parenthesised copy suffix") << qsl("test (2)") << true; + // not permitted for a new name, but harmless on disk and in a + // credential path, so an existing folder keeps it + QTest::newRow("non-ascii") << qsl("café") << true; + QTest::newRow("exclamation mark") << qsl("test!") << true; + QTest::newRow("single dot") << qsl("my.profile") << true; + QTest::newRow("version number") << qsl("Achaea 2.0") << true; + + QTest::newRow("empty") << QString() << false; + QTest::newRow("current directory") << qsl(".") << false; + QTest::newRow("parent directory") << qsl("..") << false; + QTest::newRow("parent directory in a path") << qsl("../..") << false; + QTest::newRow("embedded parent directory") << qsl("test..2") << false; + QTest::newRow("path separator") << qsl("test/2") << false; + QTest::newRow("windows path separator") << qsl("test\\2") << false; + QTest::newRow("colon") << qsl("test:2") << false; + QTest::newRow("pipe") << qsl("test|2") << false; + QTest::newRow("asterisk") << qsl("test*2") << false; + QTest::newRow("question mark") << qsl("test?2") << false; + QTest::newRow("angle brackets") << qsl("<test>") << false; + QTest::newRow("double quote") << qsl("test\"2") << false; + QTest::newRow("control character") << qsl("test\x01 2") << false; + } + + void usableAsIs() + { + QFETCH(QString, name); + QFETCH(bool, usable); + QCOMPARE(dlgConnectionProfiles::profileNameUsableAsIs(name), usable); + } + + // No path at all means nothing for the deletion to act on + void folderPath_data() + { + QTest::addColumn<QString>("name"); + QTest::addColumn<QString>("expectedPath"); + + const QString profilesPath = qsl("/home/user/.config/mudlet/profiles"); + + QTest::newRow("plain") << qsl("Achaea") << qsl("%1/Achaea").arg(profilesPath); + QTest::newRow("version number") << qsl("Achaea 2.0") << qsl("%1/Achaea 2.0").arg(profilesPath); + QTest::newRow("parentheses") << qsl("test (2)") << qsl("%1/test (2)").arg(profilesPath); + QTest::newRow("non-ascii") << qsl("café") << qsl("%1/café").arg(profilesPath); + QTest::newRow("cyrillic") << qsl("Мудлет") << qsl("%1/Мудлет").arg(profilesPath); + QTest::newRow("leading dot") << qsl(".hidden") << qsl("%1/.hidden").arg(profilesPath); + + QTest::newRow("current directory") << qsl(".") << QString(); + QTest::newRow("parent directory") << qsl("..") << QString(); + QTest::newRow("grandparent directory") << qsl("../..") << QString(); + QTest::newRow("traversal back in") << qsl("../profiles/Achaea") << QString(); + QTest::newRow("nested") << qsl("Achaea/current") << QString(); + QTest::newRow("windows separator") << qsl("Achaea\\current") << QString(); + QTest::newRow("absolute path") << qsl("/etc") << QString(); + QTest::newRow("empty") << QString() << QString(); + } + + void folderPath() + { + QFETCH(QString, name); + QFETCH(QString, expectedPath); + QCOMPARE(dlgConnectionProfiles::profileFolderPath(qsl("/home/user/.config/mudlet/profiles"), name), expectedPath); + } + + // The profiles directory is a native path, so every platform's root counts + void folderPathHandlesNativeRoots_data() + { + QTest::addColumn<QString>("profilesPath"); + + QTest::newRow("posix") << qsl("/home/user/.config/mudlet/profiles"); + QTest::newRow("windows drive") << qsl("C:/Users/user/.config/mudlet/profiles"); + QTest::newRow("windows unc") << qsl("//server/share/mudlet/profiles"); + QTest::newRow("macos") << qsl("/Users/user/Library/Application Support/mudlet/profiles"); + } + + void folderPathHandlesNativeRoots() + { + QFETCH(QString, profilesPath); + + // a UNC root keeps its leading "//" on Windows and loses it elsewhere, + // so compare against the cleaned root rather than what was passed in + const QString root = QDir::cleanPath(profilesPath); + QCOMPARE(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("Achaea")), qsl("%1/Achaea").arg(root)); + QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl(".")).isEmpty()); + QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("..")).isEmpty()); + QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("../..")).isEmpty()); + } + + void folderPathIgnoresTrailingSeparator() + { + QCOMPARE(dlgConnectionProfiles::profileFolderPath(qsl("/home/user/.config/mudlet/profiles/"), qsl("Achaea")), qsl("/home/user/.config/mudlet/profiles/Achaea")); + QCOMPARE(dlgConnectionProfiles::profileFolderPath(qsl("/home/user/.config/mudlet/profiles/"), qsl(".")), QString()); + } +}; + +QTEST_GUILESS_MAIN(ProfileNameValidationTest) +#include "ProfileNameValidationTest.moc" diff --git a/test/README.md b/test/README.md index 7463e9c4a..6449cead2 100644 --- a/test/README.md +++ b/test/README.md @@ -79,7 +79,7 @@ QTEST_MAIN(MyComponentTest) ### Adding a New Test -1. **Create Test File**: Create `YourTestName.cpp` in the `test/` directory +1. **Create Test File**: Create `YourTestName.cpp` in the `test/` directory. Keep the words `install`, `uninstall`, `setup`, `update` and `patch` out of the name: Windows takes an unsigned executable named that way for an installer and will not start it from an ordinary, non-elevated Windows shell. CMake rejects such a name at configure time. 2. **Implement Test Class**: Follow the structure above, inheriting from `QObject` and using `Q_OBJECT` macro diff --git a/test/ReleaseChecksumPairingTest.cpp b/test/ReleaseChecksumPairingTest.cpp new file mode 100644 index 000000000..2f913948e --- /dev/null +++ b/test/ReleaseChecksumPairingTest.cpp @@ -0,0 +1,230 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "../src/updater/Feed.h" +#include "../src/updater/Release.h" + +#include <QtTest/QtTest> + +#include <QJsonArray> +#include <QJsonObject> + +/* + * Covers the pairing of a release's assets with its SHA256SUMS.txt, which is what + * decides whether an update can be installed at all. + * + * Windows auto-update broke on the 2026-08-01 PTB: the release carried five + * binaries but a SHA256SUMS.txt covering only four, and the uncovered one was the + * .exe the Windows updater picks. Feed refuses to install a download it cannot + * verify, so users got "Could not verify the integrity of the download" and no + * update. The data below is that release's real asset list and checksum file. + * + * The publishing side of the fix - never overwriting SHA256SUMS.txt with a file + * that covers fewer binaries - is covered by test/ci/release-checksums-test.sh. + */ + +namespace { +const auto tag = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-01-dfdcb137"); +const auto windowsAsset = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-01-dfdcb137-windows-64.exe"); +const auto linuxAsset = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-01-dfdcb137-linux-x64.AppImage.tar"); +const auto arm64Asset = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-01-dfdcb137-arm64.dmg"); +const auto intelMacAsset = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-01-dfdcb137-x86_64.dmg"); +// A re-run of the Windows build restamped the date and appended a rebuild counter +const auto rebuiltWindowsAsset = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-02-dfdcb137rebuild2-windows-64.exe"); + +const auto windowsHash = QStringLiteral("72dba076741a245a994b553b1cf88dba5d7f5bb07f0d6887ecd58361402764e1"); +const auto linuxHash = QStringLiteral("72a239146b07fc3b94f9e96955d2d6d52a6f0f40c8a5b7ee314b0bf875a55611"); +const auto arm64Hash = QStringLiteral("7a7a75723e15a3443c4e8937fe2a85cee90b9dc8938e1e0580887e5c5e1fabbb"); +const auto intelMacHash = QStringLiteral("d2426d7f799b619b0bfcb1e51e373f776288f77584bc9f08a79cc288cc58278c"); +const auto rebuiltWindowsHash = QStringLiteral("9b3895247937d37f645dd31e7ded739e3126ccad6bfa4188cdd3b78f735c2f6f"); + +// Exactly what the 2026-08-01 PTB shipped: the .exe on the release is absent and a +// different build's .exe is listed in its place +QString publishedChecksums() +{ + return QStringLiteral("%1 %2\n%3 %4\n%5 *%6\n%7 %8\n").arg(linuxHash, linuxAsset, intelMacHash, intelMacAsset, rebuiltWindowsHash, rebuiltWindowsAsset, arm64Hash, arm64Asset); +} + +// What the release should have shipped, and does once the publishing scripts merge +// instead of overwrite +QString mergedChecksums() +{ + return publishedChecksums() + QStringLiteral("%1 *%2\n").arg(windowsHash, windowsAsset); +} + +// The 2026-08-01 PTB's assets, in the order the GitHub releases API returns them - +// which is what decided the bug: had the API listed the rebuilt .exe first, the +// updater would have chosen the one that *was* covered. published_at and size are +// filler, only the tag and the asset names and order are the release's real values. +QJsonObject releaseJson() +{ + const QStringList assetNames{arm64Asset, linuxAsset, windowsAsset, intelMacAsset, rebuiltWindowsAsset, QStringLiteral("SHA256SUMS.txt")}; + + QJsonArray assets; + for (const auto& name : assetNames) { + QJsonObject asset; + asset.insert(QStringLiteral("name"), name); + asset.insert(QStringLiteral("browser_download_url"), QStringLiteral("https://github.com/Mudlet/Mudlet/releases/download/%1/%2").arg(tag, name)); + asset.insert(QStringLiteral("size"), 137252032); + assets.append(asset); + } + + QJsonObject release; + release.insert(QStringLiteral("tag_name"), tag); + release.insert(QStringLiteral("published_at"), QStringLiteral("2026-07-31T18:22:33Z")); + release.insert(QStringLiteral("prerelease"), true); + release.insert(QStringLiteral("draft"), false); + release.insert(QStringLiteral("assets"), assets); + return release; +} +} // namespace + +class ReleaseChecksumPairingTest : public QObject +{ + Q_OBJECT + +private slots: + void windowsDownloadIsThePlatformExe(); + void publishedChecksumsDoNotCoverTheWindowsDownload(); + void mergedChecksumsCoverTheWindowsDownload(); + void everyOtherPlatformWasAlreadyCovered(); + void binaryAndTextModeLinesBothParse(); + void anotherBuildsEntryDoesNotCoverThisDownload(); + void aLongerNameContainingThisOneDoesNotCoverIt(); + void aPathPrefixedEntryStillCoversTheDownload(); + void malformedLinesAreIgnored(); + void emptyInputsYieldNoChecksum(); + void entriesParsedTellsAnUnreadableFileFromAMissingEntry(); +}; + +// The updater picks the first asset matching its platform, so this is the file +// whose checksum has to be present +void ReleaseChecksumPairingTest::windowsDownloadIsThePlatformExe() +{ + const dblsqd::Release release(releaseJson(), QStringLiteral("win"), QStringLiteral("x86_64")); + + QCOMPARE(release.getDownloadUrl().fileName(), windowsAsset); + QCOMPARE(release.getChecksumsUrl().fileName(), QStringLiteral("SHA256SUMS.txt")); +} + +// The regression: this is why Windows auto-update failed +void ReleaseChecksumPairingTest::publishedChecksumsDoNotCoverTheWindowsDownload() +{ + const dblsqd::Release release(releaseJson(), QStringLiteral("win"), QStringLiteral("x86_64")); + + QVERIFY(dblsqd::Feed::findChecksum(publishedChecksums(), release.getDownloadUrl().fileName()).isEmpty()); +} + +void ReleaseChecksumPairingTest::mergedChecksumsCoverTheWindowsDownload() +{ + const dblsqd::Release release(releaseJson(), QStringLiteral("win"), QStringLiteral("x86_64")); + + QCOMPARE(dblsqd::Feed::findChecksum(mergedChecksums(), release.getDownloadUrl().fileName()), windowsHash); +} + +void ReleaseChecksumPairingTest::everyOtherPlatformWasAlreadyCovered() +{ + const dblsqd::Release linuxRelease(releaseJson(), QStringLiteral("linux"), QStringLiteral("x86_64")); + QCOMPARE(linuxRelease.getDownloadUrl().fileName(), linuxAsset); + QCOMPARE(dblsqd::Feed::findChecksum(publishedChecksums(), linuxRelease.getDownloadUrl().fileName()), linuxHash); + + const dblsqd::Release intelMacRelease(releaseJson(), QStringLiteral("mac"), QStringLiteral("x86_64")); + QCOMPARE(intelMacRelease.getDownloadUrl().fileName(), intelMacAsset); + QCOMPARE(dblsqd::Feed::findChecksum(publishedChecksums(), intelMacRelease.getDownloadUrl().fileName()), intelMacHash); + + const dblsqd::Release appleSiliconRelease(releaseJson(), QStringLiteral("mac"), QStringLiteral("arm64")); + QCOMPARE(appleSiliconRelease.getDownloadUrl().fileName(), arm64Asset); + QCOMPARE(dblsqd::Feed::findChecksum(publishedChecksums(), appleSiliconRelease.getDownloadUrl().fileName()), arm64Hash); +} + +// sha256sum writes two spaces in text mode and " *" in binary mode; the Windows +// build produces the latter +void ReleaseChecksumPairingTest::binaryAndTextModeLinesBothParse() +{ + QCOMPARE(dblsqd::Feed::findChecksum(QStringLiteral("%1 *%2").arg(windowsHash, windowsAsset), windowsAsset), windowsHash); + QCOMPARE(dblsqd::Feed::findChecksum(QStringLiteral("%1 %2").arg(windowsHash, windowsAsset), windowsAsset), windowsHash); + QCOMPARE(dblsqd::Feed::findChecksum(QStringLiteral("%1\t%2").arg(windowsHash, windowsAsset), windowsAsset), windowsHash); + // trailing CR from a file written on Windows must not become part of the name + QCOMPARE(dblsqd::Feed::findChecksum(QStringLiteral("%1 *%2\r\n").arg(windowsHash, windowsAsset), windowsAsset), windowsHash); +} + +// The rebuilt installer's entry must not be accepted for a different file, or the +// updater would check the download against the wrong hash +void ReleaseChecksumPairingTest::anotherBuildsEntryDoesNotCoverThisDownload() +{ + const QString rebuiltOnly = QStringLiteral("%1 *%2\n").arg(rebuiltWindowsHash, rebuiltWindowsAsset); + + QVERIFY(dblsqd::Feed::findChecksum(rebuiltOnly, windowsAsset).isEmpty()); + QCOMPARE(dblsqd::Feed::findChecksum(rebuiltOnly, rebuiltWindowsAsset), rebuiltWindowsHash); +} + +// SHA256SUMS.txt accumulates entries across builds, so a name that merely contains +// the download's name must not hand back its hash - the updater would then reject a +// perfectly good download as corrupt +void ReleaseChecksumPairingTest::aLongerNameContainingThisOneDoesNotCoverIt() +{ + const QString longerName = QStringLiteral("old-%1").arg(windowsAsset); + + QVERIFY(dblsqd::Feed::findChecksum(QStringLiteral("%1 *%2\n").arg(rebuiltWindowsHash, longerName), windowsAsset).isEmpty()); + QVERIFY(dblsqd::Feed::findChecksum(QStringLiteral("%1 %2.sha256\n").arg(rebuiltWindowsHash, windowsAsset), windowsAsset).isEmpty()); + QVERIFY(dblsqd::Feed::findChecksum(QStringLiteral("%1 %2.tar\n").arg(rebuiltWindowsHash, linuxAsset), linuxAsset).isEmpty()); +} + +void ReleaseChecksumPairingTest::aPathPrefixedEntryStillCoversTheDownload() +{ + QCOMPARE(dblsqd::Feed::findChecksum(QStringLiteral("%1 *upload/%2\n").arg(windowsHash, windowsAsset), windowsAsset), windowsHash); +} + +void ReleaseChecksumPairingTest::malformedLinesAreIgnored() +{ + // too short, non-hex, and no separator respectively, then the real entry + const QString data = QStringLiteral("abc123 %1\n" + "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz %1\n" + "%2\n" + "%3 *%1\n") + .arg(windowsAsset, windowsHash, windowsHash); + + QCOMPARE(dblsqd::Feed::findChecksum(data, windowsAsset), windowsHash); +} + +void ReleaseChecksumPairingTest::emptyInputsYieldNoChecksum() +{ + QVERIFY(dblsqd::Feed::findChecksum(QString(), windowsAsset).isEmpty()); + QVERIFY(dblsqd::Feed::findChecksum(mergedChecksums(), QString()).isEmpty()); +} + +// A release that forgot one platform and a payload that was never a checksum file +// both yield no hash, but they need different messages +void ReleaseChecksumPairingTest::entriesParsedTellsAnUnreadableFileFromAMissingEntry() +{ + int entriesParsed = -1; + QVERIFY(dblsqd::Feed::findChecksum(publishedChecksums(), windowsAsset, &entriesParsed).isEmpty()); + QCOMPARE(entriesParsed, 4); + + entriesParsed = -1; + QVERIFY(dblsqd::Feed::findChecksum(QStringLiteral("<html><body>503 Service Unavailable</body></html>"), windowsAsset, &entriesParsed).isEmpty()); + QCOMPARE(entriesParsed, 0); + + entriesParsed = -1; + QCOMPARE(dblsqd::Feed::findChecksum(mergedChecksums(), windowsAsset, &entriesParsed), windowsHash); + QCOMPARE(entriesParsed, 5); +} + +#include "ReleaseChecksumPairingTest.moc" +QTEST_MAIN(ReleaseChecksumPairingTest) diff --git a/test/ReleasePlatformAssetTest.cpp b/test/ReleasePlatformAssetTest.cpp new file mode 100644 index 000000000..ed06c757e --- /dev/null +++ b/test/ReleasePlatformAssetTest.cpp @@ -0,0 +1,225 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "../src/updater/Feed.h" +#include "../src/updater/Release.h" + +#include <QtTest/QtTest> + +#include <QJsonArray> +#include <QJsonObject> + +/* + * Covers which releases the updater is willing to offer as an update. + * + * The 2026-08-07 PTB published a Windows installer and nothing else: the Linux + * and macOS build jobs failed, so their assets were never attached. Linux PTB + * users got a red "no download available for your platform" error on the + * console on every check, because the release was offered as an update and the + * download then had nothing to fetch. A partly published release is valid and + * will happen again, so it has to be passed over instead - as does one that + * published a binary but no SHA256SUMS.txt, since the download refuses to + * install what it cannot verify. + */ + +namespace { +// Only windowsOnlyTag is a real release; the complete, unverifiable and +// installed ones are constructed around it +const auto windowsOnlyTag = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-07-eaa991b9"); +const auto unverifiableTag = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-07-5e4d3c2b"); +const auto completeTag = QStringLiteral("Mudlet-4.22.0-ptb-2026-08-06-1a2b3c4d"); +const auto windowsOnlyVersion = QStringLiteral("4.22.0-ptb-2026-08-07-eaa991b9"); +const auto completeVersion = QStringLiteral("4.22.0-ptb-2026-08-06-1a2b3c4d"); +const auto installedVersion = QStringLiteral("4.22.0-ptb-2026-08-05-9f8e7d6c"); + +const auto windowsSuffix = QStringLiteral("-windows-64.exe"); +const auto linuxSuffix = QStringLiteral("-linux-x64.AppImage.tar"); +const auto intelMacSuffix = QStringLiteral("-x86_64.dmg"); +const auto appleSiliconSuffix = QStringLiteral("-arm64.dmg"); + +QJsonObject makeAsset(const QString& tag, const QString& name) +{ + QJsonObject asset; + asset.insert(QStringLiteral("name"), name); + asset.insert(QStringLiteral("browser_download_url"), QStringLiteral("https://github.com/Mudlet/Mudlet/releases/download/%1/%2").arg(tag, name)); + asset.insert(QStringLiteral("size"), 137252032); + return asset; +} + +QJsonObject releaseJson(const QString& tag, const QString& publishedAt, const QStringList& assetSuffixes, bool withChecksums = true) +{ + QJsonArray assets; + for (const auto& suffix : assetSuffixes) { + assets.append(makeAsset(tag, QStringLiteral("%1%2").arg(tag, suffix))); + } + if (withChecksums) { + assets.append(makeAsset(tag, QStringLiteral("SHA256SUMS.txt"))); + } + + QJsonObject release; + release.insert(QStringLiteral("tag_name"), tag); + release.insert(QStringLiteral("published_at"), publishedAt); + release.insert(QStringLiteral("prerelease"), true); + release.insert(QStringLiteral("draft"), false); + release.insert(QStringLiteral("body"), QStringLiteral("- fixed a thing\n")); + release.insert(QStringLiteral("assets"), assets); + return release; +} + +QJsonObject windowsOnlyRelease() +{ + return releaseJson(windowsOnlyTag, QStringLiteral("2026-08-07T02:14:11Z"), {windowsSuffix}); +} + +// Published its Linux binary, but not the checksums that binary is verified against +QJsonObject unverifiableRelease() +{ + return releaseJson(unverifiableTag, QStringLiteral("2026-08-07T04:22:09Z"), {linuxSuffix}, /*withChecksums=*/false); +} + +QJsonObject completeRelease() +{ + return releaseJson(completeTag, QStringLiteral("2026-08-06T02:11:47Z"), {windowsSuffix, linuxSuffix, intelMacSuffix, appleSiliconSuffix}); +} + +// Newest first, the order Feed sorts its releases into +QList<dblsqd::Release> feedReleases(const QString& os, const QString& arch) +{ + return {dblsqd::Release(windowsOnlyRelease(), os, arch), dblsqd::Release(completeRelease(), os, arch)}; +} + +dblsqd::Release installedRelease() +{ + return dblsqd::Release(installedVersion, QDateTime::fromString(QStringLiteral("2026-08-05T02:09:03Z"), Qt::ISODate)); +} +} // namespace + +class ReleasePlatformAssetTest : public QObject +{ + Q_OBJECT + +private slots: + void linuxIsNotOfferedTheReleaseWithoutALinuxAsset(); + void windowsIsStillOfferedIt(); + void intelMacIsNotOfferedTheReleaseWithoutADmg(); + void appleSiliconIsNotOfferedTheReleaseWithoutADmg(); + void aReleaseWithOnlyItsChecksumsFileIsNotOffered(); + void aReleaseWithoutChecksumsIsNotOffered(); + void theVerifiableReleaseIsOfferedInsteadOfTheNewerUnverifiableOne(); + void aPlatformWithNoAssetsAtAllIsOfferedNothing(); + void nothingIsOfferedOnceTheOnlyNewerReleaseIsIncomplete(); + void thePassedOverReleaseStaysReadableForTheChangelog(); +}; + +// The regression: this is the update that produced a red console error twice a day +void ReleasePlatformAssetTest::linuxIsNotOfferedTheReleaseWithoutALinuxAsset() +{ + const auto updates = dblsqd::Feed::selectUpdates(feedReleases(QStringLiteral("linux"), QStringLiteral("x86_64")), installedRelease()); + + QCOMPARE(updates.size(), 1); + QCOMPARE(updates.first().getVersion(), completeVersion); + QVERIFY(updates.first().getDownloadUrl().fileName().endsWith(linuxSuffix)); +} + +void ReleasePlatformAssetTest::windowsIsStillOfferedIt() +{ + const auto updates = dblsqd::Feed::selectUpdates(feedReleases(QStringLiteral("win"), QStringLiteral("x86_64")), installedRelease()); + + QCOMPARE(updates.size(), 2); + QCOMPARE(updates.first().getVersion(), windowsOnlyVersion); + QCOMPARE(updates.last().getVersion(), completeVersion); +} + +void ReleasePlatformAssetTest::intelMacIsNotOfferedTheReleaseWithoutADmg() +{ + const auto updates = dblsqd::Feed::selectUpdates(feedReleases(QStringLiteral("mac"), QStringLiteral("x86_64")), installedRelease()); + + QCOMPARE(updates.size(), 1); + QCOMPARE(updates.first().getVersion(), completeVersion); + QVERIFY(updates.first().getDownloadUrl().fileName().endsWith(intelMacSuffix)); +} + +void ReleasePlatformAssetTest::appleSiliconIsNotOfferedTheReleaseWithoutADmg() +{ + const auto updates = dblsqd::Feed::selectUpdates(feedReleases(QStringLiteral("mac"), QStringLiteral("arm64")), installedRelease()); + + QCOMPARE(updates.size(), 1); + QCOMPARE(updates.first().getVersion(), completeVersion); + QVERIFY(updates.first().getDownloadUrl().fileName().endsWith(appleSiliconSuffix)); +} + +// A release whose binaries are still uploading looks the same as one that lost a build job +void ReleasePlatformAssetTest::aReleaseWithOnlyItsChecksumsFileIsNotOffered() +{ + const QList<dblsqd::Release> releases{dblsqd::Release(releaseJson(windowsOnlyTag, QStringLiteral("2026-08-07T02:14:11Z"), {}), QStringLiteral("linux"), QStringLiteral("x86_64"))}; + + QVERIFY(dblsqd::Feed::selectUpdates(releases, installedRelease()).isEmpty()); +} + +// The download refuses to install what it cannot verify, so a release whose +// SHA256SUMS.txt is missing is as uninstallable as one missing its binary +void ReleasePlatformAssetTest::aReleaseWithoutChecksumsIsNotOffered() +{ + const QList<dblsqd::Release> releases{dblsqd::Release(unverifiableRelease(), QStringLiteral("linux"), QStringLiteral("x86_64"))}; + + QVERIFY(dblsqd::Feed::selectUpdates(releases, installedRelease()).isEmpty()); +} + +void ReleasePlatformAssetTest::theVerifiableReleaseIsOfferedInsteadOfTheNewerUnverifiableOne() +{ + const QList<dblsqd::Release> releases{dblsqd::Release(unverifiableRelease(), QStringLiteral("linux"), QStringLiteral("x86_64")), + dblsqd::Release(completeRelease(), QStringLiteral("linux"), QStringLiteral("x86_64"))}; + + const auto updates = dblsqd::Feed::selectUpdates(releases, installedRelease()); + + QCOMPARE(updates.size(), 1); + QCOMPARE(updates.first().getVersion(), completeVersion); +} + +// Mudlet publishes no binaries for the platforms it is packaged for by others, +// so those builds are told there is no update rather than shown a failure they +// can do nothing about, twice a day, forever +void ReleasePlatformAssetTest::aPlatformWithNoAssetsAtAllIsOfferedNothing() +{ + QVERIFY(dblsqd::Feed::selectUpdates(feedReleases(QStringLiteral("freebsd"), QStringLiteral("x86_64")), installedRelease()).isEmpty()); +} + +// What a Linux user on the previous PTB sees: no update, rather than an error +void ReleasePlatformAssetTest::nothingIsOfferedOnceTheOnlyNewerReleaseIsIncomplete() +{ + const QList<dblsqd::Release> releases{dblsqd::Release(windowsOnlyRelease(), QStringLiteral("linux"), QStringLiteral("x86_64"))}; + const dblsqd::Release installed(completeVersion, QDateTime::fromString(QStringLiteral("2026-08-06T02:11:47Z"), Qt::ISODate)); + + QVERIFY(dblsqd::Feed::selectUpdates(releases, installed).isEmpty()); +} + +// A release passed over here still has to carry its version and notes: the +// changelog dialogs render the unfiltered release list +// (UpdateDialog::generateChangelogDocument) +void ReleasePlatformAssetTest::thePassedOverReleaseStaysReadableForTheChangelog() +{ + const dblsqd::Release release(windowsOnlyRelease(), QStringLiteral("linux"), QStringLiteral("x86_64")); + + QVERIFY(release.getDownloadUrl().isEmpty()); + QCOMPARE(release.getVersion(), windowsOnlyVersion); + QCOMPARE(release.getChangelog(), QStringLiteral("- fixed a thing\n")); +} + +#include "ReleasePlatformAssetTest.moc" +QTEST_MAIN(ReleasePlatformAssetTest) diff --git a/test/SecureStringUtilsTest.cpp b/test/SecureStringUtilsTest.cpp index 7b5aed37b..14321591b 100644 --- a/test/SecureStringUtilsTest.cpp +++ b/test/SecureStringUtilsTest.cpp @@ -143,22 +143,17 @@ void SecureStringUtilsTest::testSecureMemoryClearing() QString testString = "sensitive_data"; QString originalContent = testString; - // Clear the string SecureStringUtils::secureStringClear(testString); - - // String should be empty after clearing QVERIFY(testString.isEmpty()); QVERIFY(testString != originalContent); - - // Test QByteArray clearing + QByteArray testArray = "sensitive_bytes"; QByteArray originalArray = testArray; - + SecureStringUtils::secureByteArrayClear(testArray); QVERIFY(testArray.isEmpty()); QVERIFY(testArray != originalArray); - // Test std::string clearing std::string testStdString = "sensitive_std_data"; std::string originalStdString = testStdString; diff --git a/test/TKeySequenceEditTest.cpp b/test/TKeySequenceEditTest.cpp index b9e006555..e90020032 100644 --- a/test/TKeySequenceEditTest.cpp +++ b/test/TKeySequenceEditTest.cpp @@ -25,6 +25,26 @@ #include <QVBoxLayout> #include <QtTest/QtTest> +static constexpr const char* activationUnavailableMessage = "the window never became active, so focus traversal cannot be exercised - this " + "display has no window manager. Run the suite through ctest, or set " + "QT_QPA_PLATFORM=offscreen, or start a window manager such as openbox."; + +// Shared by the two traversal cases, because QSKIP and QFAIL only work from the +// test function itself and the two must not drift apart. ctest sets +// MUDLET_REQUIRE_WINDOW_ACTIVATION because every display it runs against can +// activate a window, so a skip there would be hiding a regression rather than +// reporting an environment - the same floor MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK +// puts under the media tests. +#define REQUIRE_WINDOW_ACTIVATION(window) \ + do { \ + if (!QTest::qWaitForWindowActive(&(window))) { \ + if (qEnvironmentVariableIsSet("MUDLET_REQUIRE_WINDOW_ACTIVATION")) { \ + QFAIL(activationUnavailableMessage); \ + } \ + QSKIP(activationUnavailableMessage); \ + } \ + } while (false) + // Pins the accessibility behaviour that TKeySequenceEdit adds on top of the // stock QKeySequenceEdit (#8873). Key events are sent to the inner QLineEdit // because that is where keyboard focus lives via the focus proxy. @@ -155,7 +175,12 @@ private slots: // The traversal tests need real focus movement: the capture is committed // by the focus-out that the traversal causes, mirroring how the stock - // widget commits in focusOutEvent() when Tab moves focus away. + // widget commits in focusOutEvent() when Tab moves focus away. Qt only + // delivers those focus events while the window is active, and nothing + // activates a window on an X server without a window manager, so on such a + // display these two cases are skipped rather than failed (#9575). Under + // ctest they never get that far: the offscreen platform is pinned there, + // and it synthesises activation. void shiftBacktabCommitsCaptureAndMovesFocusBackwards() { QWidget window; @@ -165,7 +190,7 @@ private slots: layout->addWidget(neighbour); layout->addWidget(edit); window.show(); - QVERIFY(QTest::qWaitForWindowActive(&window)); + REQUIRE_WINDOW_ACTIVATION(window); edit->setFocus(); auto* lineEdit = edit->findChild<QLineEdit*>(); @@ -191,7 +216,7 @@ private slots: layout->addWidget(edit); layout->addWidget(neighbour); window.show(); - QVERIFY(QTest::qWaitForWindowActive(&window)); + REQUIRE_WINDOW_ACTIVATION(window); edit->setFocus(); auto* lineEdit = edit->findChild<QLineEdit*>(); diff --git a/test/TLuaInterfaceTest.cpp b/test/TLuaInterfaceTest.cpp index b66cde91d..c95a75adf 100644 --- a/test/TLuaInterfaceTest.cpp +++ b/test/TLuaInterfaceTest.cpp @@ -22,6 +22,8 @@ #include <VarUnit.h> #include <QtTest/QtTest> +#include <memory> + extern "C" { #if defined(INCLUDE_VERSIONED_LUA_HEADERS) #include <lua5.1/lauxlib.h> @@ -35,26 +37,30 @@ extern "C" { } -class TVarTest : public QObject { -Q_OBJECT +class TVarTest : public QObject +{ + Q_OBJECT private: - lua_State* L = luaL_newstate(); - LuaInterface* interface = new LuaInterface(L); + lua_State* L = nullptr; + std::unique_ptr<LuaInterface> interface; private slots: // NOLINT(readability-redundant-access-specifiers) void init() { L = luaL_newstate(); - interface = new LuaInterface(L); + interface = std::make_unique<LuaInterface>(L); } - void cleanup() { + void cleanup() + { + interface.reset(); lua_close(L); } - void execLua(const QString& string) { + void execLua(const QString& string) + { luaL_loadstring(L, string.toUtf8().constData()); lua_pcall(L, 0, 0, 0); } @@ -84,7 +90,6 @@ private slots: // NOLINT(readability-redundant-access-specifiers) QCOMPARE(testVar->getValue(), "1"); QCOMPARE(testVar->getValueType(), LUA_TNUMBER); } - }; #include "TLuaInterfaceTest.moc" diff --git a/test/TVariableEditorTest.cpp b/test/TVariableEditorTest.cpp index 100fa9588..7d728fe73 100644 --- a/test/TVariableEditorTest.cpp +++ b/test/TVariableEditorTest.cpp @@ -22,6 +22,8 @@ #include <VarUnit.h> #include <QtTest/QtTest> +#include <memory> + extern "C" { #if defined(INCLUDE_VERSIONED_LUA_HEADERS) #include <lua5.1/lauxlib.h> @@ -35,12 +37,13 @@ extern "C" { } -class TVariableEditorTest : public QObject { +class TVariableEditorTest : public QObject +{ Q_OBJECT private: lua_State* L = nullptr; - LuaInterface* interface = nullptr; + std::unique_ptr<LuaInterface> interface; void execLua(const QString& code) { @@ -107,12 +110,12 @@ private slots: { L = luaL_newstate(); luaL_openlibs(L); - interface = new LuaInterface(L); + interface = std::make_unique<LuaInterface>(L); } void cleanup() { - delete interface; + interface.reset(); lua_close(L); } diff --git a/test/UntrustedTextTest.cpp b/test/UntrustedTextTest.cpp new file mode 100644 index 000000000..f08956179 --- /dev/null +++ b/test/UntrustedTextTest.cpp @@ -0,0 +1,188 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "UntrustedText.h" + +#include <QtTest/QtTest> + +/* + * A game server chooses the tooltip, menu labels and menu title of an OSC 8 + * link, and Mudlet shows the link's target URL in the default hint. Bidi + * overrides and zero-width characters let that text claim one target while the + * link carries another, so they are escaped into a visible form before display. + * + * Test inputs spell invisible code points as \uXXXX escapes rather than + * embedding them raw: editors and review tools that strip invisible characters + * have silently deleted them from source before, which turns a real assertion + * into one that passes for the wrong reason. + */ +class UntrustedTextTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() {} + + void testOrdinaryTextUnchanged() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("Open browser to: https://www.mudlet.org")), QStringLiteral("Open browser to: https://www.mudlet.org")); } + + void testNonLatinTextUnchanged() + { + // Sanitization must not damage legitimate non-Latin tooltips. + const QString text = QStringLiteral("你好 مرحبا Здравствуй"); + QCOMPARE(UntrustedText::forTarget(text), text); + } + + void testAstralPlaneTextUnchanged() + { + // Surrogate pairs must round-trip: an emoji is two QChars, one code point. + const QString text = QStringLiteral("a\U0001F600b"); + QCOMPARE(UntrustedText::forTarget(text), text); + } + + void testRightToLeftOverrideEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("mudlet\u202Egro.live")), QStringLiteral("mudlet\\u{202E}gro.live")); } + + void testZeroWidthEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("mud\u200Blet.org")), QStringLiteral("mud\\u{200B}let.org")); } + + void testByteOrderMarkEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\uFEFFb")), QStringLiteral("a\\u{FEFF}b")); } + + void testControlCharactersEscaped() + { + // A newline in a tooltip creates a second visual line that can forge + // trusted-looking UI text below the real target. + QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\nb")), QStringLiteral("a\\u{A}b")); + QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u0085b")), QStringLiteral("a\\u{85}b")); + } + + void testLineSeparatorEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u2028b")), QStringLiteral("a\\u{2028}b")); } + + void testEmptyText() { QCOMPARE(UntrustedText::forTarget(QString()), QString()); } + + void testLiteralEscapeSequenceDisambiguated() + { + // Server text containing the six characters \u{202E} must not display + // the same as a sanitized real U+202E. + QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\\u{202E}b")), QStringLiteral("a\\u{5C}u{202E}b")); + // A backslash not starting a \u{ sequence is left alone. + QCOMPARE(UntrustedText::forTarget(QStringLiteral("C:\\mud\\maps")), QStringLiteral("C:\\mud\\maps")); + } + + // Emoji in tooltips and menu labels are a documented OSC 8 feature and are + // used in the wild. The strict policy escapes the joiners and tag + // characters they are assembled from, so authored text must not use it. + void testAuthoredTextKeepsEmoji_data() + { + QTest::addColumn<QString>("emoji"); + + QTest::newRow("rainbow flag") << QStringLiteral("\U0001F3F3\uFE0F\u200D\U0001F308"); + QTest::newRow("pirate flag") << QStringLiteral("\U0001F3F4\u200D☠\uFE0F"); + QTest::newRow("man cook") << QStringLiteral("\U0001F468\u200D\U0001F373"); + QTest::newRow("family") << QStringLiteral("\U0001F468\u200D\U0001F469\u200D\U0001F467"); + QTest::newRow("scotland flag") << QStringLiteral("\U0001F3F4\U000E0067\U000E0062\U000E0073\U000E0063\U000E0074\U000E007F"); + QTest::newRow("plain emoji") << QStringLiteral("⚔\uFE0F"); + QTest::newRow("skin tone") << QStringLiteral("\U0001F44D\U0001F3FD"); + QTest::newRow("regional flag") << QStringLiteral("\U0001F1EC\U0001F1E7"); + } + + void testAuthoredTextKeepsEmoji() + { + QFETCH(QString, emoji); + QCOMPARE(UntrustedText::forAuthoredText(emoji), emoji); + } + + void testAuthoredTextKeepsPersianShaping() + { + // ZWNJ separates the prefix in this Persian verb; escaping it changes + // how the word is shaped and read. + const QString text = QStringLiteral("می\u200Cرود"); + QCOMPARE(UntrustedText::forAuthoredText(text), text); + } + + void testAuthoredTextEscapesUnusableTagCharacters() + { + // The exception covers only what an emoji flag needs. The deprecated + // language tag and the unassigned code points below it are not that. + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E0001b")), QStringLiteral("a\\u{E0001}b")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E0000b")), QStringLiteral("a\\u{E0000}b")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E001Fb")), QStringLiteral("a\\u{E001F}b")); + // The first assigned tag character is where the exception starts. + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E0020b")), QStringLiteral("a\U000E0020b")); + } + + void testTargetStillEscapesWhatAuthoredTextKeeps() + { + // The same characters must not survive in a link target, where they + // would hide part of what the user is being asked to trust. + QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u200Db")), QStringLiteral("a\\u{200D}b")); + QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u200Cb")), QStringLiteral("a\\u{200C}b")); + QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\U000E0067b")), QStringLiteral("a\\u{E0067}b")); + } + + void testAuthoredTextStillEscapesReordering() + { + // Relaxing the joiners must not relax the characters that let a label + // misrepresent itself or forge a second line of UI. + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("mudlet\u202Egro.live")), QStringLiteral("mudlet\\u{202E}gro.live")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\u2066b")), QStringLiteral("a\\u{2066}b")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\nb")), QStringLiteral("a\\u{A}b")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\u2028b")), QStringLiteral("a\\u{2028}b")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\u200Bb")), QStringLiteral("a\\u{200B}b")); + QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\uFEFFb")), QStringLiteral("a\\u{FEFF}b")); + } + + void testClassification() + { + QVERIFY(UntrustedText::unsafeCharacter(0x202E)); + QVERIFY(UntrustedText::unsafeCharacter(0x200B)); + QVERIFY(UntrustedText::unsafeCharacter(0x0000)); + QVERIFY(UntrustedText::unsafeCharacter(0x009F)); + // Bidi embeddings and overrides, and the isolates that replaced them. + QVERIFY(UntrustedText::unsafeCharacter(0x202A)); + QVERIFY(UntrustedText::unsafeCharacter(0x202D)); + QVERIFY(UntrustedText::unsafeCharacter(0x2066)); + QVERIFY(UntrustedText::unsafeCharacter(0x2069)); + // Arabic letter mark, zero-width joiners and the word joiner. + QVERIFY(UntrustedText::unsafeCharacter(0x061C)); + QVERIFY(UntrustedText::unsafeCharacter(0x200C)); + QVERIFY(UntrustedText::unsafeCharacter(0x200D)); + QVERIFY(UntrustedText::unsafeCharacter(0x2060)); + // Both ends of the invisible-by-design tag character block. + QVERIFY(UntrustedText::unsafeCharacter(0xE0000)); + QVERIFY(UntrustedText::unsafeCharacter(0xE007F)); + QVERIFY(!UntrustedText::unsafeCharacter(0x0041)); + QVERIFY(!UntrustedText::unsafeCharacter(0x00A0)); + QVERIFY(!UntrustedText::unsafeCharacter(0x4F60)); + } + + void testAuthoredClassificationDiffersOnlyWhereIntended() + { + // The authored policy is the strict one minus exactly three things. + for (char32_t codePoint = 0; codePoint <= 0xE0100; ++codePoint) { + const bool relaxed = UntrustedText::unsafeCharacter(codePoint) && !UntrustedText::unsafeAuthoredCharacter(codePoint); + const bool expected = codePoint == 0x200C || codePoint == 0x200D || (codePoint >= 0xE0020 && codePoint <= 0xE007F); + if (relaxed != expected) { + QFAIL(qPrintable(QStringLiteral("policies diverge unexpectedly at U+%1").arg(QString::number(static_cast<uint>(codePoint), 16).toUpper()))); + } + // Authored text may never mark something unsafe that strict does not. + QVERIFY(!(UntrustedText::unsafeAuthoredCharacter(codePoint) && !UntrustedText::unsafeCharacter(codePoint))); + } + } +}; + +QTEST_MAIN(UntrustedTextTest) +#include "UntrustedTextTest.moc" diff --git a/test/XdgRecipeConsistencyTest.cpp b/test/XdgRecipeConsistencyTest.cpp new file mode 100644 index 000000000..15eaaa0aa --- /dev/null +++ b/test/XdgRecipeConsistencyTest.cpp @@ -0,0 +1,380 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * A test that drives setupConfig() has to point XDG_CONFIG_HOME at a temporary + * directory and opt that directory in. Since #9712 the opt-in marker is + * $XDG_CONFIG_HOME/mudlet/profiles - the mudlet directory on its own no longer + * counts, because other tooling creates that by accident - so a test that + * creates only that directory gets the developer's own ~/.config/mudlet instead + * whenever theirs holds profiles or a Mudlet.ini. Where there is no config + * directory to lose the stale recipe still resolves to the temporary one, so + * the mistake hides on exactly the machines it cannot hurt. + * + * Nothing about it fails: the test reads and writes the user's own profiles, + * and some of these tests delete profiles. + * + * So creating a directory whose path ends in /mudlet is an error here, unless + * the same file creates the profiles/ opt-in somewhere. That is deliberately + * coarse - a file isolating two config roots is trusted once it gets one of + * them right. A test that means it says so with an "xdg-recipe-guard: allow" + * comment on the line its call starts on, or the line above. + * + * The path has to be spelled out in the call. A file that builds the config + * root through a helper or a local first is out of range; ConfigDirOverrideTest + * does that, and creates every shape of config root deliberately, the + * resolution rules being its subject. + * + * The test directory is provided at configure time via MUDLET_TEST_DIR. Like + * CMakeListsConsistencyTest this pulls in no Mudlet headers, hence QStringLiteral + * rather than utils.h's qsl(). + * + * Run with: ctest -R XdgRecipeConsistencyTest -V + */ + +#include <QtTest/QtTest> + +#include <QDir> +#include <QFile> +#include <QRegularExpression> +#include <QSet> +#include <QString> +#include <QStringList> +#include <QVector> + +class XdgRecipeConsistencyTest : public QObject +{ + Q_OBJECT + + struct DirectoryCreation + { + int line = 0; + QString argument; + }; + + static QString testDir() { return QStringLiteral(MUDLET_TEST_DIR); } + + static QString allowToken() { return QStringLiteral("xdg-recipe-guard: allow"); } + + // Blanks out comment bodies so a recipe quoted in prose cannot read as code, + // keeping the newlines so line numbers survive. The lines spanned by a + // comment holding the allow token are collected on the way through. + static QString withoutComments(const QString& source, QSet<int>& allowedLines) + { + enum class State { code, lineComment, blockComment, string, character }; + State state = State::code; + QString stripped; + stripped.reserve(source.size()); + QString comment; + int line = 1; + int commentStart = 1; + + auto endComment = [&]() { + if (comment.contains(allowToken())) { + for (int marked = commentStart; marked <= line; ++marked) { + allowedLines.insert(marked); + } + } + comment.clear(); + }; + + for (qsizetype i = 0; i < source.size(); ++i) { + const QChar current = source.at(i); + const QChar next = i + 1 < source.size() ? source.at(i + 1) : QChar(u'\0'); + switch (state) { + case State::code: + if (current == u'/' && (next == u'/' || next == u'*')) { + state = next == u'/' ? State::lineComment : State::blockComment; + commentStart = line; + stripped.append(QStringLiteral(" ")); + ++i; + continue; + } + if (current == u'R' && next == u'"') { + // A raw string carries unbalanced quotes as ordinary text, so + // one read as a normal string desynchronises everything after + // it. Blanked whole rather than parsed: no path is spelled + // this way, and a missed one is only a missed report. + const qsizetype open = source.indexOf(u'(', i + 2); + const QString terminator = open < 0 ? QString() : QStringLiteral(")%1\"").arg(source.mid(i + 2, open - i - 2)); + const qsizetype close = open < 0 ? -1 : source.indexOf(terminator, open); + if (close >= 0) { + for (const qsizetype end = close + terminator.size(); i < end; ++i) { + const QChar skipped = source.at(i); + stripped.append(skipped == u'\n' ? skipped : QChar(u' ')); + if (skipped == u'\n') { + ++line; + } + } + --i; + continue; + } + } + if (current == u'"') { + state = State::string; + } else if (current == u'\'') { + state = State::character; + } + stripped.append(current); + break; + case State::string: + case State::character: + stripped.append(current); + if (current == u'\\' && i + 1 < source.size()) { + stripped.append(next); + ++i; + if (next == u'\n') { + ++line; + } + continue; + } + if ((state == State::string && current == u'"') || (state == State::character && current == u'\'')) { + state = State::code; + } + break; + case State::lineComment: + if (current == u'\n') { + endComment(); + state = State::code; + stripped.append(current); + } else { + comment.append(current); + stripped.append(u' '); + } + break; + case State::blockComment: + if (current == u'*' && next == u'/') { + endComment(); + state = State::code; + stripped.append(QStringLiteral(" ")); + ++i; + continue; + } + comment.append(current); + stripped.append(current == u'\n' ? current : QChar(u' ')); + break; + } + if (current == u'\n') { + ++line; + } + } + if (state == State::lineComment || state == State::blockComment) { + endComment(); + } + return stripped; + } + + static int lineOf(const QString& code, qsizetype offset) { return static_cast<int>(QStringView(code).left(offset).count(u'\n')) + 1; } + + // The argument text of every mkpath()/mkdir() call, found by matching + // parentheses rather than by line, so a call wrapped over several lines and + // one nesting further calls both come out whole. + static QVector<DirectoryCreation> directoryCreations(const QString& code) + { + static const QRegularExpression call(QStringLiteral("\\b(?:mkpath|mkdir)\\s*\\(")); + QVector<DirectoryCreation> creations; + auto matches = call.globalMatch(code); + while (matches.hasNext()) { + const QRegularExpressionMatch match = matches.next(); + const qsizetype start = match.capturedEnd(); + int depth = 1; + QChar quote(u'\0'); + qsizetype end = start; + for (; end < code.size() && depth > 0; ++end) { + const QChar current = code.at(end); + if (quote != QChar(u'\0')) { + if (current == u'\\') { + ++end; + } else if (current == quote) { + quote = QChar(u'\0'); + } + } else if (current == u'"' || current == u'\'') { + quote = current; + } else if (current == u'(') { + ++depth; + } else if (current == u')') { + --depth; + } + } + if (depth > 0) { + continue; + } + creations.append({lineOf(code, match.capturedStart()), code.mid(start, end - 1 - start)}); + } + return creations; + } + + static bool mentionsConfigRoot(const QString& argument) + { + static const QRegularExpression configRoot(QStringLiteral("\"(?:[^\"]*/)?mudlet\"")); + return argument.contains(configRoot); + } + + // The opt-in either spelled in one literal or assembled from two, so that + // filePath("profiles") off a config root counts as much as "%1/mudlet/profiles" + static bool createsOptIn(const QString& argument) + { + static const QRegularExpression optIn(QStringLiteral("\"(?:[^\"]*/)?mudlet/profiles(?:/[^\"]*)?\"")); + static const QRegularExpression profiles(QStringLiteral("\"(?:[^\"]*/)?profiles(?:/[^\"]*)?\"")); + return argument.contains(optIn) || (mentionsConfigRoot(argument) && argument.contains(profiles)); + } + + static bool createsConfigRootOnly(const QString& argument) { return mentionsConfigRoot(argument) && !createsOptIn(argument); } + + static QStringList staleRecipes(const QString& source) + { + QSet<int> allowedLines; + const QString code = withoutComments(source, allowedLines); + const QVector<DirectoryCreation> creations = directoryCreations(code); + + bool optedIn = false; + for (const DirectoryCreation& creation : creations) { + if (createsOptIn(creation.argument)) { + optedIn = true; + break; + } + } + if (optedIn) { + return {}; + } + + QStringList problems; + for (const DirectoryCreation& creation : creations) { + if (!createsConfigRootOnly(creation.argument) || allowedLines.contains(creation.line) || allowedLines.contains(creation.line - 1)) { + continue; + } + problems.append(QStringLiteral("line %1 creates the config root itself (%2) - create its profiles/ subdirectory instead, that is the opt-in") + .arg(QString::number(creation.line), creation.argument.simplified())); + } + return problems; + } + +private slots: + void test_theStaleRecipeIsFlagged() + { + const QString source = QStringLiteral("void initTestCase()\n{\n QVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(mConfigDir.path())));\n}\n"); + const QStringList problems = staleRecipes(source); + QCOMPARE(problems.size(), 1); + QVERIFY2(problems.first().startsWith(QStringLiteral("line 3 ")), qPrintable(problems.first())); + } + + void test_theCurrentRecipeIsAccepted() + { + const QString recipe = QStringLiteral("qsl(\"%1/mudlet/profiles\").arg(mConfigDir.path())"); + QVERIFY(createsOptIn(recipe)); + QVERIFY(!createsConfigRootOnly(recipe)); + const QString source = QStringLiteral("QVERIFY(QDir().mkpath(%1));\n").arg(recipe); + QVERIFY2(staleRecipes(source).isEmpty(), qPrintable(staleRecipes(source).join(QChar(u'\n')))); + } + + void test_aProfileUnderTheOptInIsAccepted() + { + const QString source = QStringLiteral("QVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(dir)));\nQVERIFY(QDir().mkpath(qsl(\"%1/mudlet/profiles/%2\").arg(dir, name)));\n"); + QVERIFY2(staleRecipes(source).isEmpty(), qPrintable(staleRecipes(source).join(QChar(u'\n')))); + } + + void test_theOptInSpelledRelativelyOrAssembledCounts() + { + const QString relative = QStringLiteral("QVERIFY(QDir(root).mkdir(qsl(\"mudlet\")));\nQVERIFY(QDir(root).mkpath(qsl(\"mudlet/profiles\")));\n"); + QVERIFY2(staleRecipes(relative).isEmpty(), qPrintable(staleRecipes(relative).join(QChar(u'\n')))); + + const QString inOneCall = QStringLiteral("QVERIFY(QDir().mkpath(QDir(qsl(\"%1/mudlet\").arg(dir)).filePath(qsl(\"profiles\"))));\n"); + QVERIFY2(staleRecipes(inOneCall).isEmpty(), qPrintable(staleRecipes(inOneCall).join(QChar(u'\n')))); + } + + // Several tests compare the resolved config root against a "%1/mudlet" + // literal, which creates nothing + void test_anAssertionOnTheConfigRootIsNotSeeding() + { + const QString source = QStringLiteral("QCOMPARE(mudlet::getMudletPath(enums::mainPath), qsl(\"%1/mudlet\").arg(mConfigDir.path()));\n"); + QVERIFY2(staleRecipes(source).isEmpty(), qPrintable(staleRecipes(source).join(QChar(u'\n')))); + } + + void test_theRecipeQuotedInACommentIsNotCode() + { + const QString source = QStringLiteral("// never QDir().mkpath(qsl(\"%1/mudlet\").arg(dir))\n/* nor QDir().mkdir(qsl(\"%1/mudlet\")) */\n"); + QVERIFY2(staleRecipes(source).isEmpty(), qPrintable(staleRecipes(source).join(QChar(u'\n')))); + } + + void test_aRawStringCannotDesynchroniseTheScan() + { + const QString source = QStringLiteral("const auto text = R\"(he said \"hi)\";\n" + "// QDir().mkpath(qsl(\"%1/mudlet\").arg(dir))\n" + "QVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(dir)));\n"); + const QStringList problems = staleRecipes(source); + QCOMPARE(problems.size(), 1); + QVERIFY2(problems.first().startsWith(QStringLiteral("line 3 ")), qPrintable(problems.first())); + } + + void test_theOptInElsewhereInTheFileForgivesTheSeed() + { + const QString source = QStringLiteral("QVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(dir)));\nQVERIFY(QDir().mkpath(qsl(\"%1/mudlet/profiles\").arg(dir)));\n"); + QVERIFY2(staleRecipes(source).isEmpty(), qPrintable(staleRecipes(source).join(QChar(u'\n')))); + } + + void test_theAllowTokenExemptsTheCallItSitsOn() + { + const QString onTheLine = QStringLiteral("QVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(dir))); // xdg-recipe-guard: allow, the legacy branch is the subject here\n"); + QVERIFY2(staleRecipes(onTheLine).isEmpty(), qPrintable(staleRecipes(onTheLine).join(QChar(u'\n')))); + + const QString aboveTheLine = QStringLiteral("// xdg-recipe-guard: allow, the legacy branch is the subject here\nQVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(dir)));\n"); + QVERIFY2(staleRecipes(aboveTheLine).isEmpty(), qPrintable(staleRecipes(aboveTheLine).join(QChar(u'\n')))); + + const QString twoLinesAbove = QStringLiteral("// xdg-recipe-guard: allow\n\nQVERIFY(QDir().mkpath(qsl(\"%1/mudlet\").arg(dir)));\n"); + QCOMPARE(staleRecipes(twoLinesAbove).size(), 1); + } + + void test_aMultiLineCallIsStillOneCall() + { + const QString source = QStringLiteral("QVERIFY(QDir().mkpath(\n qsl(\"%1/mudlet\")\n .arg(mConfigDir.path())));\n"); + const QStringList problems = staleRecipes(source); + QCOMPARE(problems.size(), 1); + QVERIFY2(problems.first().startsWith(QStringLiteral("line 1 ")), qPrintable(problems.first())); + } + + void test_everyTestSourceOptsInTheCurrentWay() + { + const QStringList directories = {testDir(), QStringLiteral("%1/functional_tests").arg(testDir())}; + QStringList problems; + int scanned = 0; + for (const QString& directory : directories) { + const QDir dir(directory); + QVERIFY2(dir.exists(), qPrintable(QStringLiteral("no such directory: %1 - is MUDLET_TEST_DIR right?").arg(directory))); + const QStringList sources = dir.entryList({QStringLiteral("*.cpp")}, QDir::Files, QDir::Name); + // This file is scanned along with the rest: its fixtures spell the + // stale recipe out inside string literals, so the sweep staying + // green is what says a quoted recipe does not read as a call. + for (const QString& name : sources) { + QFile source(dir.filePath(name)); + QVERIFY2(source.open(QIODevice::ReadOnly | QIODevice::Text), qPrintable(source.fileName())); + ++scanned; + const QStringList stale = staleRecipes(QString::fromUtf8(source.readAll())); + for (const QString& problem : stale) { + problems.append(QStringLiteral("%1 %2").arg(name, problem)); + } + } + } + QVERIFY2(scanned > 50, qPrintable(QStringLiteral("only %1 sources scanned, so this test would pass whatever they hold").arg(scanned))); + QVERIFY2(problems.isEmpty(), qPrintable(QStringLiteral("tests seeding the pre-#9712 XDG opt-in, which can resolve to the real ~/.config/mudlet:\n%1").arg(problems.join(QChar(u'\n'))))); + } +}; + +QTEST_GUILESS_MAIN(XdgRecipeConsistencyTest) + +#include "XdgRecipeConsistencyTest.moc" diff --git a/test/ci/milestone-resolution-test.sh b/test/ci/milestone-resolution-test.sh new file mode 100755 index 000000000..44c9f261e --- /dev/null +++ b/test/ci/milestone-resolution-test.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# Tests .github/scripts/resolve-milestone.sh, whose whole job is to not fail +# quietly. +# +# It replaces a jq filter that matched a milestone title exactly. The real title +# had picked up a suffix, so it matched nothing, produced an empty milestone +# number and exited 0 - every pull request in the repository went unassigned for +# months without one red check (#9671). +# +# gh is stubbed from GH_STUB_DIR, so this needs no network and no token. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS_DIR="$(cd "${SCRIPT_DIR}/../../.github/scripts" && pwd)" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +FAILURES=0 + +start_test() { + echo "=== $1" +} + +fail() { + echo " FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +assert_status() { + local expected="$1" actual="$2" + if [ "${actual}" -ne "${expected}" ]; then + fail "expected exit ${expected}, got ${actual}" + fi +} + +assert_contains() { + local file="$1" needle="$2" + if ! grep -qF -- "${needle}" "${file}"; then + fail "expected '${needle}' in ${file}:" + sed 's/^/ /' "${file}" >&2 + fi +} + +assert_absent() { + local file="$1" needle="$2" + if grep -qF -- "${needle}" "${file}"; then + fail "did not expect '${needle}' in ${file}:" + sed 's/^/ /' "${file}" >&2 + fi +} + +# A stand-in for the gh CLI that answers from files, so the script can be handed +# a milestone listing without a network call or a token +new_stub() { + local dir="${WORK_DIR}/$1" + rm -rf "${dir}" + mkdir -p "${dir}/bin" + cat > "${dir}/bin/gh" <<'STUB' +#!/usr/bin/env bash +set -u + +key=unknown +for arg in "$@"; do + case "${arg}" in + *milestones*) key=milestones ;; + esac +done + +if [ -f "${GH_STUB_DIR}/${key}.fail" ]; then + echo "gh: simulated API failure for ${key}" >&2 + exit 1 +fi +if [ -f "${GH_STUB_DIR}/${key}.out" ]; then + cat "${GH_STUB_DIR}/${key}.out" +fi +exit 0 +STUB + chmod +x "${dir}/bin/gh" + STUB_DIR="${dir}" +} + +# The script takes every input from the environment, so the environment is built +# from nothing rather than inherited: `env -i`, and not a bare assignment prefix, +# so that nothing the surrounding CI job exported can reach it. +run_resolve_milestone() { + env -i \ + PATH="${STUB_DIR}/bin:${PATH}" \ + HOME="${WORK_DIR}" \ + GH_STUB_DIR="${STUB_DIR}" \ + REPO=Mudlet/Mudlet \ + NEXT_MILESTONE="$1" \ + bash "${SCRIPTS_DIR}/resolve-milestone.sh" > "${STUB_DIR}/out.log" 2>&1 +} + +#----------------------------------------------------------------------------- +# The repository's real open milestones. "5.0 beginner-friendly" sitting next to +# "5.0.0 next release" is the reason the prefix match is anchored on a following +# space rather than being a bare startswith +MILESTONES='[{"number":18,"title":"5.0 beginner-friendly"}, + {"number":62,"title":"future release"}, + {"number":64,"title":"5.0.0 next release"}]' + +start_test "a suffixed milestone title still matches the bare version (#9671)" +new_stub milestone-prefix +printf '%s\n' "${MILESTONES}" > "${STUB_DIR}/milestones.out" +run_resolve_milestone 5.0.0 +assert_status 0 $? +assert_contains "${STUB_DIR}/out.log" '64 5.0.0 next release' + +#----------------------------------------------------------------------------- +start_test "a shorter milestone that shares a prefix is not swept in" +new_stub milestone-shared-prefix +printf '%s\n' "${MILESTONES}" > "${STUB_DIR}/milestones.out" +run_resolve_milestone 5.0 +assert_status 0 $? +assert_contains "${STUB_DIR}/out.log" '18 5.0 beginner-friendly' +assert_absent "${STUB_DIR}/out.log" '5.0.0 next release' + +#----------------------------------------------------------------------------- +start_test "an exact title wins over a longer one that also starts with it" +new_stub milestone-exact +printf '%s\n' '[{"number":64,"title":"5.0.0 next release"},{"number":70,"title":"5.0.0"}]' \ + > "${STUB_DIR}/milestones.out" +run_resolve_milestone 5.0.0 +assert_status 0 $? +assert_contains "${STUB_DIR}/out.log" '70 5.0.0' + +#----------------------------------------------------------------------------- +start_test "a version that matches no milestone fails and says what is open" +new_stub milestone-none +printf '%s\n' "${MILESTONES}" > "${STUB_DIR}/milestones.out" +run_resolve_milestone 4.99.0 +assert_status 1 $? +assert_contains "${STUB_DIR}/out.log" '::error::No open milestone matches' +assert_contains "${STUB_DIR}/out.log" '5.0.0 next release' + +#----------------------------------------------------------------------------- +start_test "a version that matches several milestones is refused, not guessed" +new_stub milestone-ambiguous +printf '%s\n' '[{"number":64,"title":"5.0.0 next release"},{"number":71,"title":"5.0.0 stretch goals"}]' \ + > "${STUB_DIR}/milestones.out" +run_resolve_milestone 5.0.0 +assert_status 1 $? +assert_contains "${STUB_DIR}/out.log" 'matches several open milestones' + +#----------------------------------------------------------------------------- +start_test "a near miss on the version does not match" +new_stub milestone-nearmiss +printf '%s\n' "${MILESTONES}" > "${STUB_DIR}/milestones.out" +run_resolve_milestone 5.0.1 +assert_status 1 $? +assert_contains "${STUB_DIR}/out.log" '::error::No open milestone matches' + +#----------------------------------------------------------------------------- +start_test "a failed milestone read is loud" +new_stub milestone-fails +touch "${STUB_DIR}/milestones.fail" +run_resolve_milestone 5.0.0 +assert_status 1 $? +assert_contains "${STUB_DIR}/out.log" '::error::Could not read the open milestones' + +#----------------------------------------------------------------------------- +echo +if [ "${FAILURES}" -gt 0 ]; then + echo "${FAILURES} check(s) FAILED" + exit 1 +fi +echo "All checks passed" diff --git a/test/ci/release-checksums-test.sh b/test/ci/release-checksums-test.sh new file mode 100755 index 000000000..85c0c638a --- /dev/null +++ b/test/ci/release-checksums-test.sh @@ -0,0 +1,406 @@ +#!/bin/bash +# Tests the release checksum scripts against the asset sets that made Windows +# auto-update fail with "Could not verify the integrity of the download". +# +# The failure: the 2026-08-01 PTB ended up with five binaries but a +# SHA256SUMS.txt covering only four. The uncovered one was +# Mudlet-4.22.0-ptb-2026-08-01-dfdcb137-windows-64.exe - the very file the +# updater downloads on Windows - so it refused to install anything. +# +# The 2026-08-01 and 2026-08-03 filenames and hashes below are the real published +# ones; the stable-release and rebuild-counter cases further down are synthetic. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CI_DIR="$(cd "${SCRIPT_DIR}/../../CI" && pwd)" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +FAILURES=0 +CURRENT_TEST="" + +readonly TAG='Mudlet-4.22.0-ptb-2026-08-01-dfdcb137' +readonly LINUX_ASSET="${TAG}-linux-x64.AppImage.tar" +readonly ARM64_ASSET="${TAG}-arm64.dmg" +readonly INTEL_MAC_ASSET="${TAG}-x86_64.dmg" +readonly WINDOWS_ASSET="${TAG}-windows-64.exe" +# Only the Windows build appends a rebuild counter (CI/deploy-mudlet-for-windows.sh); +# a re-run also restamps the PTB date, which is what changes the tag prefix +readonly REBUILD_WINDOWS_ASSET='Mudlet-4.22.0-ptb-2026-08-02-dfdcb137rebuild2-windows-64.exe' + +readonly LINUX_HASH='72a239146b07fc3b94f9e96955d2d6d52a6f0f40c8a5b7ee314b0bf875a55611' +readonly ARM64_HASH='7a7a75723e15a3443c4e8937fe2a85cee90b9dc8938e1e0580887e5c5e1fabbb' +readonly INTEL_MAC_HASH='d2426d7f799b619b0bfcb1e51e373f776288f77584bc9f08a79cc288cc58278c' +readonly WINDOWS_HASH='72dba076741a245a994b553b1cf88dba5d7f5bb07f0d6887ecd58361402764e1' +readonly REBUILD_WINDOWS_HASH='9b3895247937d37f645dd31e7ded739e3126ccad6bfa4188cdd3b78f735c2f6f' + +start_test() { + CURRENT_TEST="$1" + echo "=== ${CURRENT_TEST}" +} + +fail() { + echo " FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +pass() { + echo " ok: $*" +} + +assert_contains() { + local file="$1" needle="$2" + if grep -qF -- "${needle}" "${file}"; then + pass "${file##*/} contains ${needle}" + else + fail "${file##*/} is missing ${needle}" + echo "--- ${file} ---" >&2 + cat "${file}" >&2 + fi +} + +assert_absent() { + local file="$1" needle="$2" + if [[ ! -s "${file}" ]]; then + fail "${file##*/} is empty or missing, so 'does not contain' proves nothing" + return + fi + if grep -qF -- "${needle}" "${file}"; then + fail "${file##*/} unexpectedly contains ${needle}" + else + pass "${file##*/} does not contain ${needle}" + fi +} + +assert_line_count() { + local file="$1" expected="$2" actual + actual="$(wc -l < "${file}" | tr -d ' ')" + if [[ "${actual}" == "${expected}" ]]; then + pass "${file##*/} has ${expected} entries" + else + fail "${file##*/} has ${actual} entries, expected ${expected}" + cat "${file}" >&2 + fi +} + +run_prepare() { + local case_dir="$1" tag="$2" type="$3" + bash "${CI_DIR}/prepare-release-assets.sh" "${case_dir}/assets" "${tag}" "${type}" > "${case_dir}/prepare.log" 2>&1 +} + +run_assemble() { + local case_dir="$1" published="$2" + bash "${CI_DIR}/assemble-release-checksums.sh" "${case_dir}/assets" "${published}" > "${case_dir}/assemble.log" 2>&1 +} + +run_verify() { + local case_dir="$1" + shift + bash "${CI_DIR}/verify-release-checksums.sh" "$@" > "${case_dir}/verify.log" 2>&1 +} + +expect_ok() { + local what="$1" case_dir="$2" log="$3" + if [[ "$4" -eq 0 ]]; then + pass "${what} succeeded" + else + fail "${what} exited non-zero" + cat "${case_dir}/${log}" >&2 + fi +} + +expect_failure() { + local what="$1" status="$2" + if [[ "${status}" -ne 0 ]]; then + pass "${what} rejected it" + else + fail "${what} accepted it" + fi +} + +# macOS has shasum rather than coreutils' sha256sum +sha256_of() { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + else + shasum -a 256 "$1" | cut -d ' ' -f 1 + fi +} + +# Creates a case directory with an assets/ subdirectory holding placeholder +# binaries and their .sha256 sidecars. Arguments are "name:hash" pairs; a hash of +# "real" is computed from the placeholder's actual bytes. +make_assets() { + local case_dir="$1" + shift + mkdir -p "${case_dir}/assets" + local pair name hash + for pair in "$@"; do + name="${pair%%:*}" + hash="${pair##*:}" + echo "placeholder for ${name}" > "${case_dir}/assets/${name}" + if [[ "${hash}" == "real" ]]; then + hash="$(sha256_of "${case_dir}/assets/${name}")" + fi + printf '%s *%s\n' "${hash}" "${name}" > "${case_dir}/assets/${name}.sha256" + done +} + +published_sums_from_the_complete_run() { + # What run 30687252829 published: all four platforms of ${TAG} + cat <<EOF +${LINUX_HASH} ${LINUX_ASSET} +${INTEL_MAC_HASH} ${INTEL_MAC_ASSET} +${ARM64_HASH} ${ARM64_ASSET} +${WINDOWS_HASH} *${WINDOWS_ASSET} +EOF +} + +#----------------------------------------------------------------------------- +start_test "the run that broke the 2026-08-01 PTB now keeps every binary covered" +# The 2026-08-02 run downloaded the Linux/macOS artifacts of ${TAG} but, because +# create-github-release.yml picks the newest successful run of each platform, the +# Windows installer of a later rebuild. It regenerated SHA256SUMS.txt from those +# four sidecars and clobbered the published file, dropping the entry for the +# ${WINDOWS_ASSET} that stayed on the release. +CASE="${WORK_DIR}/clobber" +make_assets "${CASE}" \ + "${REBUILD_WINDOWS_ASSET}:${REBUILD_WINDOWS_HASH}" \ + "${LINUX_ASSET}:${LINUX_HASH}" \ + "${ARM64_ASSET}:${ARM64_HASH}" \ + "${INTEL_MAC_ASSET}:${INTEL_MAC_HASH}" +published_sums_from_the_complete_run > "${CASE}/published-SHA256SUMS.txt" + +run_prepare "${CASE}" "${TAG}" ptb +expect_ok "prepare-release-assets.sh" "${CASE}" prepare.log $? +assert_contains "${CASE}/prepare.log" "::warning::Ignoring 2 asset(s) from a different build" +if [[ -e "${CASE}/assets/${REBUILD_WINDOWS_ASSET}" ]]; then + fail "the other build's installer was left in assets/" +else + pass "the other build's installer was set aside" +fi + +run_assemble "${CASE}" "${CASE}/published-SHA256SUMS.txt" +expect_ok "assemble-release-checksums.sh" "${CASE}" assemble.log $? +SUMS="${CASE}/assets/SHA256SUMS.txt" +# The regression: without merging, this file lost the ${WINDOWS_ASSET} entry +assert_contains "${SUMS}" "${WINDOWS_HASH}" +assert_contains "${SUMS}" "${WINDOWS_ASSET}" +assert_contains "${SUMS}" "${LINUX_ASSET}" +assert_contains "${SUMS}" "${ARM64_ASSET}" +assert_contains "${SUMS}" "${INTEL_MAC_ASSET}" +assert_absent "${SUMS}" "${REBUILD_WINDOWS_ASSET}" +assert_line_count "${SUMS}" 4 + +# The release afterwards holds what was already published plus what we upload +cat > "${CASE}/final-assets.txt" <<EOF +${ARM64_ASSET} +${LINUX_ASSET} +${WINDOWS_ASSET} +${INTEL_MAC_ASSET} +SHA256SUMS.txt +EOF +run_verify "${CASE}" "${SUMS}" "${CASE}/final-assets.txt" +expect_ok "verify-release-checksums.sh" "${CASE}" verify.log $? + +#----------------------------------------------------------------------------- +start_test "an uncovered release binary is rejected" +# The exact state the 2026-08-01 PTB shipped in: SHA256SUMS.txt regenerated from +# one run's sidecars while an earlier run's Windows installer stayed published +CASE="${WORK_DIR}/uncovered" +mkdir -p "${CASE}" +cat > "${CASE}/SHA256SUMS.txt" <<EOF +${LINUX_HASH} ${LINUX_ASSET} +${INTEL_MAC_HASH} ${INTEL_MAC_ASSET} +${REBUILD_WINDOWS_HASH} *${REBUILD_WINDOWS_ASSET} +${ARM64_HASH} ${ARM64_ASSET} +EOF +cat > "${CASE}/assets.txt" <<EOF +${ARM64_ASSET} +${LINUX_ASSET} +${WINDOWS_ASSET} +${INTEL_MAC_ASSET} +${REBUILD_WINDOWS_ASSET} +SHA256SUMS.txt +EOF +run_verify "${CASE}" "${CASE}/SHA256SUMS.txt" "${CASE}/assets.txt" +expect_failure "verify-release-checksums.sh" $? +assert_contains "${CASE}/verify.log" "${WINDOWS_ASSET}" +assert_contains "${CASE}/verify.log" "::error::" +# a blocked release cannot be repaired by re-running, so say what to do +assert_contains "${CASE}/verify.log" "Delete the stale asset from the release" + +#----------------------------------------------------------------------------- +start_test "a binary whose bytes do not match its entry is rejected" +# The Windows job recomputes the sidecar after signing changes the bytes +# (build-mudlet-win.yml); if that ever stops happening, coverage alone would not +# notice and every user's updater would report a corrupt download +CASE="${WORK_DIR}/mismatch" +make_assets "${CASE}" "${WINDOWS_ASSET}:real" +run_assemble "${CASE}" "" +expect_ok "assemble-release-checksums.sh" "${CASE}" assemble.log $? +printf '%s\nSHA256SUMS.txt\n' "${WINDOWS_ASSET}" > "${CASE}/assets.txt" + +run_verify "${CASE}" "${CASE}/assets/SHA256SUMS.txt" "${CASE}/assets.txt" "${CASE}/assets" +expect_ok "verify-release-checksums.sh on matching bytes" "${CASE}" verify.log $? + +echo "tampered" > "${CASE}/assets/${WINDOWS_ASSET}" +run_verify "${CASE}" "${CASE}/assets/SHA256SUMS.txt" "${CASE}/assets.txt" "${CASE}/assets" +expect_failure "verify-release-checksums.sh on altered bytes" $? +assert_contains "${CASE}/verify.log" "do not match their SHA256SUMS.txt entry" + +#----------------------------------------------------------------------------- +start_test "a Windows-only PTB is published with its one checksum" +# The 2026-08-03 PTB: the Linux/macOS build had not succeeded, so only the +# Windows installer was available. Partial PTBs are allowed, but the installer +# still has to be covered. +CASE="${WORK_DIR}/windows-only" +WINDOWS_ONLY_TAG='Mudlet-4.22.0-ptb-2026-08-03-3474cb58' +WINDOWS_ONLY_ASSET="${WINDOWS_ONLY_TAG}-windows-64.exe" +make_assets "${CASE}" \ + "${WINDOWS_ONLY_ASSET}:c22435c7ff0d36a5dbe5936bcfbbc173f656c2c33583dccbf17a7ad4ade3e350" +run_prepare "${CASE}" "${WINDOWS_ONLY_TAG}" ptb +expect_ok "prepare-release-assets.sh on a partial PTB" "${CASE}" prepare.log $? +assert_contains "${CASE}/prepare.log" "::warning::Missing release assets for: Linux (.AppImage.tar) macOS (arm64 .dmg) macOS (x86_64 .dmg)" +# the workflow always passes published/SHA256SUMS.txt, which does not exist on a +# release's first run +run_assemble "${CASE}" "${CASE}/does-not-exist-SHA256SUMS.txt" +expect_ok "assemble-release-checksums.sh with no published file" "${CASE}" assemble.log $? +assert_line_count "${CASE}/assets/SHA256SUMS.txt" 1 +printf '%s\nSHA256SUMS.txt\n' "${WINDOWS_ONLY_ASSET}" > "${CASE}/assets.txt" +run_verify "${CASE}" "${CASE}/assets/SHA256SUMS.txt" "${CASE}/assets.txt" +expect_ok "verify-release-checksums.sh on the Windows-only PTB" "${CASE}" verify.log $? + +#----------------------------------------------------------------------------- +start_test "a stable release missing a platform is rejected" +CASE="${WORK_DIR}/partial-stable" +STABLE_TAG='Mudlet-4.22.0' +make_assets "${CASE}" \ + "${STABLE_TAG}-windows-64-installer.exe:c22435c7ff0d36a5dbe5936bcfbbc173f656c2c33583dccbf17a7ad4ade3e350" +run_prepare "${CASE}" "${STABLE_TAG}" release +expect_failure "prepare-release-assets.sh on an incomplete stable release" $? +assert_contains "${CASE}/prepare.log" "::error::Stable release is missing assets for:" + +#----------------------------------------------------------------------------- +start_test "a rebuild counter on this build's own installer is kept" +# When the tag comes from the Windows re-run itself, the counter is part of that +# build's own filename and must not make the installer look foreign +CASE="${WORK_DIR}/own-rebuild" +REBUILD_TAG='Mudlet-4.22.0-ptb-2026-08-02-dfdcb137' +make_assets "${CASE}" \ + "${REBUILD_TAG}rebuild2-windows-64.exe:${REBUILD_WINDOWS_HASH}" \ + "${REBUILD_TAG}-linux-x64.AppImage.tar:${LINUX_HASH}" \ + "${REBUILD_TAG}-arm64.dmg:${ARM64_HASH}" \ + "${REBUILD_TAG}-x86_64.dmg:${INTEL_MAC_HASH}" +run_prepare "${CASE}" "${REBUILD_TAG}" ptb +expect_ok "prepare-release-assets.sh" "${CASE}" prepare.log $? +assert_absent "${CASE}/prepare.log" "::warning::Ignoring" +if [[ -e "${CASE}/assets/${REBUILD_TAG}rebuild2-windows-64.exe" ]]; then + pass "this build's own rebuilt installer was kept" +else + fail "this build's own rebuilt installer was set aside" +fi + +#----------------------------------------------------------------------------- +start_test "assets are matched to the tag case-insensitively" +# CI/set-build-info.sh lowercases VERSION, while a pushed git tag keeps its case +CASE="${WORK_DIR}/tag-case" +MIXED_CASE_TAG='Mudlet-4.23.0-RC1' +make_assets "${CASE}" \ + "mudlet-4.23.0-rc1-windows-64-installer.exe:${WINDOWS_HASH}" \ + "mudlet-4.23.0-rc1-linux-x64.AppImage.tar:${LINUX_HASH}" \ + "mudlet-4.23.0-rc1-arm64.dmg:${ARM64_HASH}" \ + "mudlet-4.23.0-rc1-x86_64.dmg:${INTEL_MAC_HASH}" +run_prepare "${CASE}" "${MIXED_CASE_TAG}" release +expect_ok "prepare-release-assets.sh on a mixed-case tag" "${CASE}" prepare.log $? +assert_absent "${CASE}/prepare.log" "::warning::Ignoring" + +#----------------------------------------------------------------------------- +start_test "merging keeps the freshly built hash when a filename repeats" +CASE="${WORK_DIR}/rebuilt-same-name" +make_assets "${CASE}" "${WINDOWS_ASSET}:${REBUILD_WINDOWS_HASH}" +printf '%s *%s\n' "${WINDOWS_HASH}" "${WINDOWS_ASSET}" > "${CASE}/published-SHA256SUMS.txt" +run_assemble "${CASE}" "${CASE}/published-SHA256SUMS.txt" +expect_ok "assemble-release-checksums.sh" "${CASE}" assemble.log $? +assert_line_count "${CASE}/assets/SHA256SUMS.txt" 1 +assert_contains "${CASE}/assets/SHA256SUMS.txt" "${REBUILD_WINDOWS_HASH}" +assert_absent "${CASE}/assets/SHA256SUMS.txt" "${WINDOWS_HASH}" + +#----------------------------------------------------------------------------- +start_test "two different hashes for one filename are not resolved by guessing" +CASE="${WORK_DIR}/conflict" +mkdir -p "${CASE}/assets" +printf '%s *%s\n%s *%s\n' "${WINDOWS_HASH}" "${WINDOWS_ASSET}" "${REBUILD_WINDOWS_HASH}" "${WINDOWS_ASSET}" \ + > "${CASE}/published-SHA256SUMS.txt" +make_assets "${CASE}" "${LINUX_ASSET}:${LINUX_HASH}" +run_assemble "${CASE}" "${CASE}/published-SHA256SUMS.txt" +expect_failure "assemble-release-checksums.sh on conflicting entries" $? +assert_contains "${CASE}/assemble.log" "::error::Conflicting checksums for ${WINDOWS_ASSET}" + +#----------------------------------------------------------------------------- +start_test "checksum lines survive tabs, CRLF and a missing trailing newline" +# None of our generators produce these, but each used to turn a cosmetic quirk into +# a release that could not be published +CASE="${WORK_DIR}/odd-formatting" +mkdir -p "${CASE}/assets" +printf '%s\t%s' "${WINDOWS_HASH}" "${WINDOWS_ASSET}" > "${CASE}/assets/${WINDOWS_ASSET}.sha256" +printf '%s *%s\r\n' "${LINUX_HASH}" "${LINUX_ASSET}" > "${CASE}/assets/${LINUX_ASSET}.sha256" +printf '%s %s' "${ARM64_HASH}" "${ARM64_ASSET}" > "${CASE}/assets/${ARM64_ASSET}.sha256" +run_assemble "${CASE}" "" +expect_ok "assemble-release-checksums.sh on oddly formatted sidecars" "${CASE}" assemble.log $? +assert_line_count "${CASE}/assets/SHA256SUMS.txt" 3 +assert_contains "${CASE}/assets/SHA256SUMS.txt" "${WINDOWS_HASH} ${WINDOWS_ASSET}" +assert_contains "${CASE}/assets/SHA256SUMS.txt" "${LINUX_HASH} *${LINUX_ASSET}" +assert_contains "${CASE}/assets/SHA256SUMS.txt" "${ARM64_HASH} ${ARM64_ASSET}" +printf '%s\n%s\n%s\nSHA256SUMS.txt\n' "${WINDOWS_ASSET}" "${LINUX_ASSET}" "${ARM64_ASSET}" > "${CASE}/assets.txt" +run_verify "${CASE}" "${CASE}/assets/SHA256SUMS.txt" "${CASE}/assets.txt" +expect_ok "verify-release-checksums.sh on the merged file" "${CASE}" verify.log $? + +#----------------------------------------------------------------------------- +start_test "a sidecar that contributes nothing is an error" +CASE="${WORK_DIR}/empty-sidecar" +mkdir -p "${CASE}/assets" +echo "placeholder" > "${CASE}/assets/${WINDOWS_ASSET}" +: > "${CASE}/assets/${WINDOWS_ASSET}.sha256" +run_assemble "${CASE}" "" +expect_failure "assemble-release-checksums.sh on an empty sidecar" $? +assert_contains "${CASE}/assemble.log" "contributed no checksum entry" + +#----------------------------------------------------------------------------- +start_test "an unreadable checksum file is reported as such, not as missing entries" +CASE="${WORK_DIR}/unreadable-sums" +mkdir -p "${CASE}" +echo "<html>503 Service Unavailable</html>" > "${CASE}/SHA256SUMS.txt" +printf '%s\nSHA256SUMS.txt\n' "${WINDOWS_ASSET}" > "${CASE}/assets.txt" +run_verify "${CASE}" "${CASE}/SHA256SUMS.txt" "${CASE}/assets.txt" +expect_failure "verify-release-checksums.sh on an unreadable checksum file" $? +assert_contains "${CASE}/verify.log" "contains no usable checksum entries" + +#----------------------------------------------------------------------------- +start_test "an unrecognised asset type still has to be covered" +# The gate must not exempt a file just because its suffix is new +CASE="${WORK_DIR}/new-asset-type" +mkdir -p "${CASE}" +printf '%s *%s\n' "${WINDOWS_HASH}" "${WINDOWS_ASSET}" > "${CASE}/SHA256SUMS.txt" +printf '%s\nMudlet-4.22.0-linux-x64-portable.tar.gz\nSHA256SUMS.txt\n' "${WINDOWS_ASSET}" > "${CASE}/assets.txt" +run_verify "${CASE}" "${CASE}/SHA256SUMS.txt" "${CASE}/assets.txt" +expect_failure "verify-release-checksums.sh on an uncovered new asset type" $? +assert_contains "${CASE}/verify.log" "Mudlet-4.22.0-linux-x64-portable.tar.gz" + +#----------------------------------------------------------------------------- +start_test "no sidecars at all is an error" +CASE="${WORK_DIR}/no-sidecars" +mkdir -p "${CASE}/assets" +run_assemble "${CASE}" "" +expect_failure "assemble-release-checksums.sh on an empty assets directory" $? +assert_contains "${CASE}/assemble.log" "::error::No .sha256 checksum files found" + +#----------------------------------------------------------------------------- +echo +if [[ ${FAILURES} -gt 0 ]]; then + echo "${FAILURES} check(s) FAILED" + exit 1 +fi +echo "All checks passed" diff --git a/test/ci/release-tag-version-test.sh b/test/ci/release-tag-version-test.sh new file mode 100755 index 000000000..218554753 --- /dev/null +++ b/test/ci/release-tag-version-test.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# Tests CI/check-release-tag.sh, which catches a mistake that otherwise leaves no +# trace: "Mudlet-5.0" passes every existing check and is then never offered to a +# single existing user. See that script for why. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CHECK="${REPO_DIR}/CI/check-release-tag.sh" +RELEASE_WORKFLOW="${REPO_DIR}/.github/workflows/create-github-release.yml" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "${WORK_DIR}"' EXIT + +OUT="${WORK_DIR}/out" +FAILURES=0 + +start_test() { + echo "=== $1" +} + +fail() { + echo " FAIL: $*" >&2 + FAILURES=$((FAILURES + 1)) +} + +run_check() { + bash "${CHECK}" "$@" > "${OUT}" 2>&1 + echo $? +} + +assert_status() { + local expected="$1" actual="$2" + # -ne on an empty status errors out and takes the false branch, turning every + # assertion into a pass + case "${actual}" in + ''|*[!0-9]*) + fail "expected exit ${expected}, got a non-numeric status '${actual}'" + return + ;; + esac + if [ "${actual}" -ne "${expected}" ]; then + fail "expected exit ${expected}, got ${actual}, output:" + sed 's/^/ /' "${OUT}" >&2 + fi +} + +assert_contains() { + local file="$1" needle="$2" + if ! grep -qF -- "${needle}" "${file}"; then + fail "expected '${needle}' in ${file}:" + sed 's/^/ /' "${file}" >&2 + fi +} + +start_test "the tag a 5.0 release has to carry is accepted" +assert_status 0 "$(run_check "5.0.0" "Mudlet-5.0.0")" + +start_test "the tags of every release since 4.19 are accepted, and plausible future ones" +for version in 4.19.0 4.20.1 4.21.0 4.22.0 5.0.0 10.11.12; do + assert_status 0 "$(run_check "${version}" "Mudlet-${version}")" +done + +start_test "the two-component tag that silently disables auto-update is rejected" +assert_status 1 "$(run_check "5.0.0" "Mudlet-5.0")" +assert_contains "${OUT}" "Mudlet-5.0.0" +assert_contains "${OUT}" "auto-update" + +start_test "a stale tag against a bumped APP_VERSION is rejected" +assert_status 1 "$(run_check "5.0.0" "Mudlet-4.22.0")" + +start_test "a stale APP_VERSION against a bumped tag is rejected" +assert_status 1 "$(run_check "4.22.0" "Mudlet-5.0.0")" + +start_test "a two-component APP_VERSION is rejected even though its tag matches" +assert_status 1 "$(run_check "5.0" "Mudlet-5.0")" +assert_contains "${OUT}" "three-component" + +start_test "a version SemVer would reject for its leading zeros is rejected" +assert_status 1 "$(run_check "5.01.0" "Mudlet-5.01.0")" + +# Release.cpp strips only a capitalised "Mudlet-", while the asset check is +# case-insensitive and set-build-info.sh lowercases the version +start_test "a lowercase tag is rejected" +assert_status 1 "$(run_check "4.22.0" "mudlet-4.22.0")" + +start_test "a tag missing the Mudlet- prefix is rejected" +assert_status 1 "$(run_check "5.0.0" "5.0.0")" + +start_test "a suffixed tag is rejected while APP_VERSION cannot carry a suffix" +assert_status 1 "$(run_check "5.0.0" "Mudlet-5.0.0-rc1")" + +start_test "a PTB checks its version without a tag to compare against" +assert_status 0 "$(run_check "5.0.0")" +assert_status 1 "$(run_check "5.0")" +assert_contains "${OUT}" "three-component" + +start_test "a missing or surplus argument is a usage error, not a silent pass" +assert_status 2 "$(run_check "")" +assert_status 2 "$(run_check)" +assert_status 2 "$(run_check "5.0.0" "Mudlet-5.0.0" "extra")" + +start_test "the failure is annotated for GitHub Actions, not only printed" +GITHUB_ACTIONS=true bash "${CHECK}" "5.0.0" "Mudlet-5.0" > "${OUT}" 2>&1 +assert_contains "${OUT}" "::error::" + +start_test "the tag-push build validation calls the guard" +if ! command -v pcre2grep > /dev/null; then + # Skipping is a local convenience; on CI a missing pcre2grep is a broken runner + if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then + fail "pcre2grep is missing, so the build validation scripts cannot be exercised" + else + echo " skipped: pcre2grep is not installed" + fi +else + mkdir -p "${WORK_DIR}/repo" + printf 'set(APP_VERSION 5.0.0)\n' > "${WORK_DIR}/repo/CMakeLists.txt" + + for script in validate_deployment.sh validate-deployment-for-windows.sh; do + (cd "${WORK_DIR}/repo" && GITHUB_REF="refs/tags/Mudlet-5.0.0" GITHUB_REPO_TAG=true WITH_UPDATER=YES \ + bash "${REPO_DIR}/CI/${script}") > "${OUT}" 2>&1 + assert_status 0 "$?" + + (cd "${WORK_DIR}/repo" && GITHUB_REF="refs/tags/Mudlet-5.0" GITHUB_REPO_TAG=true WITH_UPDATER=YES \ + bash "${REPO_DIR}/CI/${script}") > "${OUT}" 2>&1 + assert_status 1 "$?" + assert_contains "${OUT}" "does not match APP_VERSION" + done + + # The Windows script decides it is a release build from GITHUB_REPO_TAG but takes + # the tag from GITHUB_REF, so the two can disagree + (cd "${WORK_DIR}/repo" && GITHUB_REF="refs/heads/development" GITHUB_REPO_TAG=true WITH_UPDATER=YES \ + bash "${REPO_DIR}/CI/validate-deployment-for-windows.sh") > "${OUT}" 2>&1 + assert_status 1 "$?" + assert_contains "${OUT}" "could not be determined" + + # The two scripts decide "not a release build" from different variables + (cd "${WORK_DIR}/repo" && GITHUB_REF="refs/heads/development" WITH_UPDATER=YES \ + bash "${REPO_DIR}/CI/validate_deployment.sh") > "${OUT}" 2>&1 + assert_status 0 "$?" + assert_contains "${OUT}" "skipping release validation" + + (cd "${WORK_DIR}/repo" && GITHUB_REF="refs/heads/development" GITHUB_REPO_TAG=false WITH_UPDATER=YES \ + bash "${REPO_DIR}/CI/validate-deployment-for-windows.sh") > "${OUT}" 2>&1 + assert_status 0 "$?" + assert_contains "${OUT}" "skipping release validation" +fi + +start_test "the release workflow calls the guard before it publishes anything" +# Keeps the line numbering, so a commented-out call cannot satisfy any of this +UNCOMMENTED="${WORK_DIR}/workflow-without-comments" +sed 's/^[[:space:]]*#.*$//' "${RELEASE_WORKFLOW}" > "${UNCOMMENTED}" + +release_call='^[[:space:]]*bash release-scripts/CI/check-release-tag\.sh "\$\{VERSION\}" "\$\{REF#refs/tags/\}"[[:space:]]*$' +ptb_call='^[[:space:]]*bash release-scripts/CI/check-release-tag\.sh "\$\{VERSION\}"[[:space:]]*$' +publish_line=$(grep -n 'gh release create' "${UNCOMMENTED}" | head -1 | cut -d: -f1) + +for description in "release:${release_call}" "PTB:${ptb_call}"; do + guard_line=$(grep -nE "${description#*:}" "${UNCOMMENTED}" | head -1 | cut -d: -f1) + if [ -z "${guard_line}" ]; then + fail "create-github-release.yml no longer runs the guard on the ${description%%:*} path" + elif [ -z "${publish_line}" ]; then + fail "could not find the publishing step in create-github-release.yml" + elif [ "${guard_line}" -ge "${publish_line}" ]; then + fail "the ${description%%:*} guard at line ${guard_line} runs after the release is published at line ${publish_line}" + fi +done + +if [ "${FAILURES}" -gt 0 ]; then + echo "${FAILURES} check(s) failed" + exit 1 +fi + +echo "All checks passed" diff --git a/test/compare-perf-baseline.py b/test/compare-perf-baseline.py index e6d948000..6541a0d8c 100755 --- a/test/compare-perf-baseline.py +++ b/test/compare-perf-baseline.py @@ -33,12 +33,15 @@ import sys # ASan build to a release build, whose absolute numbers are incomparable. INVARIANTS = ("text_corpus_lines", "text_corpus_bytes", "trigger_count", "build_asan") -# Gated by default: throughput (lines/sec) for the text and trigger pipelines. +# Gated by default: throughput (lines/sec) for the text and trigger pipelines, +# plus the shipped default packages on the same corpus - the pipeline metrics run +# on a bare profile, so only defaults_text_lines_per_sec can see a package +# costing every new user throughput. # trigger_overhead_ms is intentionally NOT here - it is a difference of two noisy # best-passes (up to ~16% run-to-run worst case, wider than the 10% gate), so it # would fire on noise. It stays emitted and reportable, and can be gated # explicitly with --gate trigger_overhead_ms when a change targets matching. -DEFAULT_GATE = ("text_lines_per_sec", "trigger_lines_per_sec") +DEFAULT_GATE = ("text_lines_per_sec", "trigger_lines_per_sec", "defaults_text_lines_per_sec") # Wall-clock ceiling for a single benchmark run under --run. The ASan/offscreen # functional-test build feeds a huge corpus several times, so this is generous. diff --git a/test/functional_tests/ActionSelfRemovalTest.cpp b/test/functional_tests/ActionSelfRemovalTest.cpp new file mode 100644 index 000000000..021e144f4 --- /dev/null +++ b/test/functional_tests/ActionSelfRemovalTest.cpp @@ -0,0 +1,263 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QtTest/QtTest> + +#include <QPushButton> + +#include "ActionUnit.h" +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TAction.h" +#include "TFlipButton.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResources(); + +// Regression tests for the self-uninstall use-after-free: a package toolbar +// button whose Lua script calls uninstallPackage() on its own package used to +// free the very TAction that TAction::execute() was running on, which then read +// this->mpHost after the Lua call returned (heap-use-after-free). ActionUnit now +// defers that delete until execute() has unwound. +class ActionSelfRemovalTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mpHostname = "Test-ActionSelfRemoval"; + const QString mpPort = "4009"; + const QString mpLocalhost = "localhost"; + + // Builds a package the way an installed package with a toolbar button is laid + // out: a master-folder root carrying the package name, a toolbar under it (the + // TEasyButtonBar, on the top bar via mLocation 0), and the actual button under + // that. updateAllToolbars() then gives the button a real TFlipButton, letting a + // test drive the genuine click dispatch path. registerAction() assigns the ids, + // so it must run before setScript() (which bakes the id into the Lua funcname). + // Returns the leaf button whose script uninstalls the package. + TAction* buildSelfUninstallingPackage(Host* host, const QString& packageName) + { + auto* actionUnit = host->getActionUnit(); + + auto* master = new TAction(packageName, host); + master->mPackageName = packageName; + master->mModuleMasterFolder = true; + master->setIsFolder(true); + master->setIsActive(true); + actionUnit->registerAction(master); + + auto* toolbar = new TAction(master, host); + toolbar->setName(qsl("selfUninstallToolbar")); + toolbar->mLocation = 0; + toolbar->setIsActive(true); + actionUnit->registerAction(toolbar); + + auto* button = new TAction(toolbar, host); + button->setName(qsl("selfUninstallButton")); + button->setIsActive(true); + actionUnit->registerAction(button); + button->setScript(qsl("uninstallPackage([[%1]])").arg(packageName)); + + host->mInstalledPackages << packageName; + return button; + } + + TFlipButton* findButtonWidget(Host* host, const TAction* action) + { + // TFlipButton has no Q_OBJECT, so findChildren<> it as its QPushButton base + // and downcast. + for (auto* pushButton : host->mpConsole->findChildren<QPushButton*>()) { + auto* pB = dynamic_cast<TFlipButton*>(pushButton); + if (pB && pB->mpTAction == action) { + return pB; + } + } + return nullptr; + } + +private slots: + void initTestCase() { initializeQRCResources(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + mpServer->start(mpLocalhost, mpPort.toUShort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mpHostname); + } + + // A button whose script uninstalls its own package must not crash, must finish + // removing the package, and its deferred TActions must be freed cleanly by + // doCleanup() afterwards. Invokes the TAction::execute() path directly (the + // entry the review brief asks for) and, because the package is given a live + // toolbar first, also drives uninstallPackage()'s internal updateAllToolbars() + // over the half-uninstalled (deactivated but still-linked) package. + void test_selfUninstallingButtonDoesNotCrash() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + host->mEchoLuaErrors = true; + auto* actionUnit = host->getActionUnit(); + + const QString packageName = qsl("TestActionUninstallPkg"); + auto* button = buildSelfUninstallingPackage(host, packageName); + const int buttonId = button->getID(); + + // Give the package a real TFlipButton/TEasyButtonBar so the uninstall runs + // over a live toolbar, not an empty one. + actionUnit->updateAllToolbars(); + QVERIFY2(findButtonWidget(host, button), "The button should have a real toolbar widget before the test"); + + // The crash: pre-fix this frees `button` mid-call, then execute() reads + // this->mpHost. If the guard works we return here with everything intact. + button->execute(); + + QCOMPARE(actionUnit->processingDepth(), 0); + QVERIFY2(!host->mInstalledPackages.contains(packageName), "The package should have been uninstalled by the button's script"); + QVERIFY2(!bufferContains(qsl("Lua error")), "The button's uninstallPackage() script must not have errored"); + // Still alive but deferred - deletion was postponed until execute() unwound. + QVERIFY2(actionUnit->getAction(buttonId), "The self-uninstalling button must not be freed while execute() is on the stack"); + + // Flushing the deferred deletes (as the dispatchers and Host's per-line + // cleanup do) must actually remove them, with no double free. + actionUnit->doCleanup(); + QVERIFY2(!actionUnit->getAction(buttonId), "The button should be gone after doCleanup()"); + QVERIFY2(actionUnit->findItems(qsl("selfUninstallButton")).empty(), "No trace of the button should remain after cleanup"); + + // uninstallPackage() defers its profile save to the next event-loop cycle + // (QTimer::singleShot, see Host::uninstallPackage()) and that save runs its + // XML serialization on a background thread. Fire the deferred timer, then + // block until the save has fully finished, so no background save thread is + // still running when cleanup() destroys the host: tearing the host down + // underneath an in-flight save corrupted the heap and crashed on Windows. + QTest::qWait(50); + host->waitForProfileSave(); + } + + void cleanup() + { + // Defence for the failure path: if an assertion above aborted the test + // before its own drain ran, a profile save uninstallPackage() deferred + // could still be in flight. Let it finish before deleting the host, so + // destruction never races a background save thread (a Windows crash). + if (auto* self = mudlet::self()) { + if (auto* host = self->getActiveHost()) { + QTest::qWait(50); + host->waitForProfileSave(); + } + } + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mpHostname); + delete mudlet::self(); + } + + // Starts a profile the way a user would via the GUI (mirrors the helper in + // TFeedTriggersRecursionTest). + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + QString joinedBuffer() + { + auto console = mudlet::self()->getActiveHost()->mpConsole; + QString allText; + for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) { + allText.append(console->buffer.line(i)).append(QChar::Space); + } + return allText.simplified(); + } + + bool bufferContains(const QString& needle) { return joinedBuffer().contains(needle); } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ActionSelfRemovalTest.moc" +QTEST_MAIN(ActionSelfRemovalTest) diff --git a/test/functional_tests/CMakeLists.txt b/test/functional_tests/CMakeLists.txt index a20003e69..50eaf52d9 100644 --- a/test/functional_tests/CMakeLists.txt +++ b/test/functional_tests/CMakeLists.txt @@ -2,6 +2,9 @@ if(NOT WIN32) include(${CMAKE_SOURCE_DIR}/src/cmake/EnableSanitizers.cmake) endif() +# Defined in the parent test/CMakeLists.txt; TESTS is per-directory, so call it here too. +cmake_language(DEFER CALL restore_windows_test_output) + set(FUNCTIONAL_TEST_SOURCES ConfigDirOverrideTest.cpp TelnetTextDisplayedTest.cpp @@ -9,24 +12,69 @@ set(FUNCTIONAL_TEST_SOURCES TelnetSgrDefaultColorTest.cpp TelnetTlsPromptTest.cpp TelnetBenchmark.cpp + cTelnetBufferTest.cpp + DefaultGameDeleteTest.cpp ResetProfileTest.cpp TOscTest.cpp TUserWindowTest.cpp + SubCommandLineLifetimeTest.cpp TriggerEditorTest.cpp TFeedTriggersRecursionTest.cpp TriggerSameLineMatchTest.cpp dlgTriggerEditorUndoRedoTest.cpp + EditorBannerViewSwitchTest.cpp TDiscordModeTest.cpp + WindowStateGettersTest.cpp MainConsoleSelectionTest.cpp EnableDisableByNameTest.cpp + UnitDeferredDeleteTest.cpp + ProfileLifecycleTest.cpp + MxpFramePlacementTest.cpp ColorTriggerFilterChildTest.cpp + CopyAsImageTest.cpp + GlyphOverflowTest.cpp GMCPCharLoginTest.cpp + InsertTextCapTest.cpp + SetScriptCallbackTest.cpp + ClearWindowLogTest.cpp + XMLexportVariablesTest.cpp + SgrUnderlineStyleTest.cpp LogRestartDuplicateLineTest.cpp ProfileRoundTripTest.cpp + ProfileLoadTempFileTest.cpp + PackageSelfRemovalTest.cpp + UnitProcessingDepthTest.cpp + PackageRemovalSaveTeardownTest.cpp + ModuleSaveTeardownTest.cpp MapRoundTripTest.cpp + MapProgressDialogSeamTest.cpp + MapCloseDuringImportTest.cpp + TtsInterruptingSpeakTest.cpp UndoServerWrapTest.cpp + NarrowWindowWrapTest.cpp + HostWidgetDecouplingTest.cpp + ActionSelfRemovalTest.cpp + ProfileFolderNameTest.cpp + ProfileDeletionSafetyTest.cpp + DialogTeardownTest.cpp + HostChildTeardownTest.cpp + ConnectionDialogCrashTest.cpp + EdbeeReinitTest.cpp + TMediaLoopTest.cpp + DefaultPackagesTest.cpp + StarterUiTriggerCostTest.cpp + ProfileSwitchShortcutTest.cpp + ExperiencedPlayerGateTest.cpp + WindowBackgroundTest.cpp + EmbeddedMapperCreationTest.cpp ) +# The updater sources are only built with USE_UPDATER, and on macOS the +# Updater wraps Sparkle instead of creating an UpdateDialog +if(USE_UPDATER AND NOT APPLE) + list(APPEND FUNCTIONAL_TEST_SOURCES NewReleaseDialogTeardownTest.cpp) +endif() + set(FUNCTIONAL_TEST_UTILS TelnetServerStub.cpp DiscordIpcServerStub.cpp @@ -36,7 +84,7 @@ set(FUNCTIONAL_TEST_UTILS # output - built but deliberately not registered with ctest: add_executable(UndoServerWrapReplay UndoServerWrapReplay.cpp ${FUNCTIONAL_TEST_UTILS}) add_dependencies(UndoServerWrapReplay ${LIB_MUDLET_TARGET}) -target_link_libraries(UndoServerWrapReplay PRIVATE Qt6::Test ${LIB_MUDLET_TARGET}) +target_link_libraries(UndoServerWrapReplay PRIVATE Qt6::Test ${LIB_MUDLET_TARGET} mudlet_lsan_hooks) set_target_properties(UndoServerWrapReplay PROPERTIES ENABLE_EXPORTS ON) # Report-only perf harness for manual before/after comparisons @@ -48,7 +96,7 @@ set_target_properties(UndoServerWrapReplay PROPERTIES ENABLE_EXPORTS ON) option(REGISTER_PERF_BENCHMARK "Register the report-only PipelineBenchmark with ctest (it is built either way)" OFF) add_executable(PipelineBenchmark PipelineBenchmark.cpp ${FUNCTIONAL_TEST_UTILS}) add_dependencies(PipelineBenchmark ${LIB_MUDLET_TARGET}) -target_link_libraries(PipelineBenchmark PRIVATE Qt6::Test ${LIB_MUDLET_TARGET}) +target_link_libraries(PipelineBenchmark PRIVATE Qt6::Test ${LIB_MUDLET_TARGET} mudlet_lsan_hooks) set_target_properties(PipelineBenchmark PROPERTIES ENABLE_EXPORTS ON) if(REGISTER_PERF_BENCHMARK) add_test(NAME PipelineBenchmark COMMAND $<TARGET_FILE:PipelineBenchmark>) @@ -59,15 +107,32 @@ if(REGISTER_PERF_BENCHMARK) TIMEOUT 600) endif() +# These still leak and stay unchecked until their defects are fixed: +# - dlgTriggerEditorUndoRedoTest: never destroys its dlgTriggerEditor, leaving +# ~3.4MB of tree-item QIcon/QPixmap behind +# - NewReleaseDialogTeardownTest: Qt's one-time system CA-certificate store load +# on first TLS use, cached for the process lifetime +set(leakCheckExcludedTests + dlgTriggerEditorUndoRedoTest + NewReleaseDialogTeardownTest +) + +# mudlet_lsan_hooks has to be linked explicitly, see src/CMakeLists.txt foreach(test_file ${FUNCTIONAL_TEST_SOURCES}) get_filename_component(test_name ${test_file} NAME_WE) add_executable(${test_name} ${test_file} ${FUNCTIONAL_TEST_UTILS}) add_dependencies(${test_name} ${LIB_MUDLET_TARGET}) - target_link_libraries(${test_name} PRIVATE Qt6::Test ${LIB_MUDLET_TARGET}) + target_link_libraries(${test_name} PRIVATE Qt6::Test ${LIB_MUDLET_TARGET} mudlet_lsan_hooks) set_target_properties(${test_name} PROPERTIES ENABLE_EXPORTS ON) add_test(NAME ${test_name} COMMAND $<TARGET_FILE:${test_name}>) + # Apple's ASan runtime has no LeakSanitizer + if(APPLE OR test_name IN_LIST leakCheckExcludedTests) + set(leakCheck "detect_leaks=0") + else() + set(leakCheck "detect_leaks=1") + endif() set_tests_properties(${test_name} PROPERTIES - ENVIRONMENT "QT_QPA_PLATFORM=offscreen;ASAN_OPTIONS=detect_leaks=0" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;ASAN_OPTIONS=${leakCheck}" LABELS "functional" TIMEOUT 60 # seconds ) @@ -80,15 +145,66 @@ set_tests_properties(ResetProfileTest PROPERTIES TIMEOUT 300) # Undo/redo tests need a longer timeout due to large batch operations set_tests_properties(dlgTriggerEditorUndoRedoTest PROPERTIES TIMEOUT 300) +# A regression in the self-re-creating-trigger cases does not fail, it hangs and +# grows the heap by ~110MB/s: the RSS ceiling and the timeout are what turn that +# into a reported failure. ENVIRONMENT replaces the loop's, hence QT_QPA_PLATFORM +# again. +set_tests_properties(TriggerSameLineMatchTest PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;ASAN_OPTIONS=detect_leaks=0:hard_rss_limit_mb=3000" + TIMEOUT 300) + # GMCPCharLoginTest creates a fresh profile per test method, so it needs a longer timeout -set_tests_properties(GMCPCharLoginTest PROPERTIES TIMEOUT 300) +set_tests_properties(GMCPCharLoginTest PROPERTIES TIMEOUT 600) + +# GlyphOverflowTest creates a fresh profile per test method and sweeps two font +# families across 22 sizes, so it needs a longer timeout +set_tests_properties(GlyphOverflowTest PROPERTIES TIMEOUT 600) + +# InsertTextCapTest creates a fresh profile per test method, so it needs a longer timeout +set_tests_properties(InsertTextCapTest PROPERTIES TIMEOUT 300) # LogRestartDuplicateLineTest creates a fresh profile per test method, so it needs a longer timeout set_tests_properties(LogRestartDuplicateLineTest PROPERTIES TIMEOUT 300) +# ProfileLifecycleTest opens and closes several profiles, and every open is a +# full profile load and every close a full save, so it needs a longer timeout +set_tests_properties(ProfileLifecycleTest PROPERTIES TIMEOUT 300) + +# TMediaLoopTest probes the audio backend and creates a fresh profile per test method, +# and each clip has to be waited out in real time, so it needs a longer timeout. +# QTEST_MAIN does not run src/main.cpp, so pin QT_MEDIA_BACKEND to what main.cpp picks for +# the shipped application - otherwise the tests silently exercise Qt's default backend for +# the platform rather than the one users actually get. +if(APPLE) + set(mediaLoopTestBackend "darwin") +elseif(WIN32) + set(mediaLoopTestBackend "ffmpeg") +else() + set(mediaLoopTestBackend "") +endif() +set_tests_properties(TMediaLoopTest PROPERTIES TIMEOUT 300) +if(mediaLoopTestBackend) + # APPEND, so the environment set by the loop above stays in force rather than being replaced + set_property(TEST TMediaLoopTest APPEND PROPERTY ENVIRONMENT "QT_MEDIA_BACKEND=${mediaLoopTestBackend}") +endif() + # The round-trip tests boot a full mudlet instance and save/reload profile and # map data, so they need a longer timeout -set_tests_properties(ProfileRoundTripTest MapRoundTripTest PROPERTIES TIMEOUT 300) +set_tests_properties(ProfileRoundTripTest MapRoundTripTest MapProgressDialogSeamTest MapCloseDuringImportTest PROPERTIES TIMEOUT 300) + +# HostWidgetDecouplingTest creates a fresh profile per test method, so it needs a longer timeout +set_tests_properties(HostWidgetDecouplingTest PROPERTIES TIMEOUT 300) + +# ConnectionDialogCrashTest waits out two real profile copies (15s budget each) +# on top of a full mudlet start under ASan, so the 60s default is too tight for +# a loaded machine even though the file runs in ~2s +set_tests_properties(ConnectionDialogCrashTest PROPERTIES TIMEOUT 300) + +# NarrowWindowWrapTest creates a fresh profile per test method, so it needs a longer timeout +set_tests_properties(NarrowWindowWrapTest PROPERTIES TIMEOUT 300) + +# CopyAsImageTest creates a fresh profile per test method, so it needs a longer timeout +set_tests_properties(CopyAsImageTest PROPERTIES TIMEOUT 300) # TDiscordModeTest drives the real discord-rpc library end-to-end. The library's # reconnect backoff is a process-global (60s ceiling) that the suite's diff --git a/test/functional_tests/ClearWindowLogTest.cpp b/test/functional_tests/ClearWindowLogTest.cpp new file mode 100644 index 000000000..33fda8dc5 --- /dev/null +++ b/test/functional_tests/ClearWindowLogTest.cpp @@ -0,0 +1,249 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QtTest/QtTest> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResources(); + +// Logging defers each received line (TBuffer::lastTextToLog) so that a trigger +// gagging it with deleteLine() can stop it reaching the log file. clearWindow() +// however only clears the display - a line that was received and shown must +// still make it into the log even if the window is cleared before the next +// line arrives. These tests pin down both sides of that contract. +class ClearWindowLogTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mHostname = "Test-ClearWindowLog"; + QString mPort; // assigned the stub's actual loopback port in init() + const QString mLocalhost = "localhost"; + +private slots: + void initTestCase() { initializeQRCResources(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + // Port 0 asks the OS for an ephemeral port so parallel test runs do + // not collide on a hardcoded one + mpServer->start(mLocalhost, 0); + QVERIFY2(mpServer->isListening(), "TelnetServerStub failed to bind a loopback port"); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + } + + // A received line still pending in the deferred logging state must survive + // a clearWindow() call - clearing the display is not gagging. + void test_clearWindowKeepsPendingLogLine() + { + auto* host = startLoggingProfile(); + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('You are dead.\\n')")); + QVERIFY2(bufferContains(qsl("You are dead.")), "Fed line did not reach the console buffer"); + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("clearWindow()")); + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('You emerge unscathed.\\n')")); + + QString log; + stopLoggingAndReadLog(host, log); + QVERIFY2(log.contains(qsl("You are dead.")), "clearWindow() dropped the line that was pending for logging"); + QVERIFY2(log.contains(qsl("You emerge unscathed.")), "Line received after clearWindow() is missing from the log"); + } + + // A line whose OWN trigger calls clearWindow() mid-processing must still + // reach the log - it was received and displayed before the screen was + // cleared, so clearing is not the same as gagging it with deleteLine(). + void test_clearWindowFromOwnTriggerKeepsLine() + { + auto* host = startLoggingProfile(); + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("tempRegexTrigger('^You perish$', [[clearWindow()]])")); + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('You perish\\n')")); + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('A new dawn\\n')")); + + QString log; + stopLoggingAndReadLog(host, log); + QVERIFY2(log.contains(qsl("You perish")), "A line whose own trigger cleared the window was dropped from the log"); + QVERIFY2(log.contains(qsl("A new dawn")), "Line received after clearWindow() is missing from the log"); + } + + // The behaviour #9429 fixed must be preserved: a line gagged by a trigger's + // deleteLine() stays out of the log while its neighbours are still logged. + void test_gaggedLineStaysOutOfLog() + { + auto* host = startLoggingProfile(); + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("tempRegexTrigger('^Top secret plans$', [[deleteLine()]])")); + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('Before the gag.\\n')")); + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('Top secret plans\\n')")); + host->getLuaInterpreter()->compileAndExecuteScript(qsl("feedTelnet('After the gag.\\n')")); + + QString log; + stopLoggingAndReadLog(host, log); + QVERIFY2(log.contains(qsl("Before the gag.")), "Line before the gagged one is missing from the log"); + QVERIFY2(!log.contains(qsl("Top secret plans")), "Gagged line leaked into the log"); + QVERIFY2(log.contains(qsl("After the gag.")), "Line after the gagged one is missing from the log"); + } + + void cleanup() + { + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + +private: + // Starts a profile, takes it offline (feedTelnet() requires that) and turns + // on plain-text logging to a known file name. + Host* startLoggingProfile() + { + startProfile(mHostname, mLocalhost, mPort); + auto* host = mudlet::self()->getActiveHost(); + host->mEchoLuaErrors = true; + + host->mTelnet.disconnectIt(); + if (!QTest::qWaitFor( + [host]() { + return host->mTelnet.getConnectionState() == QAbstractSocket::UnconnectedState; + }, + 5000)) { + qWarning() << "Profile did not go offline in time; feedTelnet() calls will fail"; + } + + host->mLogDir.clear(); + host->mLogFileNameFormat.clear(); + host->mLogFileName = qsl("clearwindow-log-test"); + host->mIsNextLogFileInHtmlFormat = false; + host->mpConsole->toggleLogging(false); + return host; + } + + // Returns the log contents through logContents. A genuine open failure is + // surfaced as its own assertion (with the OS error) rather than silently + // returning an empty string, which would otherwise masquerade as a + // dropped/missing log line in the callers' QVERIFY2 checks. + void stopLoggingAndReadLog(Host* host, QString& logContents) + { + const QString logFileName = host->mpConsole->mLogFileName; + host->mpConsole->toggleLogging(false); + + QFile logFile(logFileName); + QVERIFY2(logFile.open(QIODevice::ReadOnly | QIODevice::Text), qPrintable(qsl("Could not open log file '%1' for reading: %2").arg(logFileName, logFile.errorString()))); + logContents = QString::fromUtf8(logFile.readAll()); + } + + // Starts a profile the way a user would via the GUI (mirrors the helper in + // TelnetTextDisplayedTest). + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + QString joinedBuffer() + { + auto console = mudlet::self()->getActiveHost()->mpConsole; + QString allText; + for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) { + allText.append(console->buffer.line(i)).append(QChar::Space); + } + return allText.simplified(); + } + + bool bufferContains(const QString& needle) { return joinedBuffer().contains(needle); } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ClearWindowLogTest.moc" +QTEST_MAIN(ClearWindowLogTest) diff --git a/test/functional_tests/ConfigDirOverrideTest.cpp b/test/functional_tests/ConfigDirOverrideTest.cpp index 201102e90..735ee99dd 100644 --- a/test/functional_tests/ConfigDirOverrideTest.cpp +++ b/test/functional_tests/ConfigDirOverrideTest.cpp @@ -25,6 +25,9 @@ * or the guard, which resurfaces as parallel-run sqlite flakiness or, worse, * users' profiles appearing to vanish on upgrade. * + * Creating $XDG_CONFIG_HOME/mudlet/profiles is the opt-in; the directory above it + * on its own is not, because that is a state other tooling creates by accident. + * * The resolution logic lives in utils::xdgConfigDir(legacyDefault), which takes * the legacy candidate as an argument, so most cases test it directly and stay * platform-independent (no HOME/USERPROFILE juggling). A couple of cases drive @@ -47,6 +50,14 @@ private: QString mudletUnder(const QString& dir) const { return QDir::cleanPath(qsl("%1/mudlet").arg(dir)); } + bool makeProfile(const QString& configDir, const QString& profileName) const { return QDir().mkpath(qsl("%1/profiles/%2").arg(configDir, profileName)); } + + bool makeSettingsFile(const QString& configDir) const + { + QFile ini(qsl("%1/Mudlet.ini").arg(configDir)); + return ini.open(QIODevice::WriteOnly); + } + // setupConfig() consults portable.txt beside the executable and in the home // config dir before the XDG/default logic; the setupConfig() integration // cases skip if one is present rather than report a baffling failure. @@ -101,13 +112,13 @@ private slots: QVERIFY(!r.migrationPending); } - void test_emptyXdgDirIsOptInAndWinsOverLegacy() + void test_emptyXdgDirWinsOverALegacyDirWithoutProfiles() { QTemporaryDir xdg; QTemporaryDir legacyHome; QVERIFY(xdg.isValid() && legacyHome.isValid()); const QString target = mudletUnder(xdg.path()); - QVERIFY(QDir().mkpath(target)); // empty opt-in dir + QVERIFY(QDir().mkpath(target)); const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); QVERIFY(QDir().mkpath(legacy)); qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); @@ -115,6 +126,78 @@ private slots: const auto r = utils::xdgConfigDir(legacy); QCOMPARE(r.path, target); QVERIFY(!r.migrationPending); + QVERIFY(r.shadowedProfilesPath.isEmpty()); + } + + // A dotfile manager, container script or aborted move leaves this directory behind. + void test_emptyXdgDirDoesNotHideLegacyProfiles() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + QVERIFY(QDir().mkpath(mudletUnder(xdg.path()))); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, legacy); + QVERIFY(r.migrationPending); + QVERIFY(r.shadowedProfilesPath.isEmpty()); + } + + // The state one bad launch leaves behind, since Mudlet writes its Mudlet.ini + // into whichever dir it chose. Deleting that dir has to be enough to recover. + void test_xdgSettingsFileDoesNotHideLegacyProfiles() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + const QString target = mudletUnder(xdg.path()); + QVERIFY(QDir().mkpath(qsl("%1/fonts").arg(target))); + QVERIFY(makeSettingsFile(target)); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); + QVERIFY(makeProfile(legacy, qsl("BetaGame"))); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, legacy); + QVERIFY(r.migrationPending); + QVERIFY(r.shadowedProfilesPath.isEmpty()); + } + + void test_xdgProfilesDirWinsAndReportsShadowedLegacyProfiles() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + const QString target = mudletUnder(xdg.path()); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(target))); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, target); + QVERIFY(!r.migrationPending); + QCOMPARE(r.shadowedProfilesPath, legacy); + } + + void test_noShadowReportedWhenLegacyProfilesDirIsEmpty() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + const QString target = mudletUnder(xdg.path()); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(target))); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(legacy))); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, target); + QVERIFY(r.shadowedProfilesPath.isEmpty()); } void test_migratedXdgDirWinsOverLegacy() @@ -147,7 +230,7 @@ private slots: QVERIFY(stale.open(QIODevice::WriteOnly)); stale.close(); const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); - QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(legacy))); // real profiles live here + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); // real profiles live here qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); const auto r = utils::xdgConfigDir(legacy); @@ -155,6 +238,26 @@ private slots: QVERIFY2(r.migrationPending, "a stale non-Mudlet XDG dir must not shadow real profiles"); } + // Deleting the last profile leaves an empty legacy profiles/ behind, which + // must not pull a config root in active use back out of $XDG_CONFIG_HOME. + void test_emptyLegacyProfilesDirDoesNotOutrankXdgSettings() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + const QString target = mudletUnder(xdg.path()); + QVERIFY(QDir().mkpath(target)); + QVERIFY(makeSettingsFile(target)); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(legacy))); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, target); + QVERIFY(!r.migrationPending); + QVERIFY(r.shadowedProfilesPath.isEmpty()); + } + void test_guardKeepsLegacyWhenXdgTargetMissing() { QTemporaryDir xdg; @@ -168,6 +271,7 @@ private slots: const auto r = utils::xdgConfigDir(legacy); QCOMPARE(r.path, legacy); QVERIFY(r.migrationPending); + QVERIFY(r.shadowedProfilesPath.isEmpty()); } void test_freshInstallUsesXdgWhenNeitherExists() @@ -202,6 +306,81 @@ private slots: QVERIFY2(!r.migrationPending, "no migration when the XDG target and legacy dir are the same"); } + // XDG_CONFIG_HOME=$HOME/.config is an ordinary export, and would otherwise + // warn on every startup about the directory it is using. + void test_noSelfShadowWhenXdgTargetEqualsLegacyWithProfiles() + { + QTemporaryDir cfg; + QVERIFY(cfg.isValid()); + const QString legacy = mudletUnder(cfg.path()); + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); + qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, legacy); + QVERIFY2(r.shadowedProfilesPath.isEmpty(), "a directory cannot shadow itself"); + } + + void test_noSelfShadowThroughASymlinkedConfigDir() + { + QTemporaryDir real; + QTemporaryDir linkHome; + QVERIFY(real.isValid() && linkHome.isValid()); + const QString legacy = mudletUnder(real.path()); + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); + const QString linked = qsl("%1/config-link").arg(linkHome.path()); + if (!QFile::link(real.path(), linked)) { + QSKIP("this filesystem does not support symlinks"); + } + qputenv("XDG_CONFIG_HOME", linked.toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QVERIFY2(r.shadowedProfilesPath.isEmpty(), "one directory under two names is still one directory"); + } + + // The only case that observes the settings tier, and losing those settings + // drops firstLaunchDate, which re-runs onboarding. + void test_settingsOnlyLegacyOutranksEmptyXdgDir() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + QVERIFY(QDir().mkpath(mudletUnder(xdg.path()))); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(QDir().mkpath(legacy)); + QVERIFY(makeSettingsFile(legacy)); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + QCOMPARE(r.path, legacy); + QVERIFY(r.migrationPending); + } + + void test_unreadableLegacyDirStillOutranksAnEmptyXdgDir() + { + QTemporaryDir xdg; + QTemporaryDir legacyHome; + QVERIFY(xdg.isValid() && legacyHome.isValid()); + QVERIFY(QDir().mkpath(mudletUnder(xdg.path()))); + const QString legacy = mudletUnder(legacyHome.path() + qsl("/.config")); + QVERIFY(makeProfile(legacy, qsl("AlphaGame"))); + // No traverse bit either, or QDir::exists() on profiles/ still answers and + // the ranking never has to fall back + if (!QFile::setPermissions(legacy, QFileDevice::Permissions())) { + QSKIP("cannot drop permissions on this filesystem"); + } + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + const auto r = utils::xdgConfigDir(legacy); + const bool readableAnyway = QFileInfo(legacy).isReadable(); + QVERIFY(QFile::setPermissions(legacy, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); + if (readableAnyway) { + QSKIP("running as a user that bypasses permission bits"); + } + QCOMPARE(r.path, legacy); + QVERIFY(r.migrationPending); + } + // --- mudlet::setupConfig() end-to-end wiring ------------------------------ void test_setupConfigUsesPreCreatedXdgTarget() @@ -212,13 +391,32 @@ private slots: QTemporaryDir xdg; QVERIFY(xdg.isValid()); const QString target = mudletUnder(xdg.path()); - QVERIFY(QDir().mkpath(target)); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(target))); qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); mudlet::self()->setupConfig(); QCOMPARE(mudlet::getMudletPath(enums::mainPath), target); } + // The warning is all that tells an affected user where their other profiles went. + void test_setupConfigWarnsAboutShadowedLegacyProfiles() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - setupConfig() takes the portable branch"); + } + const QString legacy = qsl("%1/.config/mudlet").arg(QDir::homePath()); + if (!utils::configDirHoldsProfiles(legacy)) { + QSKIP("no profiles in the real ~/.config/mudlet, so nothing can be shadowed"); + } + QTemporaryDir xdg; + QVERIFY(xdg.isValid()); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(mudletUnder(xdg.path())))); + qputenv("XDG_CONFIG_HOME", xdg.path().toUtf8()); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(qsl("holds profiles as well"))); + mudlet::self()->setupConfig(); + } + // With XDG unset, the config root is the usual ~/.config/mudlet, so normal // users are unaffected. Uses the real home dir - no HOME override. void test_setupConfigUnsetUsesHomeConfigDir() diff --git a/test/functional_tests/ConnectionDialogCrashTest.cpp b/test/functional_tests/ConnectionDialogCrashTest.cpp new file mode 100644 index 000000000..c6afcc3c7 --- /dev/null +++ b/test/functional_tests/ConnectionDialogCrashTest.cpp @@ -0,0 +1,412 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Crashes of the connection dialog, driven through the real dialog against an + * isolated config directory. Reaching the end of a test is most of what it + * asserts; the rest pins the behaviour that replaced the crash. + * + * Run with: ctest -R ConnectionDialogCrashTest -V + */ + +#include "MudletInstanceCoordinator.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +#include <QtTest/QtTest> + +#include <QAbstractScrollArea> +#include <QContextMenuEvent> +#include <QMenu> +#include <QPushButton> +#include <QTabBar> +#include <chrono> + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); + +static void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +class ConnectionDialogCrashTest : public QObject +{ + Q_OBJECT + +private: + QTemporaryDir mXdgDir; + QByteArray mSavedXdg; + + // not created in initTestCase(): the first test needs a profiles/ with + // nothing in it at all + const QString mProfileName = qsl("ConnDialogCrash-Test"); + // what copyProfileWidget() names the copy of a name not ending in a digit + const QString mCopyName = qsl("ConnDialogCrash-Test1"); + const QString mQuietProfileName = qsl("ConnDialogCrash-Quiet"); + const QString mQuietCopyName = qsl("ConnDialogCrash-Quiet1"); + + static constexpr int scmMyGamesTab = 0; + static constexpr int scmAllGamesTab = 1; + static constexpr int scmTestMarkerRole = Qt::UserRole + 99; + const QString mProfileUrl = qsl("mudlet.org"); + const QString mProfilePort = qsl("23"); + + // setupConfig() consults portable.txt before the XDG logic, so its presence + // would put this test on the user's real config dir (see ConfigDirOverrideTest) + bool portableMarkerPresent() const + { + return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath())); + } + + // by name: QTabWidget gives the dialog a second QTabBar + QTabBar* gamesTabBar(dlgConnectionProfiles* dialog) const { return dialog->findChild<QTabBar*>(qsl("gamesTabBar")); } + + // members, not locals of the calling test: with no menu the timer is still + // armed when that method returns + QStringList mMenuActionTexts; + bool mSawMenu = false; + QString mUnexpectedPopup; + + // menu.exec() runs its own event loop, so the menu can only be inspected and + // dismissed from inside it; callers stop the timer when no menu appears + QTimer* armMenuCloser() + { + mMenuActionTexts.clear(); + mSawMenu = false; + mUnexpectedPopup.clear(); + auto* closer = new QTimer(this); + closer->setInterval(20); + connect(closer, &QTimer::timeout, this, [this, closer]() { + auto* popup = QApplication::activePopupWidget(); + if (!popup) { + return; + } + auto* menu = qobject_cast<QMenu*>(popup); + if (!menu) { + // menu.exec() waits on whatever holds the popup, so close it + // rather than time the run out + mUnexpectedPopup = QString::fromLatin1(popup->metaObject()->className()); + popup->close(); + closer->stop(); + return; + } + mSawMenu = true; + const auto actions = menu->actions(); + for (const auto* action : actions) { + mMenuActionTexts << action->text(); + } + menu->close(); + closer->stop(); + }); + closer->start(); + return closer; + } + + void disarmMenuCloser(QTimer* closer) + { + closer->stop(); + closer->deleteLater(); + } + + QString menuOutcome() const { return mUnexpectedPopup.isEmpty() ? QString() : qsl(" (a %1 took the popup instead)").arg(mUnexpectedPopup); } + + // Must go to the viewport: QAbstractScrollArea ignores a mouse-reason + // context menu sent to itself, and its viewportEvent() is what raises + // customContextMenuRequested. + void rightClickBelowTheLastItem(QAbstractScrollArea* view) const + { + auto* viewport = view->viewport(); + const QPoint pos(viewport->width() / 2, viewport->height() - 4); + QContextMenuEvent event(QContextMenuEvent::Mouse, pos, viewport->mapToGlobal(pos)); + QApplication::sendEvent(viewport, &event); + } + + // reports instead of QVERIFYing: a QVERIFY here would only leave the helper + bool makeProfileFolder(const QString& name) const + { + return QDir().mkpath(mudlet::getMudletPath(enums::profileHomePath, name)) && mudlet::self()->writeProfileData(name, qsl("url"), mProfileUrl).first + && mudlet::self()->writeProfileData(name, qsl("port"), mProfilePort).first; + } + +private slots: + void initTestCase() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - cannot redirect the config dir for this test"); + } + initializeQRCResources(); + + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(mXdgDir.isValid()); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mXdgDir.path()))); // profiles/ = XDG opt-in + qputenv("XDG_CONFIG_HOME", mXdgDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + QVERIFY(mudlet::getMudletPath(enums::profilesPath).startsWith(mXdgDir.path())); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + mudlet::self()->startAutoLogin({}); + // the dialog is only shown from a queued lambda, so the pointer turning + // up is not enough + QVERIFY(QTest::qWaitFor( + []() { + return mudlet::self()->mpConnectionDialog && mudlet::self()->mpConnectionDialog->isVisible(); + }, + 5000)); + } + + void cleanupTestCase() + { + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + delete mudlet::self(); + } + + void test_rightClickWithNoProfileSelected() + { + auto* dialog = mudlet::self()->mpConnectionDialog.data(); + QVERIFY2(dialog, "No connection dialog to test against"); + + auto* skipButton = dialog->findChild<QPushButton*>(qsl("skipToGamesButton")); + QVERIFY2(skipButton, "The first-launch invitation has no skip button any more"); + QVERIFY2(skipButton->isVisible(), "This is not a first-launch dialog - the skip button is not shown"); + skipButton->click(); + QTest::qWait(100ms); + + auto* tabBar = gamesTabBar(dialog); + QVERIFY2(tabBar, "The games list has no tab bar"); + tabBar->setCurrentIndex(scmMyGamesTab); + QTest::qWait(100ms); + + // not an empty list: a debug build still lists the self-test entry, but + // none of it is on disk so fillout_form() makes nothing current + QVERIFY2(!dialog->listWidget_profiles->currentItem(), + qPrintable( + qsl("The 'My games' tab of a fresh install selected something (%1 items listed) - this test no longer covers the reported crash").arg(dialog->listWidget_profiles->count()))); + + auto* closer = armMenuCloser(); + rightClickBelowTheLastItem(dialog->listWidget_profiles); + disarmMenuCloser(closer); + + QVERIFY2(!mSawMenu, "A context menu was offered with no profile for it to act on"); + QVERIFY2(mUnexpectedPopup.isEmpty(), qPrintable(menuOutcome())); + QVERIFY2(!QApplication::activePopupWidget(), "A popup was left on screen"); + } + + void test_rightClickOnAnEmptyList() + { + auto* dialog = mudlet::self()->mpConnectionDialog.data(); + QVERIFY2(dialog, "No connection dialog to test against"); + + dialog->listWidget_profiles->clear(); + QCOMPARE(dialog->listWidget_profiles->count(), 0); + + auto* closer = armMenuCloser(); + rightClickBelowTheLastItem(dialog->listWidget_profiles); + disarmMenuCloser(closer); + + QVERIFY2(!mSawMenu, "A context menu was offered for an empty games list"); + QVERIFY2(mUnexpectedPopup.isEmpty(), qPrintable(menuOutcome())); + + dialog->fillout_form(); + QTest::qWait(100ms); + } + + // so that "return early when nothing is current" cannot become "return early" + void test_contextMenuStillOpensForASelectedProfile() + { + auto* dialog = mudlet::self()->mpConnectionDialog.data(); + QVERIFY2(dialog, "No connection dialog to test against"); + + auto* tabBar = gamesTabBar(dialog); + QVERIFY(tabBar); + tabBar->setCurrentIndex(scmAllGamesTab); + QTest::qWait(100ms); + + QVERIFY2(dialog->listWidget_profiles->count() > 0, "The 'All games' tab lists nothing"); + dialog->listWidget_profiles->setCurrentRow(0); + QVERIFY2(dialog->listWidget_profiles->currentItem(), "Could not select a profile to open the menu for"); + + auto* closer = armMenuCloser(); + rightClickBelowTheLastItem(dialog->listWidget_profiles); + disarmMenuCloser(closer); + + QVERIFY2(mSawMenu, qPrintable(qsl("No context menu appeared for a selected profile%1").arg(menuOutcome()))); + // "Set custom icon" and "Set custom color" for a profile without one + QCOMPARE(mMenuActionTexts.size(), 2); + QVERIFY2(!mMenuActionTexts.first().isEmpty(), "The menu offered a nameless action"); + } + + void test_copiedProfileSurvivesTheListBeingRebuilt() + { + auto* dialog = mudlet::self()->mpConnectionDialog.data(); + QVERIFY2(dialog, "No connection dialog to test against"); + + QVERIFY(makeProfileFolder(mProfileName)); + QDir(mudlet::getMudletPath(enums::profileHomePath, mCopyName)).removeRecursively(); + + auto* tabBar = gamesTabBar(dialog); + QVERIFY(tabBar); + tabBar->setCurrentIndex(scmMyGamesTab); + dialog->fillout_form(); + // clear slot_itemClicked()'s 100ms same-profile debounce, which would + // otherwise leave the form blank after fillout_form() cleared it + QTest::qWait(300ms); + + const auto items = dialog->findData(*dialog->listWidget_profiles, mProfileName, dlgConnectionProfiles::csmNameRole); + QVERIFY2(!items.isEmpty(), "The test profile is not listed in the dialog"); + dialog->listWidget_profiles->setCurrentItem(items.first()); + dialog->slot_itemClicked(items.first()); + QCOMPARE(dialog->profile_name_entry->text(), mProfileName); + + auto* copyAction = dialog->findChild<QAction*>(qsl("copyProfile")); + QVERIFY2(copyAction, "The dialog has no Copy action any more"); + + dialog->slot_copyProfile(); + QVERIFY2(!copyAction->isEnabled(), "The copy did not take the asynchronous path"); + // the copy reports back through the event loop, which has not run since, + // so this destroys the copy's item before the handler sees it + tabBar->setCurrentIndex(scmAllGamesTab); + + QVERIFY2(QTest::qWaitFor( + [copyAction]() { + return copyAction->isEnabled(); + }, + 15000), + "The copy never completed"); + + QVERIFY2(QDir(mudlet::getMudletPath(enums::profileHomePath, mCopyName)).exists(), "The copy has no folder on disk"); + QCOMPARE(dialog->readProfileData(mCopyName, qsl("url")), mProfileUrl); + QCOMPARE(dialog->readProfileData(mCopyName, qsl("port")), mProfilePort); + QVERIFY2(!dialog->findData(*dialog->listWidget_profiles, mCopyName, dlgConnectionProfiles::csmNameRole).isEmpty(), "The copy is not listed in the games list"); + // No assertions on the form fields: a copy completes inside + // slot_itemClicked()'s 100ms debounce, which swallows the fill and + // leaves Server address and Port blank - a separate bug. + auto* pCurrentItem = dialog->listWidget_profiles->currentItem(); + QVERIFY2(pCurrentItem, "Nothing is selected after the copy finished"); + QCOMPARE(pCurrentItem->data(dlgConnectionProfiles::csmNameRole).toString(), mCopyName); + + QDir(mudlet::getMudletPath(enums::profileHomePath, mCopyName)).removeRecursively(); + QDir(mudlet::getMudletPath(enums::profileHomePath, mProfileName)).removeRecursively(); + dialog->fillout_form(); + QTest::qWait(100ms); + } + + // the branch where the copy's item is still there and still current + void test_copiedProfileIsSelectedWhenTheListIsLeftAlone() + { + auto* dialog = mudlet::self()->mpConnectionDialog.data(); + QVERIFY2(dialog, "No connection dialog to test against"); + + QVERIFY(makeProfileFolder(mQuietProfileName)); + QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietCopyName)).removeRecursively(); + + auto* tabBar = gamesTabBar(dialog); + QVERIFY(tabBar); + tabBar->setCurrentIndex(scmMyGamesTab); + dialog->fillout_form(); + QTest::qWait(300ms); + + const auto items = dialog->findData(*dialog->listWidget_profiles, mQuietProfileName, dlgConnectionProfiles::csmNameRole); + QVERIFY2(!items.isEmpty(), "The test profile is not listed in the dialog"); + dialog->listWidget_profiles->setCurrentItem(items.first()); + dialog->slot_itemClicked(items.first()); + QCOMPARE(dialog->profile_name_entry->text(), mQuietProfileName); + + auto* copyAction = dialog->findChild<QAction*>(qsl("copyProfile")); + QVERIFY(copyAction); + dialog->slot_copyProfile(); + QVERIFY2(!copyAction->isEnabled(), "The copy did not take the asynchronous path"); + + const auto created = dialog->findData(*dialog->listWidget_profiles, mQuietCopyName, dlgConnectionProfiles::csmNameRole); + QVERIFY2(!created.isEmpty(), "The copy got no entry in the list"); + // a mark outlives a rebuild check; a pointer would have been freed by one + created.first()->setData(scmTestMarkerRole, true); + + QVERIFY2(QTest::qWaitFor( + [copyAction]() { + return copyAction->isEnabled(); + }, + 15000), + "The copy never completed"); + + QVERIFY2(QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietCopyName)).exists(), "The copy has no folder on disk"); + QCOMPARE(dialog->readProfileData(mQuietCopyName, qsl("url")), mProfileUrl); + QCOMPARE(dialog->readProfileData(mQuietCopyName, qsl("port")), mProfilePort); + auto* pCurrentItem = dialog->listWidget_profiles->currentItem(); + QVERIFY2(pCurrentItem, "Nothing is selected after the copy finished"); + QCOMPARE(pCurrentItem->data(dlgConnectionProfiles::csmNameRole).toString(), mQuietCopyName); + QVERIFY2(pCurrentItem->data(scmTestMarkerRole).toBool(), "The list was rebuilt after all - this test no longer covers the undisturbed branch"); + + QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietCopyName)).removeRecursively(); + QDir(mudlet::getMudletPath(enums::profileHomePath, mQuietProfileName)).removeRecursively(); + dialog->fillout_form(); + QTest::qWait(100ms); + } + + // Must stay last: leaves the main window hidden and no connection dialog, + // both of which the other tests need. + void test_connectionDialogClosedBeforeItIsShown() + { + auto* mudletApp = mudlet::self(); + if (mudletApp->mpConnectionDialog) { + mudletApp->mpConnectionDialog->close(); + mudletApp->mpConnectionDialog = nullptr; + QTest::qWait(200ms); + } + QVERIFY2(!mudletApp->mpConnectionDialog, "Could not get rid of the connection dialog this test starts from"); + + mudletApp->slot_showConnectionDialog(); + QVERIFY2(mudletApp->mpConnectionDialog, "No connection dialog was created"); + + // what closeEvent() does, with the event loop not having run since + // slot_showConnectionDialog() queued its lambda + QVERIFY2(mudletApp->isVisible(), "The main window has to start out visible for the hide() below to mean anything"); + mudletApp->mpConnectionDialog->close(); + mudletApp->mpConnectionDialog = nullptr; + mudletApp->hide(); + QVERIFY2(!mudletApp->isVisible(), "The main window did not hide"); + + QTest::qWait(300ms); // the queued lambda gets its turn in here + + QVERIFY2(!mudletApp->mpConnectionDialog, "The queued lambda brought the connection dialog back"); + QVERIFY2(!mudletApp->isVisible(), "The queued lambda re-showed the main window Mudlet was shutting down"); + } +}; + +QTEST_MAIN(ConnectionDialogCrashTest) +#include "ConnectionDialogCrashTest.moc" diff --git a/test/functional_tests/CopyAsImageTest.cpp b/test/functional_tests/CopyAsImageTest.cpp new file mode 100644 index 000000000..918766d21 --- /dev/null +++ b/test/functional_tests/CopyAsImageTest.cpp @@ -0,0 +1,589 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QClipboard> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TBuffer.h" +#include "TMainConsole.h" +#include "TTextEdit.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResources(); + +// Regression tests for #9715: the console's "Copy as image" context menu entry +// leaving nothing on the clipboard. +class CopyAsImageTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mpHostname = "Test-CopyAsImage"; + QString mpPort; // assigned the stub's actual ephemeral port in init() + const QString mpLocalhost = "localhost"; + + QString fillerText() const + { + const QString line = QString(100, QLatin1Char('X')); + QString message; + for (int i = 0; i < 80; ++i) { + message.append(line); + message.append(QStringLiteral("\r\n")); + } + return message; + } + + TTextEdit* upperPane() const + { + auto host = mudlet::self()->getActiveHost(); + if (!host || !host->mpConsole) { + return nullptr; + } + return host->mpConsole->mUpperPane; + } + + void sendMouse(QWidget* w, QEvent::Type type, Qt::MouseButton button, Qt::MouseButtons buttons, const QPointF& localPos) + { + const QPointF globalPos = w->mapToGlobal(localPos.toPoint()); + QMouseEvent event(type, localPos, globalPos, button, buttons, Qt::NoModifier); + QApplication::sendEvent(w, &event); + } + + TTextEdit* preparePane() + { + mpServer->setWelcomeMessage(fillerText()); + if (!startProfile(mpHostname, mpLocalhost, mpPort)) { + return nullptr; + } + if (!waitForTextInBuffer(QString(100, QLatin1Char('X')))) { + return nullptr; + } + + // big enough for the multi-line drags the tests make across the pane + mudlet::self()->resize(1200, 800); + QTest::qWait(100ms); + + TTextEdit* pane = upperPane(); + if (!pane) { + return nullptr; + } + pane->unHighlight(); + pane->mSelectedRegion = QRegion(); + return pane; + } + + void dragSelection(TTextEdit* pane, const QPointF& dragOffset) + { + const QPointF startPos = QRectF(pane->rect()).center(); + const QPointF endPos = startPos + dragOffset; + sendMouse(pane, QEvent::MouseButtonPress, Qt::LeftButton, Qt::LeftButton, startPos); + sendMouse(pane, QEvent::MouseMove, Qt::NoButton, Qt::LeftButton, endPos); + sendMouse(pane, QEvent::MouseButtonRelease, Qt::LeftButton, Qt::NoButton, endPos); + } + + TTextEdit* prepareSelectedPane(const QPointF& dragOffset) + { + TTextEdit* pane = preparePane(); + if (!pane) { + return nullptr; + } + dragSelection(pane, dragOffset); + return pane; + } + + static void copyAsImage(TTextEdit* pane) + { + QApplication::clipboard()->clear(); + pane->slot_copySelectionToClipboardImage(); + } + + // mouseReleaseEvent() parents the menu to the pane, so it can be read back + // from there rather than having to be intercepted as it pops up. + QMenu* openContextMenu(TTextEdit* pane) + { + const QPointF pos = QRectF(pane->rect()).center(); + sendMouse(pane, QEvent::MouseButtonPress, Qt::RightButton, Qt::RightButton, pos); + sendMouse(pane, QEvent::MouseButtonRelease, Qt::RightButton, Qt::NoButton, pos); + return pane->findChildren<QMenu*>().value(0); + } + + static QAction* menuEntry(QMenu* menu, const QString& objectName) + { + for (QAction* action : menu->actions()) { + if (action->objectName() == objectName) { + return action; + } + } + return nullptr; + } + + // "Copy as image" is deliberately not one of these: it falls back to the + // visible screen instead of needing a selection. + static QStringList selectionEntryNames() { return {QStringLiteral("consoleCopy"), QStringLiteral("consoleCopyHtml"), QStringLiteral("consoleSearchOnline")}; } + + static int backgroundPixels(const QImage& image, const QColor& backgroundColour) + { + const QRgb background = backgroundColour.rgb() | 0xff000000; + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if ((image.pixel(x, y) | 0xff000000) == background) { + ++count; + } + } + } + return count; + } + + static bool blankImage(const QImage& image, const QColor& backgroundColour) { return backgroundPixels(image, backgroundColour) == image.width() * image.height(); } + + // Text drawn normally leaves most of the cell as background; a line drawn + // with its selection still on has the two swapped over. + static bool invertedImage(const QImage& image, const QColor& backgroundColour) { return backgroundPixels(image, backgroundColour) * 2 < image.width() * image.height(); } + + // Mimics TBuffer::shrinkBuffer() dropping the oldest lines once the buffer + // reaches its size limit, which shifts every remaining line's index down. + void shrinkBuffer(TBuffer& buffer, int lines) + { + buffer.mBatchDeleteSize = lines; + for (int i = 0; i < lines; ++i) { + buffer.lineBuffer.pop_front(); + buffer.promptBuffer.pop_front(); + buffer.timeBuffer.pop_front(); + buffer.buffer.pop_front(); + buffer.mCursorY--; + } + } + +private slots: + void initTestCase() { initializeQRCResources(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + mpServer->start(mpLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mpPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mpHostname); + } + + // #9715 as reported, driven through the menu so a mis-wired entry is caught too. + void test_noSelectionCopiesTheVisibleScreen() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + QVERIFY(pane->mSelectedRegion.isEmpty()); + + QMenu* menu = openContextMenu(pane); + QVERIFY2(menu, "Right-clicking the console opened no context menu"); + QAction* copyAsImageEntry = menuEntry(menu, QStringLiteral("consoleCopyAsImage")); + QVERIFY2(copyAsImageEntry, "No \"Copy as image\" entry in the console context menu"); + QVERIFY2(copyAsImageEntry->isEnabled(), "\"Copy as image\" was not offered with nothing selected (regression of #9715)"); + + QApplication::clipboard()->clear(); + copyAsImageEntry->trigger(); + + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "\"Copy as image\" put nothing on the clipboard (regression of #9715)"); + QVERIFY2(!blankImage(image, pane->mBgColor), "The copied image is entirely background, no text was drawn into it"); + QCOMPARE(image.height() % pane->mFontHeight, 0); + QVERIFY2(image.height() >= 10 * pane->mFontHeight, qPrintable(QStringLiteral("Only %1 lines copied, expected a screenful").arg(image.height() / pane->mFontHeight))); + QVERIFY2(image.height() <= pane->height(), qPrintable(QStringLiteral("Copied %1px, taller than the %2px pane").arg(image.height()).arg(pane->height()))); + } + + void test_visibleScreenCopyIncludesTimestamps() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + auto console = mudlet::self()->getActiveHost()->mpConsole; + QVERIFY(!console->showTimeStamps()); + + copyAsImage(pane); + const int widthWithoutTimestamps = QApplication::clipboard()->image().width(); + QVERIFY(widthWithoutTimestamps > 0); + + console->slot_toggleTimeStamps(true); + QVERIFY(console->showTimeStamps()); + QTest::qWait(100ms); + + copyAsImage(pane); + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "\"Copy as image\" put nothing on the clipboard with timestamps showing"); + QCOMPARE(image.width(), widthWithoutTimestamps + mudlet::smTimeStampFormat.size() * pane->mFontWidth); + } + + void test_contextMenuOffersNoSelectionOnlyEntriesWithoutSelection() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + QVERIFY(pane->mSelectedRegion.isEmpty()); + + QMenu* menu = openContextMenu(pane); + QVERIFY2(menu, "Right-clicking the console opened no context menu"); + + for (const QString& name : selectionEntryNames()) { + QAction* entry = menuEntry(menu, name); + QVERIFY2(entry, qPrintable(QStringLiteral("No \"%1\" entry in the console context menu").arg(name))); + QVERIFY2(!entry->isEnabled(), qPrintable(QStringLiteral("\"%1\" was offered as usable with nothing selected").arg(name))); + QVERIFY2(!entry->toolTip().isEmpty(), qPrintable(QStringLiteral("\"%1\" is disabled without saying why").arg(name))); + } + + QAction* selectAll = menuEntry(menu, QStringLiteral("consoleSelectAll")); + QVERIFY(selectAll); + QVERIFY2(selectAll->isEnabled(), "\"Select all\" should not need an existing selection"); + } + + void test_contextMenuOffersCopyEntriesWithSelection() + { + TTextEdit* pane = prepareSelectedPane(QPointF(60, 0)); + QVERIFY2(pane, "Could not prepare a console with a selection"); + QVERIFY(!pane->mSelectedRegion.isEmpty()); + + QMenu* menu = openContextMenu(pane); + QVERIFY2(menu, "Right-clicking the console opened no context menu"); + + for (const QString& name : selectionEntryNames() + QStringList{QStringLiteral("consoleCopyAsImage")}) { + QAction* entry = menuEntry(menu, name); + QVERIFY2(entry, qPrintable(QStringLiteral("No \"%1\" entry in the console context menu").arg(name))); + QVERIFY2(entry->isEnabled(), qPrintable(QStringLiteral("\"%1\" was disabled even though text is selected").arg(name))); + } + } + + void test_selectionCopiesOnlyTheSelectedLines() + { + TTextEdit* pane = prepareSelectedPane(QPointF(60, 0)); + QVERIFY2(pane, "Could not prepare a console with a selection"); + + copyAsImage(pane); + + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "\"Copy as image\" put nothing on the clipboard (regression of #9715)"); + QCOMPARE(image.height(), pane->mFontHeight); + QVERIFY2(!blankImage(image, pane->mBgColor), "The copied image is entirely background, no text was drawn into it"); + } + + // The height must come from the dragged distance, not from a recomputed + // mScreenHeight. + void test_multiLineSelectionCopiesEveryLine() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + const int expectedLines = 4; + dragSelection(pane, QPointF(60, (expectedLines - 1) * pane->mFontHeight)); + QVERIFY2(!pane->mSelectedRegion.isEmpty(), "The drag failed to create a selection"); + + copyAsImage(pane); + + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "\"Copy as image\" put nothing on the clipboard (regression of #9715)"); + QCOMPARE(image.height(), expectedLines * pane->mFontHeight); + QVERIFY2(!blankImage(image, pane->mBgColor), "The copied image is entirely background, no text was drawn into it"); + } + + void test_repeatedCopyKeepsWorking() + { + TTextEdit* pane = prepareSelectedPane(QPointF(60, 60)); + QVERIFY2(pane, "Could not prepare a console with a selection"); + + copyAsImage(pane); + const QImage first = QApplication::clipboard()->image(); + QVERIFY2(!first.isNull(), "The first \"Copy as image\" put nothing on the clipboard"); + + copyAsImage(pane); + const QImage second = QApplication::clipboard()->image(); + QVERIFY2(!second.isNull(), "A second \"Copy as image\" of the same selection put nothing on the clipboard"); + // the first copy deselects and reselects to keep inverted colours out of + // the image, so a mismatch here means it did not put the state back + QCOMPARE(second, first); + } + + // A selection can be a whole buffer long, so the copy gives up on a timeout - + // whatever it drew by then still has to reach the clipboard at its own scale. + void test_abandonedCopyKeepsTheLinesItDrew() + { + TTextEdit* pane = prepareSelectedPane(QPointF(60, 60)); + QVERIFY2(pane, "Could not prepare a console with a selection"); + + const int originalTimeout = mudlet::self()->mCopyAsImageTimeout; + // 0s of budget stops the drawing after the very first line + mudlet::self()->mCopyAsImageTimeout = 0; + copyAsImage(pane); + const QImage abandoned = QApplication::clipboard()->image(); + mudlet::self()->mCopyAsImageTimeout = originalTimeout; + + QVERIFY2(!abandoned.isNull(), "A copy that ran out of time left nothing at all on the clipboard"); + QCOMPARE(abandoned.height(), pane->mFontHeight); + + copyAsImage(pane); + const QImage complete = QApplication::clipboard()->image(); + QVERIFY2(complete.height() > abandoned.height(), "The unrestricted copy is no taller, so the abandoned one was not actually cut short"); + // scaling the abandoned copy to fit would have shrunk its width in + // proportion and resampled the one line it did draw + QCOMPARE(abandoned.width(), complete.width()); + QCOMPARE(abandoned, complete.copy(QRect(0, 0, complete.width(), abandoned.height()))); + } + + void test_blankLineSelectionCopiesAnImage() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + // with timestamps on, a blank line is still 13 characters wide, which is + // not the zero width case this covers + QVERIFY(!mudlet::self()->getActiveHost()->mpConsole->showTimeStamps()); + + const int blankLine = firstBlankLine(); + QVERIFY2(blankLine >= 0, "The console has no blank line to select"); + + pane->mDragStart = QPoint(0, blankLine); + pane->mDragSelectionEnd = pane->mDragStart; + pane->normaliseSelection(); + pane->highlightSelection(); + QVERIFY2(!pane->mSelectedRegion.isEmpty(), "Could not select a blank line"); + + copyAsImage(pane); + + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "Copying a selection of blank lines left nothing on the clipboard"); + QCOMPARE(image.height(), pane->mFontHeight); + QCOMPARE(image.width(), pane->mFontWidth); + QVERIFY2(blankImage(image, pane->mBgColor), "A blank line copied as something other than background"); + } + + void test_emptyBufferCopiesNothingAndKeepsTheClipboard() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + // TConsole::clear() leaves an empty line behind, so empty it by hand + auto& buffer = mudlet::self()->getActiveHost()->mpConsole->buffer; + buffer.lineBuffer.clear(); + buffer.timeBuffer.clear(); + buffer.promptBuffer.clear(); + buffer.buffer.clear(); + buffer.mCursorY = 0; + + QMenu* menu = openContextMenu(pane); + QVERIFY2(menu, "Right-clicking the console opened no context menu"); + QAction* copyAsImageEntry = menuEntry(menu, QStringLiteral("consoleCopyAsImage")); + QVERIFY(copyAsImageEntry); + QVERIFY2(!copyAsImageEntry->isEnabled(), "\"Copy as image\" was offered for a console holding no text at all"); + QVERIFY2(!copyAsImageEntry->toolTip().isEmpty(), "\"Copy as image\" is disabled without saying why"); + + QApplication::clipboard()->setText(QStringLiteral("something the user copied earlier")); + pane->slot_copySelectionToClipboardImage(); + QCOMPARE(QApplication::clipboard()->text(), QStringLiteral("something the user copied earlier")); + } + + // Clearing a console strands the selection on lines that no longer exist. + void test_copyAfterClearingTheConsole() + { + TTextEdit* pane = prepareSelectedPane(QPointF(60, 60)); + QVERIFY2(pane, "Could not prepare a console with a selection"); + + mudlet::self()->getActiveHost()->mpConsole->TConsole::clear(); + QVERIFY2(pane->mSelectedRegion.isEmpty(), "Clearing the console left a selection behind pointing at lines that are gone"); + + copyAsImage(pane); + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "Copying a cleared console put nothing on the clipboard"); + QVERIFY2(blankImage(image, pane->mBgColor), "A cleared console copied as something other than background"); + } + + // Lines dropped off the front of a full buffer shift every remaining index + // down, so the selection has to be followed down with them. + void test_copyFollowsTheSelectionThroughABufferShrink() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + auto& buffer = mudlet::self()->getActiveHost()->mpConsole->buffer; + + dragSelection(pane, QPointF(60, 3 * pane->mFontHeight)); + QVERIFY2(!pane->mSelectedRegion.isEmpty(), "The drag failed to create a selection"); + const int selectedLines = pane->mPB.y() - pane->mPA.y() + 1; + + // enough to push the selection past the end of the buffer, so that the + // shift is what has to bring it back rather than it happening to still fit + const int droppedLines = buffer.lineBuffer.size() - pane->mPB.y() + 2; + QVERIFY2(droppedLines > 0 && droppedLines <= pane->mPA.y(), "Could not size a buffer shrink that strands the selection"); + shrinkBuffer(buffer, droppedLines); + QVERIFY2(pane->mPB.y() > buffer.getLastLineNumber(), "The selection still fits, so the shift is not being exercised"); + + copyAsImage(pane); + + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "Copying after a buffer shrink put nothing on the clipboard"); + QCOMPARE(image.height(), selectedLines * pane->mFontHeight); + QVERIFY2(!blankImage(image, pane->mBgColor), "The copied image is entirely background, the selection was not followed down"); + QVERIFY2(!invertedImage(image, pane->mBgColor), "The copied image still has the selection's inverted colours on it"); + } + + // A selection outliving its lines must not be reinterpreted as whatever now + // sits at those buffer indices. + void test_copyOfASelectionPastTheEndOfTheBuffer() + { + TTextEdit* pane = preparePane(); + QVERIFY2(pane, "Could not prepare a console"); + auto console = mudlet::self()->getActiveHost()->mpConsole; + const int lastLine = console->buffer.getLastLineNumber(); + + // beyond the buffer and beyond any batch-delete adjustment, i.e. gone + pane->mDragStart = QPoint(0, lastLine + console->buffer.mBatchDeleteSize + 10); + pane->mDragSelectionEnd = QPoint(6, lastLine + console->buffer.mBatchDeleteSize + 12); + pane->normaliseSelection(); + pane->mSelectedRegion = QRegion(0, 0, 10, 10); + + copyAsImage(pane); + + const QImage image = QApplication::clipboard()->image(); + QVERIFY2(!image.isNull(), "Copying a selection that outlived its lines put nothing on the clipboard"); + QVERIFY2(image.height() >= 10 * pane->mFontHeight, qPrintable(QStringLiteral("Only %1 lines copied, expected the visible screen").arg(image.height() / pane->mFontHeight))); + QVERIFY2(!blankImage(image, pane->mBgColor), "The copied image is entirely background, no text was drawn into it"); + } + + void cleanup() + { + const QString profilePath = mudlet::getMudletPath(enums::profileHomePath, mpHostname); + + // Tear down Mudlet (and with it the live cTelnet connection) before the + // stub server it is talking to, so the socket is closed from the client + // side rather than being yanked out from under an active connection. + delete mudlet::self(); + delete mpServer; + mpServer = nullptr; + deleteDirectory(profilePath); + } + +private: + // Returns false rather than QVERIFYing, which would only abort the caller's + // helper and let the test go on to dereference a host that never appeared. + bool startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + qWarning() << "Profile took too long to load."; + return false; + } + auto host = mudlet::self()->getActiveHost(); + if (!host || !host->mpConsole) { + qWarning() << "No active host available for the test."; + return false; + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + qWarning() << "Could not connect with the host."; + return false; + } + return true; + } + + int firstBlankLine() const + { + auto console = mudlet::self()->getActiveHost()->mpConsole; + for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) { + if (console->buffer.lineBuffer.at(i).isEmpty()) { + return i; + } + } + return -1; + } + + bool waitForTextInBuffer(const QString& text, int timeoutMs = 5000) + { + auto console = mudlet::self()->getActiveHost()->mpConsole; + return QTest::qWaitFor( + [&]() { + for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) { + if (console->buffer.line(i) == text) { + return true; + } + } + return false; + }, + timeoutMs); + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + deleteDirectory(path); + } + + void deleteDirectory(const QString& path) + { + QDir dir(path); + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "CopyAsImageTest.moc" +QTEST_MAIN(CopyAsImageTest) diff --git a/test/functional_tests/DefaultGameDeleteTest.cpp b/test/functional_tests/DefaultGameDeleteTest.cpp new file mode 100644 index 000000000..5b476215c --- /dev/null +++ b/test/functional_tests/DefaultGameDeleteTest.cpp @@ -0,0 +1,226 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Deleting a pre-installed game's profile from the "My games" tab of the + * connection dialog must only remove that profile's local data - the game has + * to remain available in the "All games" catalog afterwards. It used to also + * vanish from "All games" because the deletion recorded the game in the + * deletedDefaultMuds blocklist which fillout_form() applied to both tabs. + * + * Run with: ctest -R DefaultGameDeleteTest -V + */ + +#include <QtTest/QtTest> + +#include <QTabBar> +#include <QTabWidget> + +#include "MudletInstanceCoordinator.h" +#include "TGameDetails.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForDefaultGameDeleteTest(); + +class DefaultGameDeleteTest : public QObject +{ + Q_OBJECT + +private: + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + const QString mGame = qsl("Mudlet Tutorial"); + const QString mSelfTest = qsl("Mudlet self-test"); + + // the dialog holds two QTabBars: the games-list one, created directly on + // the dialog, and QTabWidget's internal one - the parent check tells them + // apart without matching translated tab text + QTabBar* gamesTabBar(dlgConnectionProfiles* dlg) const + { + const auto tabBars = dlg->findChildren<QTabBar*>(); + for (auto* tabBar : tabBars) { + if (!qobject_cast<QTabWidget*>(tabBar->parentWidget())) { + return tabBar; + } + } + return nullptr; + } + + bool gameListed(dlgConnectionProfiles* dlg, const QString& game) const { return !dlg->findData(*dlg->listWidget_profiles, game, dlgConnectionProfiles::csmNameRole).isEmpty(); } + +private slots: + void initTestCase() + { + initializeQRCResourcesForDefaultGameDeleteTest(); + + QVERIFY(mConfigDir.isValid()); + // pre-create $XDG_CONFIG_HOME/mudlet/profiles so setupConfig() adopts it + // and the test never touches the real profiles or settings + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + QCOMPARE(mudlet::getMudletPath(enums::mainPath), qsl("%1/mudlet").arg(mConfigDir.path())); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QVERIFY2(TGameDetails::keys().contains(mGame), "expected pre-installed game missing from TGameDetails"); + } + + void cleanupTestCase() + { + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + void test_deletedDefaultGameStaysInAllGames() + { + // an on-disk profile dir makes the game appear under "My games"; an + // empty one means slot_deleteProfile() deletes it without raising the + // confirmation dialog + QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileHomePath, mGame))); + + auto* dlg = new dlgConnectionProfiles(); + dlg->show(); + dlg->fillout_form(); + auto* tabBar = gamesTabBar(dlg); + QVERIFY(tabBar); + + tabBar->setCurrentIndex(0); // "My games" + dlg->fillout_form(); + QVERIFY2(gameListed(dlg, mGame), "game with an on-disk profile should show under 'My games'"); + + // having no sub-directory of its own - nothing but the connection + // details the dialog wrote there - is what makes slot_deleteProfile() + // skip the confirmation dialog; assert it so a change there fails loudly + QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mGame)).entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty()); + const auto items = dlg->findData(*dlg->listWidget_profiles, mGame, dlgConnectionProfiles::csmNameRole); + dlg->listWidget_profiles->setCurrentItem(items.first()); + dlg->slot_deleteProfile(); + + QVERIFY2(!gameListed(dlg, mGame), "deleted game should no longer show under 'My games'"); + QVERIFY2(!QDir(mudlet::getMudletPath(enums::profileHomePath, mGame)).exists(), "profile data should be removed from disk"); + + tabBar->setCurrentIndex(1); // "All games", refills the list + QVERIFY2(gameListed(dlg, mGame), "a deleted pre-installed game must still be offered under 'All games'"); + + dlg->deleteLater(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + // users who deleted a pre-installed game before this fix have it recorded + // in the deletedDefaultMuds blocklist; it must not keep the game out of + // the catalog + void test_legacyBlocklistedGameStillShownInAllGames() + { + mudlet::self()->mpSettings->setValue(qsl("deletedDefaultMuds"), QStringList{mGame}); + + auto* dlg = new dlgConnectionProfiles(); + dlg->show(); + auto* tabBar = gamesTabBar(dlg); + QVERIFY(tabBar); + // the dialog already opens on "All games" (tab choice persisted by the + // previous test), so setCurrentIndex() fires no currentChanged and the + // explicit refill is load-bearing + tabBar->setCurrentIndex(1); // "All games" + dlg->fillout_form(); + + QVERIFY2(gameListed(dlg, mGame), "a game on the legacy deletedDefaultMuds blocklist must still be offered under 'All games'"); + + dlg->deleteLater(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + // the self-test entry is not a game: debug builds offer it without any + // profile data on disk, so unlike a real pre-installed game it has to + // stay dismissed once deleted + void test_deletedSelfTestStaysHidden() + { + mudlet::self()->mpSettings->setValue(qsl("deletedDefaultMuds"), QStringList{mSelfTest}); + + auto* dlg = new dlgConnectionProfiles(); + dlg->show(); + auto* tabBar = gamesTabBar(dlg); + QVERIFY(tabBar); + tabBar->setCurrentIndex(1); // "All games" + dlg->fillout_form(); + + QVERIFY2(!gameListed(dlg, mSelfTest), "a deleted self-test entry must not come back under 'All games'"); + + tabBar->setCurrentIndex(0); // "My games" + dlg->fillout_form(); + QVERIFY2(!gameListed(dlg, mSelfTest), "a deleted self-test entry must not come back under 'My games'"); + + dlg->deleteLater(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + // debug builds add the self-test entry to "My games" themselves; "All + // games" already gets it from TGameDetails, so it must not be listed twice + void test_selfTestListedOnce() + { + mudlet::self()->mpSettings->setValue(qsl("deletedDefaultMuds"), QStringList{}); + + auto* dlg = new dlgConnectionProfiles(); + dlg->show(); + auto* tabBar = gamesTabBar(dlg); + QVERIFY(tabBar); + + for (const int tab : {1, 0}) { // "All games", then "My games" + tabBar->setCurrentIndex(tab); + dlg->fillout_form(); + const auto items = dlg->findData(*dlg->listWidget_profiles, mSelfTest, dlgConnectionProfiles::csmNameRole); +#if defined(QT_DEBUG) + QCOMPARE(items.size(), 1); +#else + QVERIFY2(items.size() <= 1, "the self-test entry must never be listed twice"); +#endif + } + + dlg->deleteLater(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } +}; + +void initializeQRCResourcesForDefaultGameDeleteTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "DefaultGameDeleteTest.moc" +QTEST_MAIN(DefaultGameDeleteTest) diff --git a/test/functional_tests/DefaultPackagesTest.cpp b/test/functional_tests/DefaultPackagesTest.cpp new file mode 100644 index 000000000..eeac66e98 --- /dev/null +++ b/test/functional_tests/DefaultPackagesTest.cpp @@ -0,0 +1,233 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Covers the packages Mudlet preinstalls into new profiles, which live in + * src/packages and are compiled in through mudlet.qrc. + * + * Nothing else notices when one of these breaks: a path in + * setupPreInstallPackages() that no longer names a compiled-in resource, or an + * archive rebuilt without config.lua, just means the profile quietly comes up + * without the package. The build stays green either way, so this test walks + * both the preinstall table and every archive in the resource tree. + * + * Run with: ctest -R DefaultPackagesTest -V + */ + +#include <QtTest/QtTest> + +#include <QTemporaryDir> +#include <QTemporaryFile> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForDefaultPackagesTest(); + +class DefaultPackagesTest : public QObject +{ + Q_OBJECT + +private: + // A package installs under the name its config.lua declares, which is + // normally the directory it lives in. These two are deliberately not: the + // tutorial uses a display name, and the Carrion Fields loader has to match + // the name its own script passes to uninstallPackage() when it is done. + inline static const QHash<QString, QString> scmInstallsAs = {{qsl("mudlet-tutorial"), qsl("Mudlet Tutorial")}, {qsl("CF-loader"), qsl("CF_Loader")}}; + + const QString mProfileName = qsl("DefaultPackages-Test"); + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + Host* mpHost = nullptr; + + QStringList preinstallsFor(const QString& gameUrl, const QString& profileName = qsl("test")) + { + mudlet::self()->mPackagesToInstallList.clear(); + mudlet::self()->setupPreInstallPackages(gameUrl, profileName); + return mudlet::self()->mPackagesToInstallList; + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForDefaultPackagesTest(); + + // Keep the test hermetic: point the config dir resolution at a + // temporary directory instead of the user's real profiles. + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host"); + mpHost = mudlet::self()->getHostManager().getHost(mProfileName); + QVERIFY(mpHost); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // Every path the preinstall table hands out has to name something that was + // actually compiled into the binary, or the profile silently misses it. + void test_preinstalledPathsResolve_data() + { + QTest::addColumn<QString>("gameUrl"); + + QTest::newRow("any game") << qsl("example.com"); + QTest::newRow("carrion fields") << qsl("carrionfields.net"); + QTest::newRow("icesus") << qsl("icesus.org"); + QTest::newRow("morgengrauen") << qsl("mg.mud.de"); + QTest::newRow("medievia") << qsl("medievia.com"); + QTest::newRow("an IRE game") << qsl("achaea.com"); + QTest::newRow("mudlet's own") << qsl("mudlet.org"); + } + + void test_preinstalledPathsResolve() + { + QFETCH(QString, gameUrl); + + const QStringList paths = preinstallsFor(gameUrl); + QVERIFY2(!paths.isEmpty(), qPrintable(qsl("no packages queued for %1").arg(gameUrl))); + for (const QString& path : paths) { + QVERIFY2(path.startsWith(qsl(":/")), qPrintable(qsl("%1 is not a resource path").arg(path))); + QVERIFY2(QFile::exists(path), qPrintable(qsl("%1 is queued for %2 but is not compiled in").arg(path, gameUrl))); + } + } + + void test_tutorialProfileGetsTheTutorial() + { + QVERIFY(preinstallsFor(qsl("localhost"), qsl("Mudlet Tutorial")).contains(qsl(":/packages/mudlet-tutorial/mudlet-tutorial.mpackage"))); + QVERIFY(!preinstallsFor(qsl("localhost"), qsl("some other profile")).contains(qsl(":/packages/mudlet-tutorial/mudlet-tutorial.mpackage"))); + } + + // Games that install an interface of their own get a loader instead of the + // starter UI, which would otherwise fight it for the same screen space. + // This config dir has no profiles, so the player counts as new to Mudlet. + void test_gamesWithTheirOwnUiSkipTheStarterUi() + { + QVERIFY(preinstallsFor(qsl("example.com")).contains(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage"))); + QVERIFY(!preinstallsFor(qsl("mg.mud.de")).contains(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage"))); + QVERIFY(preinstallsFor(qsl("mg.mud.de")).contains(qsl(":/packages/mg-loader/mg-loader.mpackage"))); + } + + // The generic mapper is for games that have no mapper script of their own. + void test_ireGamesGetTheirOwnMapper() + { + QVERIFY(preinstallsFor(qsl("achaea.com")).contains(qsl(":/mudlet-mapper.xml"))); + QVERIFY(!preinstallsFor(qsl("achaea.com")).contains(qsl(":/packages/generic_mapper/generic_mapper.mpackage"))); + QVERIFY(preinstallsFor(qsl("example.com")).contains(qsl(":/packages/generic_mapper/generic_mapper.mpackage"))); + } + + // Installing is what the preinstall table ultimately does, and a package + // that unpacks but does not import leaves the profile just as empty. + void test_packagesInstall_data() + { + QTest::addColumn<QString>("package"); + + for (const QString& package : QDir(qsl(":/packages")).entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + QTest::newRow(qPrintable(package)) << package; + } + } + + void test_packagesInstall() + { + QFETCH(QString, package); + + mpHost->mBlockScriptCompile = false; + auto [installed, message] = mpHost->installPackage(qsl(":/packages/%1/%1.mpackage").arg(package), enums::PackageModuleType::Package, true); + QVERIFY2(installed, qPrintable(qsl("%1 failed to install: %2").arg(package, message))); + + const QString installedAs = scmInstallsAs.value(package, package); + QVERIFY2(mpHost->mInstalledPackages.contains(installedAs), qPrintable(qsl("%1 installed but is not registered as %2").arg(package, installedAs))); + } + + // Each package directory carries the archive Mudlet installs. Unpack every + // one the way Host::installPackage() does and check it is shaped the way + // the installer requires: metadata in config.lua, exactly one xml. + void test_everyArchiveIsWellFormed() + { + const QStringList packages = QDir(qsl(":/packages")).entryList(QDir::Dirs | QDir::NoDotAndDotDot); + QVERIFY2(!packages.isEmpty(), "no packages found in the resource tree"); + + for (const QString& package : packages) { + const QString archive = qsl(":/packages/%1/%1.mpackage").arg(package); + QVERIFY2(QFile::exists(archive), qPrintable(qsl("%1 holds no archive named after it").arg(package))); + + QTemporaryFile onDisk; + QVERIFY(onDisk.open()); + QFile resource(archive); + QVERIFY(resource.open(QIODevice::ReadOnly)); + QVERIFY(onDisk.write(resource.readAll()) != -1); + onDisk.close(); + + QTemporaryDir unpacked; + QVERIFY(unpacked.isValid()); + // mudlet::unzip() joins the destination and the entry name as-is, + // so the trailing slash is what keeps the files inside the folder: + const QString destination = qsl("%1/").arg(unpacked.path()); + QVERIFY2(mudlet::unzip(onDisk.fileName(), destination, QDir(unpacked.path())), qPrintable(qsl("%1 could not be unzipped").arg(archive))); + + const QDir contents(unpacked.path()); + QVERIFY2(contents.exists(qsl("config.lua")), qPrintable(qsl("%1 carries no config.lua, so it would install without any metadata").arg(archive))); + const QStringList xmls = contents.entryList(QStringList{qsl("*.xml")}, QDir::Files); + QCOMPARE(xmls.count(), 1); + + // Mudlet names the installed package after config.lua, so keeping + // the directory named the same is what makes the paths guessable. + const QString declaredName = mpHost->getPackageConfig(contents.absoluteFilePath(qsl("config.lua"))); + QVERIFY2(!declaredName.isEmpty(), qPrintable(qsl("%1 declares no package name in its config.lua").arg(archive))); + QCOMPARE(declaredName, scmInstallsAs.value(package, package)); + } + } +}; + +void initializeQRCResourcesForDefaultPackagesTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "DefaultPackagesTest.moc" +QTEST_MAIN(DefaultPackagesTest) diff --git a/test/functional_tests/DialogTeardownTest.cpp b/test/functional_tests/DialogTeardownTest.cpp new file mode 100644 index 000000000..ae200a234 --- /dev/null +++ b/test/functional_tests/DialogTeardownTest.cpp @@ -0,0 +1,379 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Functional tests for windows that are destroyed while one of their own + * editing widgets still has the keyboard focus (#9574). + * + * A visible window is taken off the screen while its base-class destructors + * unwind (~QDialog hides it, ~QWidget closes any other window class), which + * moves the focus off the widget that holds it; a QLineEdit (QAbstractSpinBox + * and QKeySequenceEdit behave the same) answers that by emitting + * editingFinished() into a slot of a window whose derived part has already + * been destroyed. + * + * A debug build ends the whole run there, on Qt's "Called object is not of the + * correct type (class destructor may have already run)". A release build has + * that assert compiled out and runs the slot against the destroyed object + * instead, so each test also checks that the edit in the focused field was not + * acted on - the same assertion holds whichever way the build was configured. + * + * Run with: ctest -R DialogTeardownTest -V + */ + +#include <QtTest/QtTest> +#include <chrono> + +#include <QAction> +#include <QKeySequenceEdit> +#include <QLineEdit> +#include <QScopeGuard> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TelnetServerStub.h" +#include "TriggerUnit.h" +#include "dlgConnectionProfiles.h" +#include "dlgProfilePreferences.h" +#include "dlgTriggerEditor.h" +#include "mudlet.h" +#if defined(INCLUDE_UPDATER) +#include "updater.h" +#endif + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForDialogTeardownTest(); + +class DialogTeardownTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mProfileName = qsl("DialogTeardown-Test"); + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = qsl("localhost"); + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + if (dir.exists()) { + dir.removeRecursively(); + } + } + + void startProfile(const QString& profileName, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), profileName); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + } + + // Gives the widget the keyboard focus and something to report: a QLineEdit + // only emits editingFinished() on focus-out once its text has been touched, + // and the setText() here is what arms that - a field nothing has written to + // stays quiet and would make this test prove nothing. + void focusWithText(QLineEdit* lineEdit, const QString& text) + { + QVERIFY2(lineEdit->isVisible(), "Field has to be on screen to be able to take the focus"); + lineEdit->setText(text); + lineEdit->setFocus(); + QCoreApplication::processEvents(); + QCOMPARE(QApplication::focusWidget(), lineEdit); + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForDialogTeardownTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator"))); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mProfileName); + + startProfile(mProfileName, mLocalhost, mPort); + QVERIFY2(mpHost, "No active host after profile creation"); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mProfileName); + delete mudlet::self(); + } + + // Everything below rests on Qt still emitting the focus-out signals while a + // window is being destroyed. If a future Qt stops doing that the other tests + // would keep passing while testing nothing, so pin the mechanism itself on + // widgets of our own - the receiver here outlives them, which is exactly what + // the windows under test cannot manage. + void test_teardownEmitsTheSignalsThisIsAllAbout() + { + auto* dialog = new QDialog(mudlet::self()); + auto* layout = new QVBoxLayout(dialog); + auto* lineEdit = new QLineEdit(dialog); + layout->addWidget(lineEdit); + dialog->show(); + lineEdit->setText(qsl("some text")); + lineEdit->setFocus(); + QCoreApplication::processEvents(); + QCOMPARE(QApplication::focusWidget(), lineEdit); + + QSignalSpy lineEditSpy(lineEdit, &QLineEdit::editingFinished); + delete dialog; + + QVERIFY2(lineEditSpy.count() == 1, + "A focused QLineEdit no longer reports editingFinished() when its " + "window is destroyed - the rest of this file now proves nothing"); + + // the same for the shortcut editors the preferences are full of + auto* keySequenceDialog = new QDialog(mudlet::self()); + auto* keySequenceLayout = new QVBoxLayout(keySequenceDialog); + auto* secondKeySequenceEdit = new QKeySequenceEdit(keySequenceDialog); + keySequenceLayout->addWidget(secondKeySequenceEdit); + keySequenceDialog->show(); + secondKeySequenceEdit->setKeySequence(QKeySequence(qsl("Ctrl+K"))); + secondKeySequenceEdit->setFocus(); + QCoreApplication::processEvents(); + // it focus-proxies to an inner line edit, so ask the wrapper itself + QVERIFY2(secondKeySequenceEdit->hasFocus(), "Shortcut editor did not take the focus"); + + QSignalSpy secondSpy(secondKeySequenceEdit, &QKeySequenceEdit::editingFinished); + delete keySequenceDialog; + QVERIFY2(secondSpy.count() == 1, + "A focused QKeySequenceEdit no longer reports editingFinished() " + "when its window is destroyed"); + } + + // #9574: the reported crash - the profile name field is connected to + // slot_saveName() and the dialog is torn down while that field has the focus + void test_connectionDialogDestroyedWithFocusedNameField() + { + // built directly rather than through mudlet::slot_showConnectionDialog() + // so that the profile this test suite loaded does not have the dialog + // connect it straight back and close it + QPointer<dlgConnectionProfiles> dialog = new dlgConnectionProfiles(mudlet::self()); + dialog->fillout_form(); + dialog->show(); + QTest::qWait(100ms); + QVERIFY2(dialog, "Connection dialog closed itself"); + + // pick our own profile, so that the name field is editing something whose + // renaming can be checked for afterwards + const auto items = dialog->findData(*dialog->listWidget_profiles, mProfileName, dlgConnectionProfiles::csmNameRole); + QVERIFY2(!items.isEmpty(), "Test profile is not listed in the dialog"); + dialog->listWidget_profiles->setCurrentItem(items.first()); + QTest::qWait(100ms); + + const QString renamedTo = qsl("DialogTeardown-Renamed"); + focusWithText(dialog->profile_name_entry, renamedTo); + + delete dialog; + QVERIFY2(dialog.isNull(), "Connection dialog should have been destroyed"); + // slot_saveName() renames the profile's directory, so it running on the way + // down leaves a trace even in a build where the assert is compiled out + QVERIFY2(!QDir(mudlet::getMudletPath(enums::profileHomePath, renamedTo)).exists(), "Being destroyed made the dialog rename the profile"); + QVERIFY2(QDir(mudlet::getMudletPath(enums::profileHomePath, mProfileName)).exists(), "The profile lost its directory while the dialog was destroyed"); + } + + // The same exposure through the preferences' chat name field, which is + // connected to slot_mmcpChatNameChanged() + void test_preferencesDestroyedWithFocusedChatNameField() + { + mudlet::self()->showOptionsDialog(qsl("tab_chat"), mpHost); + QTest::qWait(100ms); + auto* preferences = mpHost->mpDlgProfilePreferences.data(); + QVERIFY2(preferences, "Preferences dialog was not created"); + + const QString chatNameBefore = mpHost->getMMCPChatName(); + const QString typedChatName = qsl("DialogTeardownChatName"); + QVERIFY2(chatNameBefore != typedChatName, "Test needs to type a chat name that is not the current one"); + focusWithText(preferences->lineEdit_mmcpChatName, typedChatName); + + delete preferences; + QVERIFY2(mpHost->mpDlgProfilePreferences.isNull(), "Preferences dialog should have been destroyed"); + QCOMPARE(mpHost->getMMCPChatName(), chatNameBefore); + } + + // Opening the preferences at all used to be enough to end the run: the + // dialog asks the updater whether it downloads updates by itself, which on + // macOS reaches into Sparkle - and Sparkle is only created by + // checkUpdatesOnStart(), which no test calls. Development builds skip that + // whole branch, so only PTB and release builds ever crashed and CI stayed + // green until the nightly PTB. DEV_UPDATER puts this build on the same path. + void test_preferencesOpensBeforeTheUpdaterIsSetUp() + { + qputenv("DEV_UPDATER", "1"); + auto restoreEnvironment = qScopeGuard([]() { + qunsetenv("DEV_UPDATER"); + }); + + mudlet::self()->showOptionsDialog(qsl("tab_specialOptions"), mpHost); + QTest::qWait(100ms); + auto* preferences = mpHost->mpDlgProfilePreferences.data(); + QVERIFY2(preferences, "Preferences dialog was not created"); + +#if defined(INCLUDE_UPDATER) + auto* updater = mudlet::self()->pUpdater; + QVERIFY2(updater, "An updater-enabled build has no updater"); + // the dev-build branch disables the checkbox and touches no updater, so + // this is what says the test is on the crashing path at all + QVERIFY2(preferences->checkbox_noAutomaticUpdates->isEnabled(), "DEV_UPDATER no longer moves a development build onto the release update path - this test covers nothing now"); + // isHidden() rather than isVisible(): the group box sits on a tab page, + // and only an explicit hide() should count here + QCOMPARE(preferences->groupBox_updates->isHidden(), !updater->ready()); + + if (!updater->ready()) { + // the accessors the dialog and the Help menu reach for have to be + // safe to call in this state, not merely avoidable + QVERIFY2(!updater->updateAutomatically(), "An updater with no platform updater claimed it auto-updates"); + updater->setAutomaticUpdates(true); + updater->manuallyCheckUpdates(); + QVERIFY2(!updater->updateAutomatically(), "An updater with no platform updater took a setting it cannot store"); + } +#endif + + delete preferences; + QVERIFY2(mpHost->mpDlgProfilePreferences.isNull(), "Preferences dialog should have been destroyed"); + } + + // ...and through the editor, where the item name field is connected to + // slot_saveProperty_TriggerName(). The editor is a QMainWindow rather than a + // QDialog, which makes no difference: it hides itself on the way down too + void test_triggerEditorDestroyedWithFocusedNameField() + { + mudlet::self()->slot_showScriptDialog(); + QTest::qWait(100ms); + auto* editor = mpHost->mpEditorDialog.data(); + QVERIFY2(editor, "Editor was not created"); + + // the item fields only appear once an item is being edited + editor->slot_showTriggers(); + editor->slot_addNewItem(); + QTest::qWait(100ms); + + auto* nameField = editor->findChild<QLineEdit*>(qsl("lineEdit_trigger_name")); + QVERIFY2(nameField, "Trigger name field not found in the editor"); + const QString nameBefore = nameField->text(); + QVERIFY2(mpHost->getTriggerUnit()->findTrigger(nameBefore), "The new trigger is not registered under the name in the field"); + + const QString typedName = qsl("DialogTeardown trigger"); + focusWithText(nameField, typedName); + + delete editor; + QVERIFY2(mpHost->mpEditorDialog.isNull(), "Editor should have been destroyed"); + // slot_saveProperty_TriggerName() renames the trigger itself, so the item + // shows whether it ran while the editor was being destroyed + QVERIFY2(!mpHost->getTriggerUnit()->findTrigger(typedName), "Being destroyed made the editor rename the trigger"); + QVERIFY2(mpHost->getTriggerUnit()->findTrigger(nameBefore), "The trigger lost its name while the editor was destroyed"); + } + + void test_protocolActionsFireAfterPreferencesReopen() + { + mudlet::self()->showOptionsDialog(qsl("tab_general"), mpHost); + QTest::qWait(100ms); + auto* first = mpHost->mpDlgProfilePreferences.data(); + QVERIFY2(first, "Preferences dialog was not created"); + delete first; + QVERIFY2(mpHost->mpDlgProfilePreferences.isNull(), "Preferences dialog should have been destroyed"); + + mudlet::self()->showOptionsDialog(qsl("tab_general"), mpHost); + QTest::qWait(100ms); + auto* preferences = mpHost->mpDlgProfilePreferences.data(); + QVERIFY2(preferences, "Preferences dialog was not recreated"); + + QAction* gmcpAction = nullptr; + for (auto* action : preferences->findChildren<QAction*>()) { + if (action->text().startsWith(qsl("GMCP"))) { + gmcpAction = action; + break; + } + } + QVERIFY2(gmcpAction, "GMCP protocol action not found under the reopened dialog - parenting to the menu broke discovery or population"); + + // initWithHost() wires GMCP's toggled() to this button's setEnabled(), + // so the button flipping proves the fresh action is connected + const bool enabledBefore = preferences->pushButton_forgetSavedSignIn->isEnabled(); + QCOMPARE(enabledBefore, gmcpAction->isChecked()); + gmcpAction->toggle(); + QCOMPARE(preferences->pushButton_forgetSavedSignIn->isEnabled(), !enabledBefore); + gmcpAction->toggle(); + QCOMPARE(preferences->pushButton_forgetSavedSignIn->isEnabled(), enabledBefore); + + delete preferences; + } +}; + +void initializeQRCResourcesForDialogTeardownTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "DialogTeardownTest.moc" +QTEST_MAIN(DialogTeardownTest) diff --git a/test/functional_tests/EdbeeReinitTest.cpp b/test/functional_tests/EdbeeReinitTest.cpp new file mode 100644 index 000000000..aa1ac60a6 --- /dev/null +++ b/test/functional_tests/EdbeeReinitTest.cpp @@ -0,0 +1,207 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * mudlet::initEdbee() only runs once per process, so destroying a mudlet + * instance and constructing another - what every functional test with a + * per-method init() does - must leave the edbee singleton fully usable: Lua + * grammar, Mudlet theme, and a script editor that opens against them. + * + * Run with: ctest -R EdbeeReinitTest -V + */ + +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "dlgTriggerEditor.h" +#include "mudlet.h" + +#include "edbee/edbee.h" +#include "edbee/models/textgrammar.h" +#include "edbee/views/texttheme.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); + +static void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +class EdbeeReinitTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mProfileName = qsl("EdbeeReinit-Test-Profile"); + QString mPort; + const QString mLocalhost = qsl("localhost"); + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + if (dir.exists() && !dir.removeRecursively()) { + qWarning() << "deleteProfileDirectory: could not remove" << path << "- later failures may stem from this stale state"; + } + } + + static void bootMudlet() + { + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator"))); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + } + + void startProfile(const QString& profileName, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + + dlgConnectionProfiles* connectionDialog = mudlet::self()->mpConnectionDialog; + if (!connectionDialog || !connectionDialog->new_profile_button) { + qWarning() << "startProfile: connection dialog did not appear"; + return; + } + QTest::mouseClick(connectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + + const auto focusedWidget = [](const char* step) -> QWidget* { + QWidget* widget = QApplication::focusWidget(); + if (!widget) { + qWarning() << "startProfile: no focused widget at step" << step; + } + return widget; + }; + + QWidget* nameField = focusedWidget("profile name"); + if (!nameField) { + return; + } + QTest::keyClicks(nameField, profileName); + QTest::qWait(100ms); + QTest::keyClick(nameField, Qt::Key_Tab); + QTest::qWait(100ms); + + QWidget* addressField = focusedWidget("address"); + if (!addressField) { + return; + } + QTest::keyClicks(addressField, address); + QTest::qWait(100ms); + QTest::keyClick(addressField, Qt::Key_Tab); + QTest::qWait(100ms); + + QWidget* portField = focusedWidget("port"); + if (!portField) { + return; + } + QTest::keyClicks(portField, port); + QTest::qWait(100ms); + QTest::keyClick(portField, Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(2000)) { + QFAIL("Profile took too long to load."); + } + + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + +private slots: + void initTestCase() + { + initializeQRCResources(); + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); + QVERIFY2(mpServer->isListening(), qPrintable(qsl("TelnetServerStub failed to start: %1").arg(mpServer->errorString()))); + mPort = QString::number(mpServer->serverPort()); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mProfileName); + delete mudlet::self(); + } + + void test_editorAliveAfterMudletReconstruction() + { + bootMudlet(); + delete mudlet::self(); + + bootMudlet(); + deleteProfileDirectory(mProfileName); + + auto* edbee = edbee::Edbee::instance(); + auto* luaGrammar = edbee->grammarManager()->get(qsl("source.lua")); + QVERIFY2(luaGrammar, "Lua grammar gone after mudlet reconstruction - initEdbee()'s once-guard left edbee unprimed"); + // the editor picks its grammar by filename, so that path must agree + QCOMPARE(edbee->grammarManager()->detectGrammarWithFilename(qsl("Buck.lua")), luaGrammar); + QVERIFY2(edbee->themeManager()->theme(qsl("Mudlet")), "Mudlet editor theme gone after mudlet reconstruction"); + + startProfile(mProfileName, mLocalhost, mPort); + if (QTest::currentTestFailed()) { + return; + } + + mudlet::self()->slot_showScriptDialog(); + QTest::qWait(100ms); + QVERIFY2(mpHost->mpEditorDialog, "Script editor did not open on the reconstructed mudlet instance"); + } +}; + +#include "EdbeeReinitTest.moc" +QTEST_MAIN(EdbeeReinitTest) diff --git a/test/functional_tests/EditorBannerViewSwitchTest.cpp b/test/functional_tests/EditorBannerViewSwitchTest.cpp new file mode 100644 index 000000000..44cea5399 --- /dev/null +++ b/test/functional_tests/EditorBannerViewSwitchTest.cpp @@ -0,0 +1,371 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression tests for the editor's dismissible help banners when switching + * between editor sections: a dismissed section's suppression must not leave + * another section's banner (or the dismissal undo toast) lingering on screen, + * the undo toast's close button must only close the toast, undo must restore + * the dismissed banner, and error messages must survive a section switch. + * + * Run with: ctest -R EditorBannerViewSwitchTest -V + */ + +#include <QSettings> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "dlgSystemMessageArea.h" +#include "dlgTriggerEditor.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); + +static void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +class EditorBannerViewSwitchTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + dlgTriggerEditor* mpEditor = nullptr; + Host* mpHost = nullptr; + const QString mProfileName = qsl("BannerViewSwitch-Test-Profile"); + QString mPort; + const QString mLocalhost = qsl("localhost"); + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + if (dir.exists() && !dir.removeRecursively()) { + qWarning() << "deleteProfileDirectory: could not remove" << path << "- later failures may stem from this stale state"; + } + } + + // The permanently-hidden banner preferences are stored in QSettings under a + // per-profile prefix; wipe this profile's slice so earlier runs cannot bleed + // into the assertions (the profile name is unique to this test, so nothing + // belonging to a real profile is touched) + void clearBannerSettings() + { + QSettings* settings = mudlet::getQSettings(); + settings->remove(qsl("Editor/banner_permanently_hidden/profiles/%1").arg(mProfileName)); + } + + void startProfile(const QString& profileName, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + + // Guard every UI step so a setup flake names the failing step in + // the log instead of surfacing as a generic profile-load timeout + dlgConnectionProfiles* connectionDialog = mudlet::self()->mpConnectionDialog; + if (!connectionDialog || !connectionDialog->new_profile_button) { + qWarning() << "startProfile: connection dialog did not appear"; + return; + } + QTest::mouseClick(connectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + + const auto focusedWidget = [](const char* step) -> QWidget* { + QWidget* widget = QApplication::focusWidget(); + if (!widget) { + qWarning() << "startProfile: no focused widget at step" << step; + } + return widget; + }; + + QWidget* nameField = focusedWidget("profile name"); + if (!nameField) { + return; + } + QTest::keyClicks(nameField, profileName); + QTest::qWait(100ms); + QTest::keyClick(nameField, Qt::Key_Tab); + QTest::qWait(100ms); + + QWidget* addressField = focusedWidget("address"); + if (!addressField) { + return; + } + QTest::keyClicks(addressField, address); + QTest::qWait(100ms); + QTest::keyClick(addressField, Qt::Key_Tab); + QTest::qWait(100ms); + + QWidget* portField = focusedWidget("port"); + if (!portField) { + return; + } + QTest::keyClicks(portField, port); + QTest::qWait(100ms); + QTest::keyClick(portField, Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(2000)) { + QFAIL("Profile took too long to load."); + } + + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + QString bannerText() const { return mpEditor->mpSystemMessageArea->notificationAreaMessageBox->text(); } + + void clickBannerCloseButton() + { + QTest::mouseClick(mpEditor->mpSystemMessageArea->messageAreaCloseButton, Qt::LeftButton); + QTest::qWait(50ms); + } + +private slots: + void initTestCase() + { + initializeQRCResources(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); + QVERIFY2(mpServer->isListening(), qPrintable(qsl("TelnetServerStub failed to start: %1").arg(mpServer->errorString()))); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + clearBannerSettings(); + deleteProfileDirectory(mProfileName); + startProfile(mProfileName, mLocalhost, mPort); + // QFAIL inside startProfile() only returns from that helper - bail out + // here too or the mpHost dereference below crashes and buries the + // recorded diagnostic under a segfault + if (QTest::currentTestFailed()) { + return; + } + + mudlet::self()->slot_showScriptDialog(); + QTest::qWait(100ms); + + mpEditor = mpHost->mpEditorDialog; + QVERIFY2(mpEditor != nullptr, "Editor dialog should be created"); + } + + void cleanupTestCase() + { + clearBannerSettings(); + mpEditor = nullptr; + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mProfileName); + delete mudlet::self(); + } + + // Reset the banner state - in-memory and this profile's persisted + // preferences - so each test starts from "nothing dismissed yet" + void init() + { + mpEditor->cancelBannerUndoTimer(); + mpEditor->mTemporarilyHiddenBanners.clear(); + mpEditor->mLastDismissedBannerView = EditorViewType::cmUnknownView; + mpEditor->mLastDismissedBannerContent.clear(); + mpEditor->mLastDismissedBannerKey.clear(); + mpEditor->mCurrentBannerKey.clear(); + mpEditor->mpSystemMessageArea->hide(); + clearBannerSettings(); + } + + // The reported repro: dismiss the Scripts banner (X on the banner, then X + // on the "Banner hidden" undo toast), visit Timers, come back to Scripts - + // the Timers banner must not linger over the Scripts section + void testDismissedBannerDoesNotLeakAcrossViews() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Scripts banner should show initially"); + const QString scriptsBanner = bannerText(); + + clickBannerCloseButton(); // dismisses the banner, shows the undo toast + clickBannerCloseButton(); // closes the undo toast + + mpEditor->slot_showTimers(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Timers banner should show after switching to Timers"); + const QString timersBanner = bannerText(); + QVERIFY2(timersBanner != scriptsBanner, "Timers banner content should differ from the Scripts one"); + + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(!mpEditor->mpSystemMessageArea->isVisible(), + "The dismissed Scripts banner must stay hidden - and the Timers " + "banner must not linger over the Scripts section"); + } + + // Same leak, without touching the toast: a single dismissal then switching + // views back and forth must not leave the other view's banner behind + void testSingleDismissDoesNotLeakAcrossViews() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY(mpEditor->mpSystemMessageArea->isVisible()); + + clickBannerCloseButton(); + + mpEditor->slot_showTimers(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Timers banner should show after switching to Timers"); + QCOMPARE(mpEditor->mCurrentBannerKey, qsl("intro")); + + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(!mpEditor->mpSystemMessageArea->isVisible(), "No banner should show in Scripts after its banner was dismissed"); + } + + // The X on the undo toast must only close the toast - not register another + // dismissal that suppresses the whole view's banners and stashes the toast + // text as restorable banner content + void testToastCloseButtonJustClosesToast() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Scripts banner should show initially"); + const QString scriptsBanner = bannerText(); + + clickBannerCloseButton(); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Undo toast should show after dismissing the banner"); + QVERIFY(bannerText() != scriptsBanner); + + clickBannerCloseButton(); + QVERIFY2(!mpEditor->mpSystemMessageArea->isVisible(), "Closing the undo toast should hide the message area"); + QCOMPARE(mpEditor->mLastDismissedBannerKey, qsl("intro")); + QCOMPARE(mpEditor->mLastDismissedBannerContent, scriptsBanner); + const QString baseKey = mpEditor->bannerSettingsKey(EditorViewType::cmScriptView, QString()); + QVERIFY2(!mpEditor->mTemporarilyHiddenBanners.contains(baseKey), "Closing the toast must not suppress all banners for the view"); + } + + // A pending undo toast belongs to the view it was shown in - switching + // views must clear it and show the new view's banner instead + void testToastHiddenOnViewSwitch() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Scripts banner should show initially"); + const QString scriptsBanner = bannerText(); + + clickBannerCloseButton(); + QVERIFY(mpEditor->mpSystemMessageArea->isVisible()); + + mpEditor->slot_showTimers(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Timers banner should show after switching to Timers"); + QCOMPARE(mpEditor->mCurrentBannerKey, qsl("intro")); + QVERIFY2(bannerText() != scriptsBanner, "The Timers banner, not stale Scripts content, should show"); + QVERIFY2(!bannerText().contains(qsl("href='undo'")), "The undo toast must not linger after a view switch"); + } + + // Undo after a single dismissal still restores the banner - driven through + // the toast's link wiring, not by calling undoBannerDismiss() directly, so + // a broken linkActivated connection is caught too + void testUndoRestoresBanner() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Scripts banner should show initially"); + const QString scriptsBanner = bannerText(); + + clickBannerCloseButton(); + QVERIFY(mpEditor->mpSystemMessageArea->isVisible()); + + QMetaObject::invokeMethod(mpEditor->mpSystemMessageArea->notificationAreaMessageBox, "linkActivated", Q_ARG(QString, qsl("undo"))); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Undo should restore the dismissed banner"); + QCOMPARE(bannerText(), scriptsBanner); + } + + // Errors are not banners: they must survive a section switch instead of + // being cleared by the new-view banner handling + void testErrorSurvivesViewSwitch() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + const QString errorText = qsl("test error message"); + mpEditor->showError(errorText); + QVERIFY(mpEditor->mpSystemMessageArea->isVisible()); + + mpEditor->slot_showTimers(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "An error message must survive a view switch"); + QCOMPARE(bannerText(), errorText); + } + + // An error raised while the undo toast's 5s expiry timer is still running + // must survive both the timer and a view switch + void testErrorShownDuringToastWindowSurvivesViewSwitch() + { + mpEditor->slot_showScripts(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "Scripts banner should show initially"); + + clickBannerCloseButton(); // toast up, expiry timer running + const QString errorText = qsl("error raised during toast"); + mpEditor->showError(errorText); + + mpEditor->slot_showTimers(); + QTest::qWait(50ms); + QVERIFY2(mpEditor->mpSystemMessageArea->isVisible(), "An error shown while the undo toast timer was live must survive a view switch"); + QCOMPARE(bannerText(), errorText); + } +}; + +#include "EditorBannerViewSwitchTest.moc" +QTEST_MAIN(EditorBannerViewSwitchTest) diff --git a/test/functional_tests/EmbeddedMapperCreationTest.cpp b/test/functional_tests/EmbeddedMapperCreationTest.cpp new file mode 100644 index 000000000..460e428b6 --- /dev/null +++ b/test/functional_tests/EmbeddedMapperCreationTest.cpp @@ -0,0 +1,203 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Covers TMainConsole::createMapper() - the embedded mapper behind Lua + * createMapper() and Geyser.Mapper{embedded = true} - on both sides of its + * already-loaded-map branch. + * + * An embedded mapper and the dockable map widget are mutually exclusive for the + * life of a profile and neither can be destroyed, so the busted suite cannot go + * here and each test method needs a mudlet of its own. + */ + +#include <QSignalSpy> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TMainConsole.h" +#include "TMap.h" +#include "TRoomDB.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "dlgMapper.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForEmbeddedMapperTest(); + +class EmbeddedMapperCreationTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = qsl("Embedded-Mapper-Test-Host"); + QString mPort; + const QString mLocalhost = qsl("localhost"); + const QString mFirstAreaName = qsl("AAArea"); + const QString mPlayerAreaName = qsl("QAArea"); + +private slots: + void initTestCase() { initializeQRCResourcesForEmbeddedMapperTest(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(); + + QTimer::singleShot(0ms, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy connectionSpy(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!connectionSpy.wait(2000)) { + QFAIL("Could not connect with the host."); + } + + QVERIFY(mpHost->mpConsole); + watchMapOpenEvent(); + } + + void cleanup() + { + delete mpServer; + mpServer = nullptr; + mpHost = nullptr; + deleteProfileDirectory(); + delete mudlet::self(); + } + + void test_createMapperWithALoadedMap() + { + TMap* pMap = mpHost->mpMap.data(); + TRoomDB* pRoomDB = pMap->mpRoomDB.get(); + + // the player's area has to sort after the one the dlgMapper constructor's own fill leaves selected + QVERIFY(pRoomDB->addArea(mFirstAreaName) > 0); + const int playerAreaId = pRoomDB->addArea(mPlayerAreaName); + QVERIFY(playerAreaId > 0); + QVERIFY(pMap->addRoom(1)); + QVERIFY(pMap->setRoomArea(1, playerAreaId, false)); + pMap->mRoomIdHash[pMap->mProfileName] = 1; + pMap->setDefaultAreaShown(false); + QVERIFY2(!pRoomDB->isEmpty(), "the map has to be non-empty for this to be the returning-user path"); + + auto [created, message] = mpHost->mpConsole->createMapper(QString(), 0, 0, 300, 300); + QVERIFY2(created, qPrintable(message)); + QVERIFY(mpHost->mpConsole->mpMapper); + + QVERIFY2(mapOpenEventCountIs(1), "createMapper() did not raise mapOpenEvent exactly once for an already-loaded map"); + + auto* pComboBox = mpHost->mpConsole->mpMapper->comboBox_showArea; + QCOMPARE(pComboBox->count(), 2); // the hidden default area is still in the constructor's fill + QCOMPARE(pComboBox->currentText(), mPlayerAreaName); + + // Geyser.Mapper re-runs createMapper() on every reposition + auto [recreated, recreateMessage] = mpHost->mpConsole->createMapper(QString(), 0, 0, 300, 300); + QVERIFY2(recreated, qPrintable(recreateMessage)); + QVERIFY2(mapOpenEventCountIs(1), "a repeat createMapper() raised mapOpenEvent again"); + } + + void test_createMapperWithNoMapToLoad() + { + QVERIFY2(mpHost->mpMap->mpRoomDB->isEmpty(), "a freshly created profile was expected to have no rooms"); + + auto [created, message] = mpHost->mpConsole->createMapper(QString(), 0, 0, 300, 300); + QVERIFY2(created, qPrintable(message)); + QVERIFY(mpHost->mpConsole->mpMapper); + + QVERIFY2(mapOpenEventCountIs(1), "createMapper() did not raise mapOpenEvent exactly once for a first-run profile"); + } + +private: + void watchMapOpenEvent() + { + mpHost->getLuaInterpreter()->compileAndExecuteScript(qsl("mapOpenSeen = 0\n" + "registerAnonymousEventHandler('mapOpenEvent', function() mapOpenSeen = mapOpenSeen + 1 end)")); + } + + bool mapOpenEventCountIs(const int expected) const { return mpHost->getLuaInterpreter()->compileAndExecuteScript(qsl("assert(mapOpenSeen == %1)").arg(expected)); } + + void deleteProfileDirectory() const + { + QDir dir(mudlet::getMudletPath(enums::profileHomePath, mHostname)); + if (dir.exists()) { + dir.removeRecursively(); + } + } +}; + +void initializeQRCResourcesForEmbeddedMapperTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "EmbeddedMapperCreationTest.moc" +QTEST_MAIN(EmbeddedMapperCreationTest) diff --git a/test/functional_tests/ExperiencedPlayerGateTest.cpp b/test/functional_tests/ExperiencedPlayerGateTest.cpp new file mode 100644 index 000000000..eab99f5cc --- /dev/null +++ b/test/functional_tests/ExperiencedPlayerGateTest.cpp @@ -0,0 +1,352 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Locks in who Mudlet considers an experienced player. That decision gates all + * of the first-time guidance - the interface tour, the starter UI package and + * the one-line hints - so getting it wrong either buries a newcomer's + * onboarding or drops a beginner tour on top of a ten-year veteran's session. + * + * mudlet::rememberFirstLaunch() and mudlet::evaluateExperiencedPlayer() take + * their settings, profiles path and "now" as arguments, so most cases run + * without a mudlet instance. The last two drive a real init() instead, to pin + * the production wiring. + * + * Run with: ctest -R ExperiencedPlayerGateTest -V + */ + +#include <QtTest/QtTest> +#include <QTimeZone> + +#include "MudletInstanceCoordinator.h" +#include "mudlet.h" + +// init() reads compiled-in resources, which are not registered automatically in +// a test binary that links mudlet_core statically +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForExperiencedPlayerGateTest(); + +class ExperiencedPlayerGateTest : public QObject +{ + Q_OBJECT + +private: + QByteArray mSavedXdg; + // Outlives the two live-singleton cases, which share one mudlet instance + QTemporaryDir mLiveConfig; + // Fixed, so the six month arithmetic does not depend on the day the suite runs + const QDateTime mNow = QDateTime(QDate(2026, 8, 5), QTime(12, 0), QTimeZone::UTC); + const QString mKey = qsl("firstLaunchDate"); + + QString profilesPathIn(const QString& configDir) const { return qsl("%1/profiles").arg(configDir); } + + QString iniIn(const QString& configDir) const { return qsl("%1/Mudlet.ini").arg(configDir); } + + QString makeProfile(const QString& configDir, const QString& name) const + { + const QString path = qsl("%1/%2").arg(profilesPathIn(configDir), name); + return QDir().mkpath(path) ? path : QString(); + } + + void setFirstLaunch(QSettings& settings, const QDateTime& when) const { settings.setValue(mKey, when.toUTC().toString(Qt::ISODate)); } + + // setupConfig() consults portable.txt before the XDG logic + bool portableMarkerPresent() const + { + return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath())); + } + +private slots: + void initTestCase() { mSavedXdg = qgetenv("XDG_CONFIG_HOME"); } + + void cleanupTestCase() + { + if (mudlet::self()) { + delete mudlet::self(); + } + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // --- a brand new installation --- + + void test_freshInstallRecordsTodayAndIsNew() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + QVERIFY(!QDir(profilesPathIn(config.path())).exists()); + + mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow); + + QCOMPARE(settings.value(mKey).toString(), mNow.toString(Qt::ISODate)); + QVERIFY2(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow), "a first-ever launch must be treated as a new player"); + } + + void test_emptyProfilesDirectoryIsStillAFirstRun() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QVERIFY(QDir().mkpath(profilesPathIn(config.path()))); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + + mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow); + + QVERIFY(settings.contains(mKey)); + QVERIFY(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow)); + } + + // --- the recorded date, once there is one --- + + void test_recentlyRecordedFirstLaunchIsNew() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + setFirstLaunch(settings, mNow.addMonths(-1)); + QVERIFY(!makeProfile(config.path(), qsl("Achaea")).isEmpty()); + + QVERIFY2(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow), "a month of use is not enough to be experienced, even with a profile in hand"); + } + + void test_oldRecordedFirstLaunchIsExperienced() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + setFirstLaunch(settings, mNow.addYears(-3)); + + QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow)); + } + + void test_sixMonthBoundary() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + const QString profiles = profilesPathIn(config.path()); + + setFirstLaunch(settings, mNow.addMonths(-6).addDays(1)); + QVERIFY2(!mudlet::evaluateExperiencedPlayer(settings, profiles, mNow), "one day short of six months is not yet experienced"); + + setFirstLaunch(settings, mNow.addMonths(-6)); + QVERIFY2(mudlet::evaluateExperiencedPlayer(settings, profiles, mNow), "exactly six months of use is experienced"); + + setFirstLaunch(settings, mNow.addMonths(-6).addDays(-1)); + QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profiles, mNow)); + } + + void test_futureDatedFirstLaunchIsNotExperienced() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + setFirstLaunch(settings, mNow.addYears(1)); + + QVERIFY(!mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow)); + } + + // --- upgrading users, who have no recorded first launch --- + + void test_upgraderWithFreshlyWrittenProfilesIsExperienced() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + for (const auto& name : {qsl("Achaea"), qsl("StickMUD"), qsl("Legends of the Jedi")}) { + const QString profile = makeProfile(config.path(), name); + QVERIFY(!profile.isEmpty()); + QFile url(qsl("%1/url").arg(profile)); + QVERIFY(url.open(QIODevice::WriteOnly)); + url.write("achaea.com"); + url.close(); + QVERIFY2(QFileInfo(profile).lastModified() > mNow.addMonths(-6), "the fixture is only meaningful while the profile directory looks brand new"); + } + + QVERIFY2(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow), "an installation with profiles but no recorded first launch predates the key, so it is experienced"); + } + + // A restored profile may or may not keep its modification times, and never + // keeps its birth time, so no timestamp is consulted + void test_restoredFromBackupIsExperienced() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + QVERIFY(!makeProfile(config.path(), qsl("Restored")).isEmpty()); + + QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow)); + } + + void test_settingsWithoutProfilesStillCountAsEarlierUse() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + settings.setValue(qsl("pos"), QPoint(120, 80)); + QVERIFY(!QDir(profilesPathIn(config.path())).exists()); + + mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow); + + QVERIFY2(!settings.contains(mKey), "an installation with settings on file is not on its first run"); + QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow)); + } + + void test_unreadableProfilesDirectoryIsTakenAsPopulated() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + const QString profiles = profilesPathIn(config.path()); + QVERIFY(!makeProfile(config.path(), qsl("Achaea")).isEmpty()); + QVERIFY(QFile::setPermissions(profiles, QFileDevice::WriteOwner | QFileDevice::ExeOwner)); + if (QFileInfo(profiles).isReadable()) { + QVERIFY(QFile::setPermissions(profiles, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); + QSKIP("the profiles directory is readable despite the permissions - running as root?"); + } + + const bool experienced = mudlet::evaluateExperiencedPlayer(settings, profiles, mNow); + // Restore before asserting, or a failure leaves QTemporaryDir unable to clean up + QVERIFY(QFile::setPermissions(profiles, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)); + QVERIFY(experienced); + } + + void test_upgradeDoesNotRecordAFirstLaunch() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + QVERIFY(!makeProfile(config.path(), qsl("Achaea")).isEmpty()); + + mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow); + + QVERIFY(!settings.contains(mKey)); + QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow.addYears(1))); + } + + void test_existingRecordIsNeverOverwritten() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + const QDateTime original = mNow.addMonths(-3); + setFirstLaunch(settings, original); + + mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow); + + QCOMPARE(settings.value(mKey).toString(), original.toString(Qt::ISODate)); + } + + void test_unparseableRecordFallsBackToTheEarlierUseCheck() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + QSettings settings(iniIn(config.path()), QSettings::IniFormat); + settings.setValue(mKey, qsl("not a date")); + + QVERIFY(mudlet::evaluateExperiencedPlayer(settings, profilesPathIn(config.path()), mNow)); + + mudlet::rememberFirstLaunch(settings, profilesPathIn(config.path()), mNow); + QCOMPARE(settings.value(mKey).toString(), qsl("not a date")); + } + + void test_recordedValueSurvivesAQSettingsRoundTrip() + { + QTemporaryDir config; + QVERIFY(config.isValid()); + { + QSettings writer(iniIn(config.path()), QSettings::IniFormat); + mudlet::rememberFirstLaunch(writer, profilesPathIn(config.path()), mNow.addYears(-2)); + } + + QSettings reader(iniIn(config.path()), QSettings::IniFormat); + QVERIFY2(mudlet::evaluateExperiencedPlayer(reader, profilesPathIn(config.path()), mNow), "the recorded date must be readable back out of Mudlet.ini"); + + QFile ini(iniIn(config.path())); + QVERIFY(ini.open(QIODevice::ReadOnly | QIODevice::Text)); + QVERIFY2(QString::fromUtf8(ini.readAll()).contains(qsl("firstLaunchDate=2024-08-05T12:00:00Z")), "the date is stored as plain ISO 8601, so it can be read and edited by hand"); + } + + // --- the live singleton --- + + // Nothing else in the suite notices the init() call being moved or dropped, + // which would make every fresh install take the "used before" fallback + void test_initRecordsTheFirstLaunchOnAFreshInstall() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - setupConfig() takes the portable branch"); + } + QVERIFY(mLiveConfig.isValid()); + // $XDG_CONFIG_HOME/mudlet/profiles is the opt-in marker, without which + // setupConfig() keeps using a legacy ~/.config/mudlet + const QString configDir = qsl("%1/mudlet").arg(mLiveConfig.path()); + QVERIFY(QDir().mkpath(qsl("%1/profiles").arg(configDir))); + qputenv("XDG_CONFIG_HOME", mLiveConfig.path().toUtf8()); + + initializeQRCResourcesForExperiencedPlayerGateTest(); + mudlet::start(); + mudlet::self()->setupConfig(); + QCOMPARE(mudlet::getMudletPath(enums::mainPath), configDir); + QVERIFY2(QDir(mudlet::getMudletPath(enums::profilesPath)).entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty(), "the opt-in profiles/ dir has to be empty, or this is not a fresh install"); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + + mudlet::self()->init(); + + QVERIFY2(mudlet::getQSettings()->contains(mKey), "init() must record the first launch date"); + QCOMPARE(QDateTime::fromString(mudlet::getQSettings()->value(mKey).toString(), Qt::ISODate).isValid(), true); + } + + // Pins the key and profiles path experiencedMudletPlayer() picks for itself, + // which the case above cannot - there both branches would answer "new". + // Runs last: experiencedMudletPlayer() memoises for the life of the process. + void test_experiencedThroughTheRealSettings() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - setupConfig() takes the portable branch"); + } + QVERIFY(mudlet::self()); + auto* settings = mudlet::getQSettings(); + QVERIFY(settings); + settings->remove(mKey); + QVERIFY(!makeProfile(mudlet::getMudletPath(enums::mainPath), qsl("Achaea")).isEmpty()); + + QVERIFY2(mudlet::self()->experiencedMudletPlayer(), "a profile with no recorded first launch must read as an experienced player"); + } +}; + +void initializeQRCResourcesForExperiencedPlayerGateTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ExperiencedPlayerGateTest.moc" +QTEST_MAIN(ExperiencedPlayerGateTest) diff --git a/test/functional_tests/GMCPCharLoginTest.cpp b/test/functional_tests/GMCPCharLoginTest.cpp index 7d984c063..503e00ee2 100644 --- a/test/functional_tests/GMCPCharLoginTest.cpp +++ b/test/functional_tests/GMCPCharLoginTest.cpp @@ -25,11 +25,15 @@ #include <QtTest/QtTest> #include <chrono> +#include <QtNetwork/QSslCertificate> +#include <QtNetwork/QSslKey> +#include <QtNetwork/QSslSocket> #include <QtNetwork/QTcpServer> #include <QtNetwork/QTcpSocket> #include <QDesktopServices> #include <QJsonDocument> #include <QJsonObject> +#include <QUrlQuery> #include <functional> #include "CredentialManager.h" @@ -41,6 +45,56 @@ using namespace std::chrono_literals; +// Self-signed loopback certificate, valid until 2126; the client accepts it via Host::mSslIgnoreAll. +static const char* csmTestCertificatePem = R"PEM(-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIULa4vwGAVOB+r6qtcLMPqwzBlEJgwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDgwNjE0MTM0OFoYDzIxMjYw +NzEzMTQxMzQ4WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDEg1HE09f69FW/OLD0jrWEQRbKkSkIkexLfV5OtzbI +ZVDWcH3Y3NKrbZ60j8WEY8DqVzO2kMnOppc5LBEKGP1TTs6C9R+e5hlI6McoKown +ha4aU9nqM7dsjY71xGZNN9DCVxhRpqadlZon7M4wzVvUO5VIRhFeA2AO6LRVQhyi +9Whe/uJVlncb2tbiGgTavixWSQ5kH0ocE8Cp4SbuHuXPwgiZ9hYEIX2xAFSR48OB +bjWgqVISptu/s+UkK2XckI42qdxqzwglLIIqjFYJ1HvGqhqV69DeqB0XNw6qp8W2 +qwTpv3gPGzI60vNL6aaHTivLxnsEClPbcrTfG1y8DnnLAgMBAAGjbzBtMB0GA1Ud +DgQWBBSyDWWzo202vFbYncaD2crvY5V2pDAfBgNVHSMEGDAWgBSyDWWzo202vFbY +ncaD2crvY5V2pDAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAs5nw4GBPPHc9Nc08uLUYTDLkA2XM +WPugjSO7OxUe8NptVh/v4GbeKzQ4FRIF6rca8De15+OOZgIDppRUoy+fd+ncoDan +flw38rIj13XfV/3WF33Uag2xtZG0Hrpu4PFZQyIzr0MwGJJ/v2uRjMiV0CX+rc0L +BJg2JS4oCbNdQpwH81qOktoH8aHirAyLjtm732GQgAGLe0fIBBsb4Dg2ZdvN+TF5 +xfKoFfri3H1rwju43zHXmUyCE/RPdIBR8flO6gzdgWAVY0jaixZi1fzQEQuReh2j +d2iZYOFSrVDea41ltrUvRC6q6gxe/REVjj1nCSYU1x44J9DQ6n6ljvJCVw== +-----END CERTIFICATE-----)PEM"; + +static const char* csmTestPrivateKeyPem = R"PEM(-----BEGIN PRIVATE KEY----- +MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDEg1HE09f69FW/ +OLD0jrWEQRbKkSkIkexLfV5OtzbIZVDWcH3Y3NKrbZ60j8WEY8DqVzO2kMnOppc5 +LBEKGP1TTs6C9R+e5hlI6McoKownha4aU9nqM7dsjY71xGZNN9DCVxhRpqadlZon +7M4wzVvUO5VIRhFeA2AO6LRVQhyi9Whe/uJVlncb2tbiGgTavixWSQ5kH0ocE8Cp +4SbuHuXPwgiZ9hYEIX2xAFSR48OBbjWgqVISptu/s+UkK2XckI42qdxqzwglLIIq +jFYJ1HvGqhqV69DeqB0XNw6qp8W2qwTpv3gPGzI60vNL6aaHTivLxnsEClPbcrTf +G1y8DnnLAgMBAAECggEBALRPHebwzfrI2CilttAeZXTdWDEzsifX5K17cd3eBBkp +xVuNShuCupZq9bUNOhl4ghlDPALmpRTFDHp78YKHXWFkLN5CVeoxjL+2Po6fQ4w7 +/3zOtWNMYp/q32Kn+4ocjaLT0U+SDs0G6LR7dtGWjAyXQylWiTbu9+OWJ2kXSTlH +QbdtamymoJrrjRTV1HUEq/a3qSHlqTA5/EKIcGeiETq2NR0fZ3NFbe+PLiOSpiNg +uIiVEdsItuZTdINSEzOtMFvRd2od0ITDpMtLG404aGsI4Zisiuhr5naf4DWqK2aL +n9Z/55LSuAdBqvrtJ9XVdtNsFdCRjbIj2R1qqDTFm/kCgYEA6W/+ufDXrhPi8XWS +8+7tlOoUd0jYZL9N+N1hfho21SN3eH5TtNO0b/os/PN/M+5dKeWPtnyzwg49EksF +Es9Z4+lLt/Z+71RDmYqSCwaLhXNKtUZluZmrGHcRogd4hJDYv9icgmpwMK5Hg634 +PYCgVYb9C1Wug/mZhgLg7Aw3hn8CgYEA14GxAPViaSszVNRps+a9WVEJklPbPR8U +kAxWTP6n1SdT3Z9HRcHH9inIdTLyC/3ti4+4dc1pDkMrq+MUTjvF8BqN35uzJa7l +6dnsXBmWvB1cIcwQb4SLnDb7jzmiK2uIjMrO54x3+atB83GdvESLOQ/9NAJL/+NX +ILq5kAs2nrUCgYEAqq7/8pceLKNPybttMr0drEenpTx3NNsIORItydWDCD8BiPHd +ZJdzFHk5Uc780EzWg97dQNJXYWmlz+1YjVNdZ57ahW1PjNDxCKBgfn1PoMkW9ArA +MIAisSXGl9GcllmOkl/guB75Xy7fDXIz00xsb3zfIt2IV+k2Dt2l9hJMuyMCgYEA +lv45ZHCJeSJZntANF41NkazjxfCXJaYHJD5goSWztfcOHbOhnlB9qA3yc5s0WA6c +RzJ1jaRUPTf2+0HpUj8zGl2gldFjnb2DPWwA3S7YnAj+Knft9BSsNNGZQ+qfo0h+ +rhbTDQ0wanABj25FlEl6OornX29UjH9e5oGtziztIhkCgYEAppTHqOgLiKmeV15d +i850uRyh7X6whywY8gm0VLO+xzCVsCR6CvgZY1MwwFuDwu2d/d5jdJXLpHueQwNU +3HipTI77OuIRv4ykXwPOIemT9VmL/N21CgrckJGA6dYywTnc/JNpOKxdTM9srOyr +Rcsgla9jttJevaHI71x2jLNBaKk= +-----END PRIVATE KEY-----)PEM"; + extern void qInitResources_mudlet(); extern void qInitResources_qm(); extern void qInitResources_additional_splash_screens(); @@ -48,6 +102,42 @@ extern void qInitResources_mudlet_fonts_common(); extern void qInitResources_mudlet_fonts_posix(); static void initializeQRCResources(); +// Hands out QSslSocket connections when asked to, so the stub can offer an encrypted transport. +class GmcpTcpServer : public QTcpServer +{ + Q_OBJECT + +public: + explicit GmcpTcpServer(QObject* parent = nullptr) + : QTcpServer(parent) + { + } + + void setTls(bool tls) { mTls = tls; } + +protected: + void incomingConnection(qintptr socketDescriptor) override + { + if (!mTls) { + QTcpServer::incomingConnection(socketDescriptor); + return; + } + auto* socket = new QSslSocket(this); + if (!socket->setSocketDescriptor(socketDescriptor)) { + delete socket; + return; + } + socket->setLocalCertificate(QSslCertificate(QByteArray(csmTestCertificatePem))); + socket->setPrivateKey(QSslKey(QByteArray(csmTestPrivateKeyPem), QSsl::Rsa)); + socket->startServerEncryption(); + // QSslSocket buffers writes queued before the handshake, so this behaves like a plain socket. + addPendingConnection(socket); + } + +private: + bool mTls = false; +}; + // A tiny GMCP-capable server: offers GMCP on connect, parses the telnet stream to // collect the client's GMCP messages, and can push Char.Login frames on demand. class GmcpServerStub : public QObject @@ -65,8 +155,15 @@ public: // reads the actual port back via serverPort(). bool start() { return mServer.listen(QHostAddress::LocalHost, 0); } quint16 serverPort() const { return mServer.serverPort(); } + void setTls(bool tls) { mServer.setTls(tls); } bool gmcpEnabled() const { return mGmcpEnabled; } + // So a test asserting on encrypted-transport behaviour cannot silently pass over a plain socket. + bool clientEncrypted() const + { + auto* sslClient = qobject_cast<QSslSocket*>(mClient.data()); + return sslClient && sslClient->isEncrypted(); + } QStringList receivedGmcp() const { return mReceivedGmcp; } void clearReceived() { mReceivedGmcp.clear(); } @@ -200,7 +297,7 @@ private: mBuffer = mBuffer.mid(i); } - QTcpServer mServer; + GmcpTcpServer mServer; QPointer<QTcpSocket> mClient; QByteArray mBuffer; QStringList mReceivedGmcp; @@ -208,20 +305,61 @@ private: int mConnectionCount = 0; }; +// Serves a static OpenID Connect discovery document over loopback http, which +// OAuthClientFlow::acceptableEndpointUrl() permits, so no second certificate is needed. +class DiscoveryServerStub : public QObject +{ + Q_OBJECT + +public: + explicit DiscoveryServerStub(QObject* parent = nullptr) + : QObject(parent) + { + connect(&mServer, &QTcpServer::newConnection, this, [this]() { + while (mServer.hasPendingConnections()) { + QTcpSocket* socket = mServer.nextPendingConnection(); + connect(socket, &QTcpSocket::readyRead, socket, [this, socket]() { + mRequests[socket] += socket->readAll(); + if (!mRequests.value(socket).contains("\r\n\r\n")) { + return; + } + mRequests.remove(socket); + const QByteArray body = QJsonDocument(QJsonObject{{qsl("authorization_endpoint"), authorizationEndpoint()}}).toJson(QJsonDocument::Compact); + socket->write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body); + socket->disconnectFromHost(); + }); + connect(socket, &QObject::destroyed, this, [this, socket]() { + mRequests.remove(socket); + }); + connect(socket, &QTcpSocket::disconnected, socket, &QObject::deleteLater); + } + }); + } + + bool start() { return mServer.listen(QHostAddress::LocalHost, 0); } + QString discoveryUrl() const { return qsl("http://127.0.0.1:%1/.well-known/openid-configuration").arg(mServer.serverPort()); } + QString authorizationEndpoint() const { return qsl("http://127.0.0.1:%1/authorize").arg(mServer.serverPort()); } + +private: + QTcpServer mServer; + QHash<QTcpSocket*, QByteArray> mRequests; +}; + class GMCPCharLoginTest : public QObject { Q_OBJECT public slots: - // Registered as the http/https URL handler so a Char.Login.URL the client auto-opens routes here + // Registered as the http/https URL handler so a sign-in address the client auto-opens routes here // instead of launching a real browser during the test. - void captureOpenedUrl(const QUrl& url) { mOpenedUrl = url; } + void captureOpenedUrl(const QUrl& url) { mOpenedUrls.append(url); } private: GmcpServerStub* mpServer = nullptr; + DiscoveryServerStub* mpDiscovery = nullptr; const QString mHostname = qsl("Test-CharLogin"); quint16 mPort = 0; // assigned the stub's actual loopback port in init() - QUrl mOpenedUrl; + QList<QUrl> mOpenedUrls; private slots: void initTestCase() @@ -245,7 +383,7 @@ private slots: mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); mudlet::self()->init(); mudlet::self()->setStorePasswordsSecurely(false); - mOpenedUrl.clear(); + mOpenedUrls.clear(); // Start each test from a clean credential state so a reconnect token saved by an earlier test // cannot leak into one that expects none (which would make the client replay it instead). CredentialManager::removeCredential(mHostname, qsl("reconnect")); @@ -256,6 +394,8 @@ private slots: { delete mpServer; mpServer = nullptr; + delete mpDiscovery; + mpDiscovery = nullptr; deleteProfileDirectory(mHostname); delete mudlet::self(); } @@ -384,7 +524,7 @@ private slots: void testSavedTokenIsReplayedOnReconnect() { - Host* host = connectAndNegotiate(); + Host* host = connectAndNegotiate(true); QVERIFY(host); host->setLogin(QString()); host->setPass(QString()); @@ -402,10 +542,56 @@ private slots: QCOMPARE(sent.value(qsl("version")).toInt(), 2); } - void testRejectedReconnectTokenIsDiscarded() + void testSavedTokenIsNotReplayedOverCleartext() { Host* host = connectAndNegotiate(); QVERIFY(host); + QVERIFY2(!host->mTelnet.currentlySecure(), "precondition: this connection is unencrypted"); + host->setLogin(QString()); + host->setPass(QString()); + const QString tokenJson = qsl("{\"account\": \"acct:char\", \"token\": \"saved-token\"}"); + QVERIFY(CredentialManager::storeCredential(host->getName(), qsl("reconnect"), tokenJson)); + + mpServer->clearReceived(); + mpServer->sendGmcp(qsl("Char.Login.Default {\"version\": 2, \"type\": [\"oauth\", \"password-credentials\"]}")); + + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Credentials"), sent), "the sign-in should fall back to the interactive hand-off"); + QVERIFY2(sent.isEmpty(), "the fall-back must be the empty {} hand-off"); + QCOMPARE(mpServer->countReceived(qsl("Char.Login.Reconnect")), 0); + QVERIFY2(waitForConsoleContains(host, qsl("not encrypted")), "the user should be told why their saved sign-in was not used"); + QVERIFY2(!CredentialManager::retrieveCredential(host->getName(), qsl("reconnect")).isEmpty(), "refusing to send the token must not destroy it"); + + // Nothing awaits a reconnect result, so an ordinary failed sign-in must not be mistaken for a + // rejected token - that would rewrite or delete the stored entry the player still needs. + mpServer->sendGmcp(qsl("Char.Login.Result {\"success\": false, \"message\": \"Invalid credentials\"}")); + QVERIFY2(waitForConsoleContains(host, qsl("Could not log in to the game")), "a failed interactive sign-in should be reported as one"); + QVERIFY2(!CredentialManager::retrieveCredential(host->getName(), qsl("reconnect")).isEmpty(), "the stored sign-in must survive an unrelated login failure"); + } + + void testCleartextTokenFallsBackToProviderResume() + { + Host* host = connectAndNegotiate(); + QVERIFY(host); + host->setLogin(QString()); + host->setPass(QString()); + const QString tokenJson = qsl("{\"account\": \"acct:char\", \"provider\": \"discord\", \"token\": \"saved-token\"}"); + QVERIFY(CredentialManager::storeCredential(host->getName(), qsl("reconnect"), tokenJson)); + + mpServer->clearReceived(); + mpServer->sendGmcp(qsl("Char.Login.Default {\"version\": 2, \"type\": [\"oauth\", \"password-credentials\"]}")); + + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Credentials"), sent), "client did not send the resume form"); + QCOMPARE(sent.value(qsl("provider")).toString(), qsl("discord")); + QVERIFY2(!sent.contains(qsl("token")), "the resume form must not carry the token"); + QCOMPARE(mpServer->countReceived(qsl("Char.Login.Reconnect")), 0); + } + + void testRejectedReconnectTokenIsDiscarded() + { + Host* host = connectAndNegotiate(true); + QVERIFY(host); host->setLogin(QString()); host->setPass(QString()); // No provider in the entry: with nothing to resume, rejection removes the entry entirely. @@ -426,7 +612,7 @@ private slots: void testRejectedReconnectKeepsResumeHint() { - Host* host = connectAndNegotiate(); + Host* host = connectAndNegotiate(true); QVERIFY(host); host->setLogin(QString()); host->setPass(QString()); @@ -491,7 +677,7 @@ private slots: void testRotatedTokenIsReplayedNotDiscarded() { - Host* host = connectAndNegotiate(); + Host* host = connectAndNegotiate(true); QVERIFY(host); host->setLogin(QString()); host->setPass(QString()); @@ -530,6 +716,41 @@ private slots: QCOMPARE(mpServer->countReceived(qsl("Char.Login.Reconnect")), 0); } + void testRotationReplayClearsTheRejectionLatch() + { + Host* host = connectAndNegotiate(true); + QVERIFY(host); + host->setLogin(QString()); + host->setPass(QString()); + const QString tokenJson = qsl("{\"account\": \"acct:char\", \"provider\": \"discord\", \"token\": \"token-A\"}"); + QVERIFY(CredentialManager::storeCredential(host->getName(), qsl("reconnect"), tokenJson)); + + mpServer->clearReceived(); + mpServer->sendGmcp(qsl("Char.Login.Default {\"version\": 2, \"type\": [\"oauth\", \"password-credentials\"]}")); + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Reconnect"), sent), "client did not replay the saved token"); + QCOMPARE(sent.value(qsl("token")).toString(), qsl("token-A")); + + // Another instance rotates the token, so our replay of token-A is rejected and the client replays + // the fresh token-B rather than discarding it. + const QString rotatedJson = qsl("{\"account\": \"acct:char\", \"provider\": \"discord\", \"token\": \"token-B\"}"); + QVERIFY(CredentialManager::storeCredential(host->getName(), qsl("reconnect"), rotatedJson)); + mpServer->clearReceived(); + mpServer->sendGmcp(qsl("Char.Login.Result {\"success\": false, \"message\": \"Reconnect token expired\"}")); + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Reconnect"), sent), "client did not replay the rotated token"); + QCOMPARE(sent.value(qsl("token")).toString(), qsl("token-B")); + + // Replaying a rotated token is an ordinary sign-in, not a recovery from a dead one, so the + // rejection latch must have been released again: a following Char.Login.Default may replay the + // stored token. Were the latch left set, this would come back as the token-less resume form and + // the player would face a browser sign-in despite holding a good token. + mpServer->clearReceived(); + mpServer->sendGmcp(qsl("Char.Login.Default {\"version\": 2, \"type\": [\"oauth\", \"password-credentials\"]}")); + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Reconnect"), sent), "the rejection latch leaked past the rotation replay"); + QCOMPARE(sent.value(qsl("token")).toString(), qsl("token-B")); + QCOMPARE(mpServer->countReceived(qsl("Char.Login.Credentials")), 0); + } + void testCorruptStoredEntryFallsThroughToHandoff() { Host* host = connectAndNegotiate(); @@ -587,7 +808,7 @@ private slots: void testStoredCredentialsOutrankSavedToken() { - Host* host = connectAndNegotiate(); + Host* host = connectAndNegotiate(true); QVERIFY(host); // Both a saved reconnect token AND stored character name/password are present. The player's // typed credentials name the exact character, so they must win: the client sends @@ -617,17 +838,40 @@ private slots: // Simulate the player having acted on the game's sign-in screen this connection; an unsolicited // Char.Login.URL is then a consequence of their input and must be auto-opened in the browser. host->setUserSentInputThisConnection(true); - mOpenedUrl.clear(); + mOpenedUrls.clear(); mpServer->sendGmcp(qsl("Char.Login.URL {\"url\": \"https://example.com/signin\", \"provider\": \"discord\"}")); QVERIFY2(waitForConsoleContains(host, qsl("Opening your browser to sign in with Discord")), "a prompted URL should be auto-opened with a provider-labelled handoff"); - QCOMPARE(mOpenedUrl, QUrl(qsl("https://example.com/signin"))); + QCOMPARE(mOpenedUrls, QList<QUrl>{QUrl(qsl("https://example.com/signin"))}); + } + + void testRepeatedAuthUrlsOpenOneBrowserPerUserAction() + { + Host* host = connectAndNegotiate(); + QVERIFY(host); + host->setUserSentInputThisConnection(true); + mOpenedUrls.clear(); + + for (int i = 1; i <= 5; ++i) { + mpServer->sendGmcp(qsl("Char.Login.URL {\"url\": \"https://example.com/signin%1\"}").arg(i)); + } + // GMCP frames are handled in order, so a reply to this one proves all five were processed. + mpServer->sendGmcp(qsl("Char.Login.Default {\"version\": 2, \"type\": [\"password-credentials\"]}")); + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Credentials"), sent), "client did not work through the pushed sign-in addresses"); + QCOMPARE(mOpenedUrls, QList<QUrl>{QUrl(qsl("https://example.com/signin1"))}); + + // A further player action re-arms it: a rate limit, not a one-per-connection cap. + host->setUserSentInputThisConnection(true); + mpServer->sendGmcp(qsl("Char.Login.URL {\"url\": \"https://example.com/signin6\"}")); + QTRY_COMPARE(mOpenedUrls.size(), 2); + QCOMPARE(mOpenedUrls.at(1), QUrl(qsl("https://example.com/signin6"))); } // ---- Post-rejection loop guard (allowToken == false) ------------------- void testReconnectAfterRejectionDoesNotReplayToken() { - Host* host = connectAndNegotiate(); + Host* host = connectAndNegotiate(true); QVERIFY(host); host->setLogin(QString()); host->setPass(QString()); @@ -660,14 +904,124 @@ private slots: QCOMPARE(mpServer->countReceived(qsl("Char.Login.Reconnect")), 0); } - // NOTE: the stale-callback (mAuthAttemptGeneration) guard in readStoredSignIn/retryOrDropRejectedToken - // - which drops a reconnect-token keychain read whose connection was superseded by a newer - // Char.Login.Default before the async read resolved - is intentionally NOT covered here. It is not - // deterministically testable with the current harness: in test/portable mode CredentialManager reads - // credentials synchronously and inline (see CredentialManager::retrievePassword), so a read always - // completes before any superseding Char.Login.Default can arrive, and the guarded race never occurs. - // Exercising it would require an injectable, genuinely-asynchronous credential manager. Documented - // rather than covered by a test that would pass without ever reaching the guard. + // NOTE: the superseded-callback (mAuthAttemptGeneration) path in readStoredSignIn and + // retryOrDropRejectedToken - taken when a newer Char.Login.Default arrives before the reconnect-token + // keychain read resolves - is intentionally NOT covered here. It is not deterministically testable + // with the current harness: in test/portable mode CredentialManager reads credentials synchronously + // and inline (see CredentialManager::retrievePassword), so a read always completes before any + // superseding Char.Login.Default can arrive, and the race never occurs. Exercising it would require an + // injectable, genuinely-asynchronous credential manager. + // + // Worth the seam if anyone revisits this: in readStoredSignIn the path is a bare early return, but in + // retryOrDropRejectedToken it decides whether to rewrite the stored entry and whether to re-arm + // mReconnectRejected. Getting either wrong loses a player's freshly saved token or lets a rejected one + // be replayed, and neither failure is reachable by hand. + + // ---- Client-driven OAuth (Char.Login.AuthCode) ------------------------- + + void testClientDrivenOAuthOpensOneBrowserPerConnection() + { + Host* host = connectAndNegotiate(true); + QVERIFY(host); + host->setLogin(QString()); + host->setPass(QString()); + QVERIFY2(!host->userSentInputThisConnection(), "precondition: no user input yet"); + startDiscoveryServer(); + mOpenedUrls.clear(); + + // Connecting is itself the request, so the first offer opens the browser with nothing typed. + mpServer->sendGmcp(clientDrivenDefault()); + QTRY_COMPARE(mOpenedUrls.size(), 1); + + for (int i = 0; i < 4; ++i) { + mpServer->sendGmcp(clientDrivenDefault()); + } + QVERIFY2(waitForConsoleContains(host, qsl("To sign in, open this link")), "a re-offered client-driven sign-in should be offered as a link"); + QCOMPARE(mOpenedUrls.size(), 1); + } + + void testAuthCodeCarriesTheNonceTheServerAskedFor() + { + Host* host = connectAndNegotiate(true); + QVERIFY(host); + host->setLogin(QString()); + host->setPass(QString()); + startDiscoveryServer(); + mOpenedUrls.clear(); + mpServer->clearReceived(); + + mpServer->sendGmcp(clientDrivenDefault()); + QTRY_VERIFY(!mOpenedUrls.isEmpty()); + const QUrlQuery authorizationQuery(mOpenedUrls.first()); + const QString nonce = authorizationQuery.queryItemValue(qsl("nonce")); + QVERIFY2(!nonce.isEmpty(), "the authorization request should carry a nonce when the server asked for one"); + + // Play the identity provider: send the browser's redirect back to the loopback listener. + const QUrl redirectUri(authorizationQuery.queryItemValue(qsl("redirect_uri"), QUrl::FullyDecoded)); + QTcpSocket browser; + browser.connectToHost(redirectUri.host(), static_cast<quint16>(redirectUri.port())); + QVERIFY(browser.waitForConnected(3000)); + browser.write("GET /?code=test-auth-code&state=" + authorizationQuery.queryItemValue(qsl("state")).toLatin1() + " HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.AuthCode"), sent), "client did not complete the client-driven sign-in"); + QCOMPARE(sent.value(qsl("code")).toString(), qsl("test-auth-code")); + QVERIFY(!sent.value(qsl("code_verifier")).toString().isEmpty()); + QCOMPARE(sent.value(qsl("redirect_uri")).toString(), redirectUri.toString()); + QCOMPARE(sent.value(qsl("nonce")).toString(), nonce); + } + + void testAuthCodeOmitsTheNonceWhenTheServerDidNotAskForIt() + { + Host* host = connectAndNegotiate(true); + QVERIFY(host); + host->setLogin(QString()); + host->setPass(QString()); + startDiscoveryServer(); + mOpenedUrls.clear(); + mpServer->clearReceived(); + + mpServer->sendGmcp(clientDrivenDefault(false)); + QTRY_VERIFY(!mOpenedUrls.isEmpty()); + const QUrlQuery authorizationQuery(mOpenedUrls.first()); + QVERIFY2(!authorizationQuery.hasQueryItem(qsl("nonce")), "no nonce should be requested from the provider either"); + + const QUrl redirectUri(authorizationQuery.queryItemValue(qsl("redirect_uri"), QUrl::FullyDecoded)); + QTcpSocket browser; + browser.connectToHost(redirectUri.host(), static_cast<quint16>(redirectUri.port())); + QVERIFY(browser.waitForConnected(3000)); + browser.write("GET /?code=test-auth-code&state=" + authorizationQuery.queryItemValue(qsl("state")).toLatin1() + " HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); + + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.AuthCode"), sent), "client did not complete the client-driven sign-in"); + QVERIFY2(!sent.contains(qsl("nonce")), "an empty nonce must be left out rather than sent as an empty string"); + } + + // ---- Char.Login.Default flood ------------------------------------------ + + void testDefaultFloodIsThrottled() + { + Host* host = connectAndNegotiate(); + QVERIFY(host); + host->setLogin(QString()); + host->setPass(QString()); + + mpServer->clearReceived(); + for (int i = 0; i < 200; ++i) { + mpServer->sendGmcp(qsl("Char.Login.Default {\"version\": 2, \"type\": [\"oauth\", \"password-credentials\"]}")); + } + + QJsonObject sent; + QVERIFY2(waitForClientGmcp(qsl("Char.Login.Credentials"), sent), "the first frame should still be answered straight away"); + + // Cost is bounded by wall clock, not by how much the server sent: one immediate attempt plus + // one when the window closes, not 200. A range, so a loaded runner slipping into the next + // window does not flake, and a lower bound because a re-offer must still be answered. + QTest::qWait(2500ms); + const int attempts = mpServer->countReceived(qsl("Char.Login.Credentials")); + QVERIFY2(attempts >= 2, qPrintable(qsl("a throttled burst must still be answered, saw %1 attempts").arg(attempts))); + QVERIFY2(attempts <= 4, qPrintable(qsl("200 frames should not buy 200 sign-in attempts, saw %1").arg(attempts))); + } // ---- Char.Login.Result -------------------------------------------------- @@ -688,8 +1042,44 @@ private slots: } private: - // Drive the GUI to create/connect a profile, then wait for GMCP to negotiate. - Host* connectAndNegotiate() + void startDiscoveryServer() + { + mpDiscovery = new DiscoveryServerStub(); + QVERIFY(mpDiscovery->start()); + } + + // Advertises the client-driven OAuth capability, which the client only honours over TLS. + QString clientDrivenDefault(bool requestNonce = true) const + { + return qsl(R"(Char.Login.Default {"version": 2, "type": ["oauth"], "location": "%1", "client_id": "test-client", "nonce": %2})") + .arg(mpDiscovery->discoveryUrl(), requestNonce ? qsl("true") : qsl("false")); + } + + // Drive the GUI to create/connect a profile, then wait for GMCP to negotiate. Reaching TLS by + // reconnecting rather than creating the profile encrypted is what makes this deterministic: + // mSslTsl and mSslIgnoreAll are set on a live Host, before the attempt that reads them starts. + Host* connectAndNegotiate(bool secure = false) + { + Host* host = createProfileAndConnect(); + if (!host || !secure) { + return host; + } + host->mSslTsl = true; + host->mSslIgnoreAll = true; // the stub's certificate is self-signed + mpServer->setTls(true); + const int plainConnection = mpServer->connectionCount(); + host->mTelnet.reconnect(); + if (!waitForNegotiatedConnection(plainConnection)) { + return nullptr; + } + if (!mpServer->clientEncrypted()) { + qWarning("The connection did not complete a TLS handshake"); + return nullptr; + } + return host; + } + + Host* createProfileAndConnect() { const QString port = QString::number(mPort); QTimer::singleShot(0ms, qApp, [this, port]() { @@ -710,8 +1100,9 @@ private: QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); }); + // A fresh mudlet and profile per test on an instrumented, loaded runner is slow. QSignalSpy loaded(mudlet::self(), &mudlet::signal_profileLoaded); - if (!loaded.wait(5000)) { + if (!loaded.wait(20000)) { qWarning("Profile took too long to load"); return nullptr; } @@ -720,22 +1111,21 @@ private: qWarning("No active host"); return nullptr; } - QSignalSpy connected(&(host->mTelnet), &cTelnet::signal_connected); - if (!connected.wait(3000)) { - qWarning("Could not connect to the stub"); - return nullptr; - } - // Wait until the client has answered our GMCP offer (IAC DO GMCP) so that - // Char.Login frames we push afterwards are processed. - const bool negotiated = QTest::qWaitFor( - [this]() { - return mpServer->gmcpEnabled(); + return waitForNegotiatedConnection(0) ? host : nullptr; + } + + // Also waits for the client to answer our GMCP offer, so frames pushed afterwards are processed. + bool waitForNegotiatedConnection(int afterConnectionCount) + { + const bool connected = QTest::qWaitFor( + [this, afterConnectionCount]() { + return mpServer->connectionCount() > afterConnectionCount && mpServer->gmcpEnabled(); }, - 3000); - if (!negotiated) { - qWarning("GMCP was not negotiated"); + 15000); + if (!connected) { + qWarning("Could not connect to the stub, or GMCP was not negotiated"); } - return host; + return connected; } // Wait until the client sends a GMCP message whose package matches, returning its JSON body. diff --git a/test/functional_tests/GlyphOverflowTest.cpp b/test/functional_tests/GlyphOverflowTest.cpp new file mode 100644 index 000000000..4adb40c60 --- /dev/null +++ b/test/functional_tests/GlyphOverflowTest.cpp @@ -0,0 +1,718 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QClipboard> +#include <QFontDatabase> +#include <QPainter> +#include <QtTest/QtTest> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TTextEdit.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResources(); + +// TTextEdit lays text out in cells of QFontMetrics::height(), which is a +// typographic measure rather than the glyph ink box. At a good number of font +// sizes the ink of a glyph such as "_" reaches a pixel past the bottom of its +// cell, so it only renders completely if nothing paints over that pixel +// afterwards. #9070 and #9719 are both reports of underscores vanishing because +// something did. +class GlyphOverflowTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mHostname = "Test-GlyphOverflow"; + QString mPort; + const QString mLocalhost = "localhost"; + + // What the line below the underscores looks like. Each one takes a different + // branch of the background fill in layoutGrapheme(). + struct Underlay + { + QString name; + QString colourTag; + bool selected = false; + }; + + static QVector<Underlay> underlays() + { + return { + {qsl("the console background"), qsl("<white>")}, + {qsl("an explicit background colour"), qsl("<white:blue>")}, + {qsl("a bright background colour"), qsl("<black:yellow>")}, + {qsl("a selection"), qsl("<blue>"), true}, + }; + } + + static constexpr int kUnderscoreCount = 40; + static constexpr int kFillerCount = 60; + // Column used to sample what a pixel row looks like where no glyph was + // drawn; whatever the line below paints there is the reference for its own + // pixel row. + static constexpr int kBackgroundSampleColumn = 50; + // How far a channel has to move from its row's background before the pixel + // counts as glyph ink rather than antialiasing noise. + static constexpr int kInkThreshold = 24; + // How far past the bottom of a cell to look for ink that overflowed out of it + static constexpr int kOverflowScanRows = 4; + static const inline QStringList kTestFamilies = {qsl("Bitstream Vera Sans Mono"), qsl("Ubuntu Mono")}; + static constexpr int kFirstSize = 9; + static constexpr int kLastSize = 30; + + static bool pixelIsInk(QRgb pixel, QRgb background) + { + return qAbs(qRed(pixel) - qRed(background)) > kInkThreshold || qAbs(qGreen(pixel) - qGreen(background)) > kInkThreshold || qAbs(qBlue(pixel) - qBlue(background)) > kInkThreshold; + } + +private slots: + void initTestCase() + { + initializeQRCResources(); +#ifndef INCLUDE_FONTS + QSKIP("Built with WITH_FONTS=NO, so the fonts whose metrics this measures are not available"); +#else + // src/main.cpp extracts the bundled fonts into the config directory and + // FontManager picks them up from there, but QTEST_MAIN never runs + // main(), so on a machine that has not run Mudlet before there is + // nothing on disk to pick up and Qt quietly substitutes another family. + for (const QString& file : {qsl(":/fonts/ttf-bitstream-vera-1.10/VeraMono.ttf"), + qsl(":/fonts/ttf-bitstream-vera-1.10/VeraMoBd.ttf"), + qsl(":/fonts/ubuntu-font-family-0.83/UbuntuMono-R.ttf"), + qsl(":/fonts/ubuntu-font-family-0.83/UbuntuMono-B.ttf")}) { + QVERIFY2(QFontDatabase::addApplicationFont(file) != -1, qPrintable(qsl("Could not register the bundled font %1").arg(file))); + } + for (const QString& family : kTestFamilies) { + QVERIFY2(QFontDatabase::families().contains(family), qPrintable(qsl("'%1' is missing from the font database after registering the bundled files").arg(family))); + } +#endif + } + + void init() + { + mpServer = new TelnetServerStub(qApp); + // Port 0 asks the OS for an ephemeral port so parallel test runs do not + // collide on a hardcoded one + mpServer->start(mLocalhost, 0); + QVERIFY2(mpServer->isListening(), "TelnetServerStub failed to bind a loopback port"); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + } + + // A line has to render all of its ink whatever the line below it looks like, + // and that ink has to match the same glyph drawn on its own with the same + // font, cell geometry and painter flags. + void test_lineBelowDoesNotEraseOverflowingInk() + { + Host* host = startOfflineProfile(); + QVERIFY2(host, "Could not start an offline profile"); + TTextEdit* pane = host->mpConsole->mUpperPane; + QVERIFY2(pane, "No upper pane available"); + + int sizesWithOverflow = 0; + for (const QString& family : kTestFamilies) { + for (int size = kFirstSize; size <= kLastSize; ++size) { + applyFont(host, family, size); + QVERIFY2(pane->getColumnCount() > kBackgroundSampleColumn, + qPrintable(qsl("%1 %2pt narrowed the pane to %3 columns, too few for the background sample at column %4") + .arg(family) + .arg(size) + .arg(pane->getColumnCount()) + .arg(kBackgroundSampleColumn))); + const int cellHeight = cellHeightOf(pane); + const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidthOf(pane), cellHeight); + if (expected.second >= cellHeight) { + ++sizesWithOverflow; + } + + for (const Underlay& underlay : underlays()) { + const QVector<QPoint> ink = renderAndCollectInk(host, underlay); + const QString where = qsl("%1 %2pt below %3").arg(family).arg(size).arg(underlay.name); + QVERIFY2(!ink.isEmpty(), qPrintable(qsl("%1: no underscore ink rendered at all").arg(where))); + + const QPair<int, int> actual = inkExtent(ink); + QVERIFY2(actual == expected, + qPrintable(qsl("%1: underscore ink occupies %2 of its cell, expected %3 (cell is %4 tall)").arg(where, describeExtent(actual), describeExtent(expected)).arg(cellHeight))); + } + } + } + + if (sizesWithOverflow == 0) { + // The comparisons above all ran and passed, so this is a coverage + // warning rather than a skip + QWARN("None of the tested font sizes overflow their cell on this platform, so the overflow case went unexercised"); + } + } + + // The screen is rendered into a pixmap sized from the number of whole + // character cells that fit, so the bottom line's overflow only survives if + // that pixmap has somewhere to put it. + void test_bottomLineOverflowSurvivesThePixmapEdge() + { + Host* host = startOfflineProfile(); + QVERIFY2(host, "Could not start an offline profile"); + TTextEdit* pane = host->mpConsole->mUpperPane; + QVERIFY2(pane, "No upper pane available"); + + int checkedSizes = 0; + for (int size = kFirstSize; size <= kLastSize; ++size) { + applyFont(host, kTestFamilies.first(), size); + const int cellHeight = cellHeightOf(pane); + const int cellWidth = cellWidthOf(pane); + const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight); + if (expected.second < cellHeight) { + continue; // this size keeps its ink inside the cell, nothing to check + } + // A pane whose height is an exact multiple of the cell height has no + // pixel left over for the bottom line's overflow to appear in. + if (pane->height() % cellHeight == 0) { + continue; + } + ++checkedSizes; + + const int screenHeight = pane->getScreenHeight(); + runLua(host, qsl("clearWindow()")); + runLua(host, qsl("cecho('<white>' .. string.rep('filler\\n', %1) .. '%2\\n')").arg(screenHeight - 1).arg(QString(kUnderscoreCount, QLatin1Char('_')))); + pane->forceUpdate(); + QApplication::processEvents(); + + const QImage rendered = renderPane(host); + const int cellTop = (screenHeight - 1) * cellHeight; + const QPair<int, int> actual = inkExtent(collectInk(rendered, cellTop, cellHeight, cellWidth)); + QVERIFY2(actual == expected, + qPrintable(qsl("%1pt: the bottom line's underscore ink occupies %2 of its cell, expected %3").arg(QString::number(size), describeExtent(actual), describeExtent(expected)))); + } + + if (checkedSizes == 0) { + QSKIP("No font size produced an overflowing bottom line on this platform, so nothing here is being proved"); + } + } + + // Repainting part of the pane clears whole character cells, which takes the + // previous line's overflow pixel with it. That line sits outside the dirty + // region and is not redrawn, so the pixel has to be put back explicitly. + void test_partialRepaintKeepsTheLineAboveIntact() + { + Host* host = startOfflineProfile(); + QVERIFY2(host, "Could not start an offline profile"); + TTextEdit* pane = host->mpConsole->mUpperPane; + QVERIFY2(pane, "No upper pane available"); + + int checkedSizes = 0; + for (int size = kFirstSize; size <= kLastSize; ++size) { + applyFont(host, kTestFamilies.first(), size); + const int cellHeight = cellHeightOf(pane); + const int cellWidth = cellWidthOf(pane); + const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight); + if (expected.second < cellHeight) { + continue; + } + ++checkedSizes; + + // More lines than fit, so imageTopLine() is past zero - the + // precondition for the partial repaint below to reach + // drawForeground()'s cached-pixmap path. + runLua(host, qsl("clearWindow()")); + runLua(host, qsl("cecho('<white>' .. string.rep('%1\\n', %2))").arg(QString(kUnderscoreCount, QLatin1Char('_'))).arg(pane->getScreenHeight() * 2)); + pane->forceUpdate(); + QApplication::processEvents(); + QVERIFY2(pane->imageTopLine() > 0, "The pane did not scroll, so the partial repaint would not reach the cached-pixmap path"); + + // Simulate a partial repaint: the previous frame, the damaged band + // reset to the console background, then only that band re-rendered. + QImage rendered = renderPane(host); + const int row = pane->getScreenHeight() / 2; + const QRect damaged(0, row * cellHeight, pane->width(), cellHeight * 2); + QPainter eraser(&rendered); + eraser.fillRect(damaged, host->mpConsole->getConsoleBgColor()); + eraser.end(); + pane->render(&rendered, damaged.topLeft(), QRegion(damaged), QWidget::DrawChildren); + + // Only the bottom of the ink can be pinned here: every line is + // underscores, so the top rows of the cell hold the overflow of the + // line above it rather than this line's own glyph. + const QPair<int, int> actual = inkExtent(collectInk(rendered, (row - 1) * cellHeight, cellHeight, cellWidth)); + QVERIFY2(actual.second == expected.second, + qPrintable(qsl("%1pt: after a partial repaint the line above the dirty region ends at row %2 of its cell, expected %3").arg(size).arg(actual.second).arg(expected.second))); + } + + if (checkedSizes == 0) { + QSKIP("No font size produced an overflowing line on this platform, so nothing here is being proved"); + } + } + + // Scrolling reuses the cached screen by blitting it a whole number of cells + // up or down, which lands a complete line of text in the strip below the + // last one. Only the bottom line's own overflow belongs there. + void test_scrollingLeavesNoGhostLineBelowTheBottomOne() + { + Host* host = startOfflineProfile(); + QVERIFY2(host, "Could not start an offline profile"); + TTextEdit* pane = host->mpConsole->mUpperPane; + QVERIFY2(pane, "No upper pane available"); + + int checkedSizes = 0; + for (int size = kFirstSize; size <= kLastSize; ++size) { + applyFont(host, kTestFamilies.first(), size); + const int cellHeight = cellHeightOf(pane); + const int cellWidth = cellWidthOf(pane); + const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight); + const int spareTop = pane->getScreenHeight() * cellHeight; + if (expected.second < cellHeight || pane->height() - spareTop <= kOverflowScanRows) { + continue; + } + ++checkedSizes; + + // Underscores for the overflow, letters past them so that a whole + // ghost line would be unmistakable in the strip. + const QString line = QString(kUnderscoreCount, QLatin1Char('_')) + QString(20, QLatin1Char('M')); + runLua(host, qsl("clearWindow()")); + runLua(host, qsl("cecho('<white>' .. string.rep('%1\\n', %2))").arg(line).arg(pane->getScreenHeight() * 4)); + pane->forceUpdate(); + QApplication::processEvents(); + // primes the cached screen the scroll below is blitted from + renderPane(host); + + // drawForeground() ignores the cache entirely below ten scrolled-off + // lines, so there has to be more scrollback than that + const int topLineBeforeScroll = pane->imageTopLine(); + QVERIFY2(topLineBeforeScroll >= 10, "Not enough scrollback for drawForeground() to take its scrolling path"); + + // Render straight after the scroll so the frame under test is the + // one drawForeground() builds from the shifted cache. + pane->scrollUp(3); + QVERIFY2(pane->imageTopLine() < topLineBeforeScroll, "The pane did not scroll back, so the frame below is not built from a shifted cache"); + const QImage rendered = renderPane(host); + + for (int y = spareTop + kOverflowScanRows; y < pane->height(); ++y) { + int litPixels = 0; + for (int x = 0; x < rendered.width(); ++x) { + if (pixelIsInk(rendered.pixel(x, y), consoleBackground(host))) { + ++litPixels; + } + } + QVERIFY2(litPixels == 0, qPrintable(qsl("%1pt: %2 stray pixels %3 rows below the last character cell after scrolling back").arg(size).arg(litPixels).arg(y - spareTop))); + } + + // As above, the top of the cell holds the previous line's overflow + const QPair<int, int> actual = inkExtent(collectInk(rendered, spareTop - cellHeight, cellHeight, cellWidth)); + QVERIFY2(actual.second == expected.second, + qPrintable(qsl("%1pt: after scrolling back the bottom line's underscore ink ends at row %2 of its cell, expected %3").arg(size).arg(actual.second).arg(expected.second))); + } + + if (checkedSizes == 0) { + QSKIP("No font size produced an overflowing bottom line on this platform, so nothing here is being proved"); + } + } + + // Miniconsoles keep the fill rule the main console does not, so check the + // paint order protects their overflow too. + void test_miniConsoleKeepsOverflowingInk() + { + Host* host = startOfflineProfile(); + QVERIFY2(host, "Could not start an offline profile"); + runLua(host, qsl("createMiniConsole('overflowMini', 0, 0, 800, 400)")); + auto* mini = host->mpConsole->mSubConsoleMap.value(qsl("overflowMini")); + QVERIFY2(mini, "The miniconsole was not created"); + TTextEdit* pane = mini->mUpperPane; + QVERIFY2(pane, "The miniconsole has no pane"); + + int checkedSizes = 0; + for (int size = kFirstSize; size <= kLastSize; ++size) { + runLua(host, qsl("setFont('overflowMini', '%1')").arg(kTestFamilies.first())); + runLua(host, qsl("setMiniConsoleFontSize('overflowMini', %1)").arg(size)); + QApplication::processEvents(); + // this one goes through the miniconsole API rather than applyFont(), + // so it needs its own check that the family was not substituted + QVERIFY2(QFontInfo(pane->font()).family() == kTestFamilies.first(), + qPrintable(qsl("The miniconsole resolved to '%1' rather than '%2', so this would measure the wrong glyph").arg(QFontInfo(pane->font()).family(), kTestFamilies.first()))); + const int cellHeight = cellHeightOf(pane); + const int cellWidth = cellWidthOf(pane); + const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight); + if (expected.second < cellHeight || pane->getColumnCount() <= kBackgroundSampleColumn) { + continue; + } + ++checkedSizes; + + runLua(host, qsl("clearWindow('overflowMini')")); + runLua(host, qsl("cecho('overflowMini', '<white>%1\\n')").arg(QString(kUnderscoreCount, QLatin1Char('_')))); + runLua(host, qsl("cecho('overflowMini', '<white:blue>%1\\n')").arg(QString(kFillerCount, QLatin1Char(' ')))); + pane->forceUpdate(); + QApplication::processEvents(); + + QImage rendered(pane->size(), QImage::Format_ARGB32_Premultiplied); + rendered.fill(mini->getConsoleBgColor()); + pane->render(&rendered, QPoint(), QRegion(), QWidget::DrawChildren); + + const QPair<int, int> actual = inkExtent(collectInk(rendered, 0, cellHeight, cellWidth)); + QVERIFY2(actual == expected, + qPrintable(qsl("%1pt: a miniconsole's underscore ink occupies %2 of its cell, expected %3").arg(QString::number(size), describeExtent(actual), describeExtent(expected)))); + } + + if (checkedSizes == 0) { + QSKIP("No font size produced an overflowing line in a miniconsole on this platform, so nothing here is being proved"); + } + } + + // Copy-as-image sizes its pixmap at exactly one cell per selected line, so + // the bottom line's overflow has nowhere to go unless the paint leaves room + // for it and the image is trimmed back afterwards. + void test_copyAsImageKeepsTheBottomLineOverflow() + { + Host* host = startOfflineProfile(); + QVERIFY2(host, "Could not start an offline profile"); + TTextEdit* pane = host->mpConsole->mUpperPane; + QVERIFY2(pane, "No upper pane available"); + + int checkedSizes = 0; + for (int size = kFirstSize; size <= kLastSize; ++size) { + applyFont(host, kTestFamilies.first(), size); + const int cellHeight = cellHeightOf(pane); + const int cellWidth = cellWidthOf(pane); + const QPair<int, int> expected = referenceInkExtent(pane->font(), qsl("_"), cellWidth, cellHeight); + if (expected.second < cellHeight) { + continue; + } + ++checkedSizes; + + const int selectedLines = 5; + runLua(host, qsl("clearWindow()")); + runLua(host, qsl("cecho('<white>' .. string.rep('%1\\n', %2))").arg(QString(kUnderscoreCount, QLatin1Char('_'))).arg(selectedLines)); + pane->forceUpdate(); + QApplication::processEvents(); + + selectRows(pane, 0, selectedLines - 1, cellHeight, cellWidth); + QMetaObject::invokeMethod(pane, "slot_copySelectionToClipboardImage", Qt::DirectConnection); + QApplication::processEvents(); + + const QImage copied = QApplication::clipboard()->image(); + QVERIFY2(!copied.isNull(), qPrintable(qsl("%1pt: copy as image produced nothing").arg(size))); + const QPair<int, int> actual = inkExtentOnFlat(copied, (selectedLines - 1) * cellHeight, cellHeight, consoleBackground(host)); + QVERIFY2(actual.second == expected.second, + qPrintable(qsl("%1pt: the copied image's bottom line ends at row %2 of its cell, expected %3").arg(size).arg(actual.second).arg(expected.second))); + // the spare row must not survive as blank padding, nor bring an + // extra line of text with it + QVERIFY2(copied.height() <= selectedLines * cellHeight + kOverflowScanRows, + qPrintable(qsl("%1pt: the copied image is %2px tall for %3 lines of %4px").arg(size).arg(copied.height()).arg(selectedLines).arg(cellHeight))); + } + + if (checkedSizes == 0) { + QSKIP("No font size produced an overflowing line on this platform, so nothing here is being proved"); + } + } + + void cleanup() + { + const QString profilePath = mudlet::getMudletPath(enums::profileHomePath, mHostname); + delete mudlet::self(); + delete mpServer; + mpServer = nullptr; + deleteDirectory(profilePath); + } + +private: + // The pane paints its cells onto whatever the parent widget is showing, so + // start from the console background rather than letting render() lay down + // the default palette colour where no cell was filled. + static QImage renderPane(Host* host) + { + TTextEdit* pane = host->mpConsole->mUpperPane; + QImage image(pane->size(), QImage::Format_ARGB32_Premultiplied); + image.fill(host->mpConsole->getConsoleBgColor()); + pane->render(&image, QPoint(), QRegion(), QWidget::DrawChildren); + return image; + } + + // For images too narrow to carry a background sample column, such as the + // copy-as-image output, which is only as wide as the selected text. + static QPair<int, int> inkExtentOnFlat(const QImage& image, int cellTop, int cellHeight, QRgb background) + { + int top = -1; + int bottom = -1; + for (int y = qMax(0, cellTop); y < qMin(image.height(), cellTop + cellHeight + kOverflowScanRows); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (pixelIsInk(image.pixel(x, y), background)) { + if (top < 0) { + top = y; + } + bottom = y; + break; + } + } + } + return {top - cellTop, bottom - cellTop}; + } + + static QRgb consoleBackground(Host* host) { return host->mpConsole->getConsoleBgColor().rgb(); } + + static int cellHeightOf(const TTextEdit* pane) { return QFontMetrics(pane->font()).height(); } + static int cellWidthOf(const TTextEdit* pane) { return QFontMetrics(pane->font()).averageCharWidth(); } + + // First and last pixel row of a set of ink, as offsets from the cell top. + // Empty ink reports {-1, -1} so a completely erased glyph never matches a + // real reference extent. + static QPair<int, int> inkExtent(const QVector<QPoint>& ink) + { + if (ink.isEmpty()) { + return {-1, -1}; + } + int top = ink.first().y(); + int bottom = top; + for (const QPoint& point : ink) { + top = qMin(top, point.y()); + bottom = qMax(bottom, point.y()); + } + return {top, bottom}; + } + + static QString describeExtent(const QPair<int, int>& extent) { return qsl("rows %1..%2").arg(extent.first).arg(extent.second); } + + void applyFont(Host* host, const QString& family, int size) + { + QFont font(family, size); + font.setFixedPitch(true); + QVERIFY2(QFontInfo(font).family() == family, qPrintable(qsl("Qt substituted '%1' for the requested '%2', so this would measure the wrong glyph").arg(QFontInfo(font).family(), family))); + const auto result = host->setDisplayFont(font); + QVERIFY2(result.first, qPrintable(qsl("Could not set the display font to %1 %2pt: %3").arg(family).arg(size).arg(result.second))); + QApplication::processEvents(); + } + + // Prints a line of underscores followed by a filler line dressed up as the + // given underlay, repaints, and returns the ink of the underscore line. + QVector<QPoint> renderAndCollectInk(Host* host, const Underlay& underlay) + { + TTextEdit* pane = host->mpConsole->mUpperPane; + runLua(host, qsl("clearWindow()")); + runLua(host, qsl("cecho('<white>%1\\n')").arg(QString(kUnderscoreCount, QLatin1Char('_')))); + runLua(host, qsl("cecho('%1%2\\n')").arg(underlay.colourTag, QString(kFillerCount, QLatin1Char(' ')))); + + const int underscoreLine = findLine(host, QString(kUnderscoreCount, QLatin1Char('_'))); + if (underscoreLine < 0) { + return {}; + } + if (underlay.selected) { + if (underscoreLine + 1 >= static_cast<int>(host->mpConsole->buffer.buffer.size())) { + return {}; + } + auto& below = host->mpConsole->buffer.buffer.at(underscoreLine + 1); + for (TChar& character : below) { + character.select(); + } + } + pane->forceUpdate(); + QApplication::processEvents(); + + const QImage rendered = renderPane(host); + const int cellHeight = cellHeightOf(pane); + return collectInk(rendered, (underscoreLine - pane->imageTopLine()) * cellHeight, cellHeight, cellWidthOf(pane)); + } + + static int findLine(Host* host, const QString& text) + { + TBuffer& buffer = host->mpConsole->buffer; + for (int i = 0; i <= buffer.getLastLineNumber(); ++i) { + if (buffer.line(i) == text) { + return i; + } + } + return -1; + } + + // Every pixel of the underscore run that differs from what its own pixel row + // looks like away from the glyphs, as offsets from the cell's top left. + // Reaches a few rows past the bottom of the cell so overflow is included. + // That only avoids picking up the line below because every caller leaves it + // blank or puts underscores on it, whose ink sits at the bottom of a cell. + static QVector<QPoint> collectInk(const QImage& image, int cellTop, int cellHeight, int cellWidth) + { + QVector<QPoint> ink; + const int sampleX = kBackgroundSampleColumn * cellWidth + cellWidth / 2; + if (sampleX >= image.width() || cellTop < 0) { + return ink; + } + const int lastX = qMin(kUnderscoreCount * cellWidth, image.width()) - 1; + const int lastY = qMin(cellTop + cellHeight + kOverflowScanRows, image.height()) - 1; + for (int y = cellTop; y <= lastY; ++y) { + const QRgb background = image.pixel(sampleX, y); + for (int x = 0; x <= lastX; ++x) { + if (pixelIsInk(image.pixel(x, y), background)) { + ink.append(QPoint(x, y - cellTop)); + } + } + } + return ink; + } + + // Where the ink of a run of graphemes starts and ends relative to the top of + // its cell, drawn cell by cell the way TTextEdit::paintGraphemeForeground() + // draws it. The whole run is rendered rather than a single glyph because + // neighbouring cells' antialiasing overlaps at the cell boundaries, which + // moves the faintest row of the ink. + static QPair<int, int> referenceInkExtent(const QFont& font, const QString& grapheme, int cellWidth, int cellHeight) + { + QImage image(kUnderscoreCount * cellWidth, cellHeight * 3, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::black); + QPainter painter(&image); + painter.setFont(font); + painter.setPen(Qt::white); + for (int cell = 0; cell < kUnderscoreCount; ++cell) { + painter.drawText(QRect(cell * cellWidth, cellHeight, cellWidth, cellHeight), Qt::AlignCenter | Qt::TextDontClip | Qt::TextSingleLine, grapheme); + } + painter.end(); + + int top = -1; + int bottom = -1; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (pixelIsInk(image.pixel(x, y), qRgb(0, 0, 0))) { + if (top < 0) { + top = y; + } + bottom = y; + break; + } + } + } + return {top - cellHeight, bottom - cellHeight}; + } + + void runLua(Host* host, const QString& script) { QVERIFY2(host->getLuaInterpreter()->compileAndExecuteScript(script), qPrintable(qsl("Lua script failed: %1").arg(script))); } + + Host* startOfflineProfile() + { + startProfile(mHostname, mLocalhost, mPort); + auto* host = mudlet::self()->getActiveHost(); + if (!host) { + return nullptr; + } + host->mEchoLuaErrors = true; + + mudlet::self()->resize(1400, 900); + QApplication::processEvents(); + + // cecho() into a live connection would race with the stub's traffic + host->mTelnet.disconnectIt(); + if (!QTest::qWaitFor( + [host]() { + return host->mTelnet.getConnectionState() == QAbstractSocket::UnconnectedState; + }, + 5000)) { + qWarning() << "Profile did not go offline in time; stub traffic may interleave with the printed lines"; + } + return host; + } + + // Starts a profile the way a user would via the GUI (mirrors the helper in + // TelnetTextDisplayedTest). + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + if (!mudlet::self()->getActiveHost()) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mudlet::self()->getActiveHost()->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + // Drag-selects whole rows, which is the only way in from outside the class. + static void selectRows(TTextEdit* pane, int firstRow, int lastRow, int cellHeight, int cellWidth) + { + auto send = [pane](QEvent::Type type, Qt::MouseButton button, Qt::MouseButtons buttons, const QPointF& pos) { + QMouseEvent event(type, pos, pane->mapToGlobal(pos.toPoint()), button, buttons, Qt::NoModifier); + QApplication::sendEvent(pane, &event); + }; + const QPointF start(2, firstRow * cellHeight + 2); + const QPointF end(kUnderscoreCount * cellWidth - 2, lastRow * cellHeight + cellHeight / 2); + send(QEvent::MouseButtonPress, Qt::LeftButton, Qt::LeftButton, start); + send(QEvent::MouseMove, Qt::NoButton, Qt::LeftButton, end); + send(QEvent::MouseButtonRelease, Qt::LeftButton, Qt::NoButton, end); + QApplication::processEvents(); + } + + void deleteProfileDirectory(const QString& profileName) { deleteDirectory(mudlet::getMudletPath(enums::profileHomePath, profileName)); } + + void deleteDirectory(const QString& path) + { + QDir dir(path); + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "GlyphOverflowTest.moc" +QTEST_MAIN(GlyphOverflowTest) diff --git a/test/functional_tests/HostChildTeardownTest.cpp b/test/functional_tests/HostChildTeardownTest.cpp new file mode 100644 index 000000000..973ae9611 --- /dev/null +++ b/test/functional_tests/HostChildTeardownTest.cpp @@ -0,0 +1,347 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * The notepad, the IRC client and the toolbars an action puts on the main + * window are created without a Host parent, so nothing disposes of them along + * with the profile unless the teardown does it by hand. Each test takes one of + * the three orderings a Host goes away in and asserts the same thing: once the + * Host is gone, so are its windows. The QPointers make a leak provable in any + * build; an AddressSanitizer build additionally catches a double delete. + * + * Run with: ctest -R HostChildTeardownTest -V + */ + +#include <QtTest/QtTest> +#include <chrono> + +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonObject> +#include <QPlainTextEdit> + +#include "ActionUnit.h" +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TAction.h" +#include "TMainConsole.h" +#include "TToolBar.h" +#include "TelnetServerStub.h" +#include "dlgConnectionProfiles.h" +#include "dlgIRC.h" +#include "dlgNotepad.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForHostChildTeardownTest(); + +class HostChildTeardownTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + QString mPort; + const QString mLocalhost = qsl("localhost"); + + void deleteProfileDirectory(const QString& profileName) + { + QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName)); + if (dir.exists()) { + dir.removeRecursively(); + } + } + + Host* startProfile(const QString& profileName) + { + deleteProfileDirectory(profileName); + + const QString address = mLocalhost; + const QString port = mPort; + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), profileName); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + return nullptr; + } + return mudlet::self()->getActiveHost(); + } + + // A root action set to be a floating toolbar is what puts a TToolBar on the + // main window, once the unit is asked to regenerate its toolbars. + void createToolBarAction(Host* pHost, const QString& name) + { + auto* pAction = new TAction(name, pHost); + pAction->setCommandButtonUp(QString()); + pAction->setCommandButtonDown(QString()); + pAction->setIsPushDownButton(false); + pAction->setIsFolder(true); + pAction->mLocation = 4; // floating/dockable toolbar + pAction->mOrientation = 1; + pAction->setScript(QString()); + pAction->setIsActive(true); + pAction->registerAction(); + } + + struct OpenWindows + { + QPointer<dlgNotepad> notePad; + QPointer<dlgIRC> dlgIrc; + // two of them, so that the loop in ~Host() is made to iterate + QList<QPointer<TToolBar>> toolBars; + }; + + OpenWindows openEveryChildWindow(Host* pHost) + { + OpenWindows windows; + + mudlet::self()->slot_notes(); + windows.notePad = pHost->mpNotePad; + if (windows.notePad) { + if (auto* note = qobject_cast<QPlainTextEdit*>(windows.notePad->tabWidget->widget(0))) { + note->setPlainText(csmNoteText); + } + } + + // built directly rather than through openIrc(), which would connect to + // the network + pHost->mpDlgIRC = new dlgIRC(pHost); + pHost->mpDlgIRC->show(); + windows.dlgIrc = pHost->mpDlgIRC; + + createToolBarAction(pHost, qsl("HostChildTeardown toolbar")); + createToolBarAction(pHost, qsl("HostChildTeardown second toolbar")); + pHost->getActionUnit()->updateAllToolbars(); + for (const auto& pToolBar : pHost->getActionUnit()->getToolBarList()) { + windows.toolBars.append(pToolBar); + } + return windows; + } + + static QStringList windowsLeftBehind(const OpenWindows& windows) + { + QStringList leftBehind; + if (windows.notePad) { + leftBehind << qsl("the notepad"); + } + if (windows.dlgIrc) { + leftBehind << qsl("the IRC client"); + } + for (const auto& pToolBar : windows.toolBars) { + if (pToolBar) { + leftBehind << qsl("a toolbar"); + } + } + return leftBehind; + } + + static bool everyWindowWasOpened(const OpenWindows& windows) { return windows.notePad && windows.dlgIrc && windows.toolBars.size() == 2 && windows.toolBars.at(0) && windows.toolBars.at(1); } + + static inline const QString csmNoteText = qsl("HostChildTeardown note text"); + + QString noteContentOnDisk(const QString& profileName) const + { + QFile file(mudlet::getMudletPath(enums::profileDataItemPath, profileName, qsl("notes.json"))); + if (!file.open(QIODevice::ReadOnly)) { + return QString(); + } + const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + const QJsonArray tabs = root.value(qsl("tabs")).toArray(); + if (tabs.isEmpty()) { + return QString(); + } + return tabs.at(0).toObject().value(qsl("content")).toString(); + } + + OpenWindows mWindowsLeftOpenAtTheEnd; + bool mLeftOpenProfileWasSetUp = false; + const QString mProfileLeftOpenAtTheEnd = qsl("HostChildTeardown-LeftOpen"); + +private slots: + void initTestCase() + { + initializeQRCResourcesForHostChildTeardownTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + // a stub that failed to bind only warns, and every test would then + // report the profile as slow to load instead + QVERIFY2(mpServer->serverPort() != 0, "The telnet stub did not start listening"); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator"))); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + } + + // A test that stops at a failed assertion leaves its Host in the pool, and + // getActiveHost() could then hand the next test the wrong one. The profile + // the last test leaves open on purpose is deliberately not named here. + void cleanup() + { + for (const QString& profileName : {qsl("HostChildTeardown-NoCloseChildren"), qsl("HostChildTeardown-CloseChildren")}) { + if (mudlet::self()->getHostManager().getHost(profileName)) { + mudlet::self()->getHostManager().deleteHost(profileName); + } + deleteProfileDirectory(profileName); + } + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + + // getMudletPath() reads the main window, so the path has to be taken + // while there still is one + const QString leftOpenProfilePath = mudlet::getMudletPath(enums::profileHomePath, mProfileLeftOpenAtTheEnd); + + // The third ordering: a profile still loaded when the main window goes, + // so the Host is destroyed with no close of any kind asked for. + QVERIFY2(mLeftOpenProfileWasSetUp, "The profile this checks on was never opened, so the check below would pass on three null pointers"); + delete mudlet::self(); + // Only the notepad and the IRC client carry weight here: the toolbars + // are children of the main window, so ~QWidget frees them either way. + const QStringList leftBehind = windowsLeftBehind(mWindowsLeftOpenAtTheEnd); + QVERIFY2(leftBehind.isEmpty(), qPrintable(qsl("Destroying the main window left %1 of the profile that was still loaded behind").arg(leftBehind.join(qsl(" and "))))); + + QDir(leftOpenProfilePath).removeRecursively(); + } + + // Nothing calls closeChildren(): the host pool simply lets go of the Host. + // Mudlet reaches this whenever the profile's main console has already gone, + // as Host::requestClose() then returns before it gets to closeChildren(). + void test_destroyingTheHostTakesItsWindowsWithIt() + { + const QString profileName = qsl("HostChildTeardown-NoCloseChildren"); + Host* pHost = startProfile(profileName); + QVERIFY2(pHost, "Profile took too long to load"); + + const OpenWindows windows = openEveryChildWindow(pHost); + QVERIFY2(everyWindowWasOpened(windows), "Not all of the profile's windows were opened"); + + // forceClose() stops TMainConsole::closeEvent() asking whether to save, + // which would block on a modal dialog here + pHost->forceClose(); + pHost->mpConsole->close(); + QTRY_VERIFY2(pHost->mpConsole.isNull(), "The main console did not go away"); // Qt 6 disposes of a WA_DeleteOnClose widget by deleteLater() + QVERIFY2(pHost->requestClose(), "Closing the profile was refused"); + QVERIFY2(pHost->mpNotePad, "requestClose() reached closeChildren() after all - this no longer tests a Host that skips it"); + + const QPointer<Host> hostGuard(pHost); + pHost = nullptr; + mudlet::self()->getHostManager().deleteHost(profileName); + QVERIFY2(hostGuard.isNull(), "The Host outlived deleteHost(), so ~Host() never ran"); + + const QStringList leftBehind = windowsLeftBehind(windows); + QVERIFY2(leftBehind.isEmpty(), qPrintable(qsl("Destroying the Host left %1 behind").arg(leftBehind.join(qsl(" and "))))); + // only reaches the disk if ~Host() closed the notepad rather than just + // deleting it + QCOMPARE(noteContentOnDisk(profileName), csmNoteText); + + deleteProfileDirectory(profileName); + } + + // closeChildren() disposes of these windows through deleteLater(), and + // mudlet::closeEvent() destroys the Host in the same call stack, so ~Host() + // meets windows whose deferred delete has not run yet. + void test_closeChildrenFollowedByDestructionIsSafe() + { + const QString profileName = qsl("HostChildTeardown-CloseChildren"); + Host* pHost = startProfile(profileName); + QVERIFY2(pHost, "Profile took too long to load"); + + const OpenWindows windows = openEveryChildWindow(pHost); + QVERIFY2(everyWindowWasOpened(windows), "Not all of the profile's windows were opened"); + + pHost->forceClose(); + QVERIFY2(pHost->requestClose(), "Closing the profile was refused"); + QVERIFY2(windows.notePad, "The notepad was disposed of before this could test what happens when it has not been"); + // deliberately no event loop turn before this: mudlet::closeEvent() does + // not give one either, which is what leaves the deferred deletes pending + const QPointer<Host> hostGuard(pHost); + pHost = nullptr; + mudlet::self()->getHostManager().deleteHost(profileName); + QVERIFY2(hostGuard.isNull(), "The Host outlived deleteHost(), so ~Host() never ran"); + + // checked before the event loop gets a turn, so that the deletes + // closeChildren() deferred cannot be what satisfies it + for (const auto& pToolBar : windows.toolBars) { + QVERIFY2(pToolBar.isNull(), "Destroying the Host did not delete a toolbar closeChildren() had only queued"); + } + + // letting what closeChildren() deferred run is where a second delete of + // anything ~Host() already took would land + QTRY_VERIFY2(windowsLeftBehind(windows).isEmpty(), "Closing and then destroying the Host left one of its windows behind"); + deleteProfileDirectory(profileName); + } + + // cleanupTestCase() is what destroys the main window on top of it. + void test_leaveAProfileOpenForTheMainWindowToTakeDown() + { + Host* pHost = startProfile(mProfileLeftOpenAtTheEnd); + QVERIFY2(pHost, "Profile took too long to load"); + + mWindowsLeftOpenAtTheEnd = openEveryChildWindow(pHost); + QVERIFY2(everyWindowWasOpened(mWindowsLeftOpenAtTheEnd), "Not all of the profile's windows were opened"); + mLeftOpenProfileWasSetUp = true; + } +}; + +void initializeQRCResourcesForHostChildTeardownTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "HostChildTeardownTest.moc" +QTEST_MAIN(HostChildTeardownTest) diff --git a/test/functional_tests/HostWidgetDecouplingTest.cpp b/test/functional_tests/HostWidgetDecouplingTest.cpp new file mode 100644 index 000000000..a256495f4 --- /dev/null +++ b/test/functional_tests/HostWidgetDecouplingTest.cpp @@ -0,0 +1,387 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QtTest/QtTest> + +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" +#include "utils.h" + +#include <QDialog> +#include <QDockWidget> +#include <QLabel> +#include <QTemporaryDir> + +#include <zip.h> + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForHostWidgetDecoupling(); + +using namespace std::chrono_literals; + +// Exercises the widget-free seams introduced when Host was de-widgeted: the +// dockable map widget is now created and owned by the profile's main console +// (TMainConsole), and the mapping-script reminder and package-unpacking dialogs +// are shown by the frontend in response to Host signals carrying already +// translated strings. These tests verify that ownership moved and that the +// signals drive the frontend widgets as expected. +class HostWidgetDecouplingTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mHostname = "Test-Host-Widget-Decoupling"; + const QString mLocalhost = "localhost"; + QString mPort; + +private slots: + void initTestCase() { initializeQRCResourcesForHostWidgetDecoupling(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + // Bind an ephemeral OS-assigned port so parallel test runs (e.g. across + // git worktrees) do not collide on a shared fixed port. + mpServer->start(mLocalhost, 0); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + } + + // The dockable map widget used to be a QDockWidget member of Host; it now + // lives on (and is owned by) the profile's TMainConsole. Creating the mapper + // must populate that console-owned pointer. + void test_dockableMapperOwnedByConsole() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + + QVERIFY2(!host->mpConsole->mpDockableMapWidget, "A fresh profile must not have a dockable map widget yet."); + + host->showHideOrCreateMapper(true); + + QVERIFY2(host->mpConsole->mpDockableMapWidget, "Creating the mapper must give the console a dockable map widget it owns."); + QCOMPARE(host->mpConsole->mpDockableMapWidget->objectName(), qsl("dockMap_%1").arg(host->getName())); + } + + // setMapperTitle is still a Host-facing (Lua) call, but it now drives the + // console-owned dock: it must fail when there is no dock and set the window + // title on the console's dock once one exists. + void test_setMapperTitleDrivesConsoleDock() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + + auto [okWithoutDock, messageWithoutDock] = host->setMapperTitle(qsl("anything")); + QVERIFY2(!okWithoutDock, "setMapperTitle must fail when there is no dockable map widget."); + + host->showHideOrCreateMapper(true); + QVERIFY2(host->mpConsole->mpDockableMapWidget, "The mapper dock was not created."); + + auto [okWithDock, messageWithDock] = host->setMapperTitle(qsl("Custom map title")); + QVERIFY2(okWithDock, qPrintable(messageWithDock)); + QCOMPARE(host->mpConsole->mpDockableMapWidget->windowTitle(), qsl("Custom map title")); + } + + // The mapping-script reminder used to be a QDialog built inside Host; it is + // now shown by the frontend in response to signal_showMapperScriptReminder(). + // Verify the frontend handler actually raises a dialog parented on the main + // window. (Whether Host emits the signal depends on the profile's script + // state, which is Host-side logic unchanged by this refactor, so we drive + // the handler directly here.) + void test_mappingScriptReminderShownByConsole() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + + const int dialogsBefore = mudlet::self()->findChildren<QDialog*>().count(); + host->mpConsole->showMapperScriptReminder(); + const int dialogsAfter = mudlet::self()->findChildren<QDialog*>().count(); + QVERIFY2(dialogsAfter > dialogsBefore, "showMapperScriptReminder must raise a reminder dialog owned by the main window."); + } + + // The package-unpacking progress dialog is now owned by the console and + // shown from Host's signal payload. A second show must replace (not stack + // on top of) the first, and closing must dispose of it. + void test_unpackingProgressDialogReplacedAndClosed() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + + auto console = host->mpConsole; + QVERIFY2(!console->mpUnpackingDialog, "There must be no unpacking dialog before one is requested."); + + console->showUnpackingProgress(qsl("Unpacking package:\n\"first\"\nplease wait..."), qsl("Unpacking")); + QVERIFY2(console->mpUnpackingDialog, "showUnpackingProgress must create a dialog."); + QCOMPARE(console->mpUnpackingDialog->windowTitle(), qsl("Unpacking")); + if (auto* pLabel = console->mpUnpackingDialog->findChild<QLabel*>(qsl("label"))) { + QVERIFY2(pLabel->text().contains(qsl("first")), "The dialog label did not carry the message payload."); + } + + // Track the first dialog: a replacement must dispose of it, not leak it + // (the dialog is parentless, so nothing else would ever delete it). + QPointer<QDialog> firstDialog = console->mpUnpackingDialog; + console->showUnpackingProgress(qsl("Unpacking package:\n\"second\"\nplease wait..."), qsl("Unpacking")); + QVERIFY2(console->mpUnpackingDialog, "A replacement unpacking dialog must exist."); + QVERIFY2(console->mpUnpackingDialog != firstDialog, "The replacement must be a distinct dialog."); + if (auto* pLabel = console->mpUnpackingDialog->findChild<QLabel*>(qsl("label"))) { + QVERIFY2(pLabel->text().contains(qsl("second")), "The replacement dialog did not carry the new message payload."); + } + QTest::qWait(50ms); // let the replaced dialog's queued deleteLater() run + QVERIFY2(!firstDialog, "Replacing the unpacking dialog must dispose of the previous one, not leak it."); + + console->closeUnpackingProgress(); + QTest::qWait(50ms); + QVERIFY2(!console->mpUnpackingDialog, "closeUnpackingProgress must dispose of the dialog."); + } + + // Regression guard: showUnpackingProgress() spins the event loop via + // processEvents(). A deferred install completion can deliver a re-entrant + // close (or a second show) during that spin, disposing of the dialog and + // clearing mpUnpackingDialog. The frame must not then dereference the member. + // Before the fix it did (mpUnpackingDialog->raise() on a nulled member) and + // crashed; now it drives a local pointer, so reaching this test's end without + // a crash is the assertion. + void test_reentrantUnpackingProgressDoesNotCrash() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + auto console = host->mpConsole; + + // Queue a re-entrant close to fire while showUnpackingProgress() is inside + // its first processEvents(), mimicking a deferred install completion. + QMetaObject::invokeMethod( + qApp, + [console]() { + console->closeUnpackingProgress(); + }, + Qt::QueuedConnection); + + console->showUnpackingProgress(qsl("Unpacking package:\n\"reentrant\"\nplease wait..."), qsl("Unpacking")); + + QTest::qWait(50ms); + QVERIFY2(!console->mpUnpackingDialog, "The re-entrant close should have left no unpacking dialog behind."); + } + + // The tests above drive the console's handlers directly, so they would all + // still pass if the Host -> console connections made in + // mudlet::addConsoleForNewHost() were lost (that function is a merge-conflict + // hot spot). This one installs a real package instead, so the show and hide + // signals have to travel the production wiring to reach the dialog. + void test_unpackingDialogDrivenByInstall() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + auto console = host->mpConsole; + + QTemporaryDir packageDir; + QVERIFY2(packageDir.isValid(), "Could not create a temporary directory for the test package."); + const QString packageName = qsl("HostWidgetDecouplingPackage"); + const QString packagePath = packageDir.filePath(qsl("%1.zip").arg(packageName)); + QVERIFY2(writePackageArchive(packagePath, packageName), "Could not write the test package archive."); + + // installPackage() postpones the whole install (and so emits nothing) if a + // profile save is still in flight from loading the profile. + QTRY_VERIFY(!host->currentlySavingProfile()); + + QSignalSpy showSpy(host, &Host::signal_showUnpackingProgress); + QSignalSpy hideSpy(host, &Host::signal_hideUnpackingProgress); + + // Connected after the console's own handler, so it observes the dialog + // that handler has just put up - if the wiring is intact. + QObject captureContext; + bool dialogUpWhileUnpacking = false; + QPointer<QDialog> dialogWhileUnpacking; + connect(host, &Host::signal_showUnpackingProgress, &captureContext, [&](const QString&, const QString&) { + dialogWhileUnpacking = console->mpUnpackingDialog; + dialogUpWhileUnpacking = !dialogWhileUnpacking.isNull(); + }); + + auto [ok, message] = host->installPackage(packagePath, enums::PackageModuleType::Package, false); + QVERIFY2(ok, qPrintable(message)); + + QCOMPARE(showSpy.count(), 1); + QCOMPARE(hideSpy.count(), 1); + QVERIFY2(dialogUpWhileUnpacking, "Installing a package must put the unpacking dialog up via the Host signal."); + QVERIFY2(!console->mpUnpackingDialog, "Finishing the install must take the unpacking dialog down again."); + QTest::qWait(50ms); // let the dialog's queued deleteLater() run + QVERIFY2(dialogWhileUnpacking.isNull(), "The unpacking dialog was taken down but never disposed of."); + } + + // The map dock moved from Host to TMainConsole, so disposing of it is now the + // console destructor's job. addDockWidget() reparents the dock onto the main + // window, which outlives the profile, so nothing else would clean it up. + void test_mapDockDestroyedOnProfileClose() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + + host->showHideOrCreateMapper(true); + QPointer<QDockWidget> dock = host->mpConsole->mpDockableMapWidget; + QVERIFY2(dock, "The mapper dock was not created."); + + // Forcing the close stops TMainConsole::closeEvent() asking whether the + // profile should be saved, which would block on a modal dialog here. + // requestClose() is the half of the profile-close path that disposes of + // the console; the mudlet::closeHost() that normally follows it only + // removes the tab and the Host, and would reopen the connection dialog + // as the last profile went away. + host->forceClose(); + QVERIFY2(host->requestClose(), "Closing the profile was refused."); + + // Two chained deferred deletes to get through: the console (it carries + // WA_DeleteOnClose) and then, from its destructor, the dock. + QTest::qWait(500ms); + QVERIFY2(dock.isNull(), "Closing the profile must destroy the map dock the console owns."); + } + + void cleanup() + { + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + + // Utility function to manually start a profile like a user would do via the + // GUI + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5s)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2s)) { + QFAIL("Could not connect with the host."); + } + } + + // Utility function producing the smallest package archive that installs: a + // zip holding one Mudlet package XML with nothing in it. An archive with no + // package XML at all is refused (it would install nowhere and could never be + // uninstalled), so the dialog wiring this test is about needs a real one. + bool writePackageArchive(const QString& path, const QString& packageName) + { + static const char packageXml[] = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<!DOCTYPE MudletPackage>\n" + "<MudletPackage version=\"1.001\">\n" + "<TriggerPackage /><TimerPackage /><AliasPackage /><ActionPackage />\n" + "<ScriptPackage /><KeyPackage /><VariablePackage><HiddenVariables /></VariablePackage>\n" + "</MudletPackage>\n"; + + int errorCode = 0; + zip* archive = zip_open(path.toUtf8().constData(), ZIP_CREATE | ZIP_TRUNCATE, &errorCode); + if (!archive) { + return false; + } + // sizeof - 1 to leave the terminating null out of the archived file + zip_source* source = zip_source_buffer(archive, packageXml, sizeof(packageXml) - 1, 0); + if (!source || zip_file_add(archive, qsl("%1.xml").arg(packageName).toUtf8().constData(), source, ZIP_FL_ENC_UTF_8) < 0) { + zip_source_free(source); + zip_discard(archive); + return false; + } + return zip_close(archive) == 0; + } + + // Utility function + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + + if (!dir.exists()) { + qInfo() << "Profile directory does not exist:" << path; + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResourcesForHostWidgetDecoupling() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "HostWidgetDecouplingTest.moc" +QTEST_MAIN(HostWidgetDecouplingTest) diff --git a/test/functional_tests/InsertTextCapTest.cpp b/test/functional_tests/InsertTextCapTest.cpp new file mode 100644 index 000000000..404a10ed1 --- /dev/null +++ b/test/functional_tests/InsertTextCapTest.cpp @@ -0,0 +1,225 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Makers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QtTest/QtTest> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResources(); + +// Regression test: a single insertText() into the middle of an existing line +// (the TBuffer::insertInLine() path) must apply the same per-echo character +// cap that the echo/append path enforces, so an oversized insert cannot grow a +// line without bound. +class InsertTextCapTest : public QObject +{ + Q_OBJECT + + // Reference the production constant directly to avoid drift. + static constexpr int kMaxCharactersPerEcho = TBuffer::MAX_CHARACTERS_PER_ECHO; + +private: + TelnetServerStub* mpServer = nullptr; + const QString mpHostname = "Test-InsertCap"; + QString mpPort; // assigned the stub's actual ephemeral port in init() + const QString mpLocalhost = "localhost"; + +private slots: + void initTestCase() { initializeQRCResources(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + mpServer->start(mpLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mpPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mpHostname); + } + + // Inserting an over-long string into the middle of a line must cap the + // inserted run at kMaxCharactersPerEcho, matching the echo/append path. + void test_oversizedInsertIsCapped() + { + mpServer->setWelcomeMessage(QStringLiteral("HELLO\r\n")); + startProfile(mpHostname, mpLocalhost, mpPort); + QVERIFY2(waitForTextInBuffer(QStringLiteral("HELLO")), "Welcome text never reached the buffer"); + + auto console = mudlet::self()->getActiveHost()->mpConsole; + + // Position the user cursor in the middle of the "HELLO" line so that + // insertText() routes through insertInLine() rather than the (already + // capped) append path used when the cursor sits at the buffer end. + QVERIFY2(console->moveCursor(2, 0), "Could not position the user cursor mid-line"); + + const int originalLength = console->buffer.line(0).size(); + QVERIFY2(originalLength > 2, "Unexpected welcome line contents"); + + const int overshoot = 500; + const QString oversized(kMaxCharactersPerEcho + overshoot, QLatin1Char('Z')); + console->insertText(oversized); + + const int newLength = console->buffer.line(0).size(); + const int insertedLength = newLength - originalLength; + + // Without the cap the full oversized string is inserted, so the inserted + // run would be kMaxCharactersPerEcho + overshoot. With the cap it is + // exactly kMaxCharactersPerEcho. + QCOMPARE(insertedLength, kMaxCharactersPerEcho); + + // The character (lineBuffer) and styling (TChar deque) containers are + // filled by two separate inserts that must stay the same length, or the + // renderer reads past the end of one of them. + QCOMPARE(static_cast<int>(console->buffer.buffer.at(0).size()), newLength); + } + + // A normally-sized insert must be inserted in full (guards against the cap + // being applied too aggressively). + void test_normalInsertIsUntouched() + { + mpServer->setWelcomeMessage(QStringLiteral("HELLO\r\n")); + startProfile(mpHostname, mpLocalhost, mpPort); + QVERIFY2(waitForTextInBuffer(QStringLiteral("HELLO")), "Welcome text never reached the buffer"); + + auto console = mudlet::self()->getActiveHost()->mpConsole; + QVERIFY2(console->moveCursor(2, 0), "Could not position the user cursor mid-line"); + + const QString original = console->buffer.line(0); + const QString payload = QStringLiteral("insertedText"); + console->insertText(payload); + + // The run must be spliced in at the cursor (x = 2) without disturbing the + // surrounding characters - the batched insert must match the old + // per-character insertion exactly, not just in length. + const QString expected = original.left(2) + payload + original.mid(2); + QCOMPARE(console->buffer.line(0), expected); + QCOMPARE(static_cast<int>(console->buffer.buffer.at(0).size()), expected.size()); + } + + void cleanup() + { + const QString profilePath = mudlet::getMudletPath(enums::profileHomePath, mpHostname); + + // Tear down Mudlet (and with it the live cTelnet connection) before the + // stub server it is talking to, so the socket is closed from the client + // side rather than being yanked out from under an active connection. + delete mudlet::self(); + delete mpServer; + mpServer = nullptr; + deleteDirectory(profilePath); + } + +private: + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + bool waitForTextInBuffer(const QString& text, int timeoutMs = 5000) + { + auto console = mudlet::self()->getActiveHost()->mpConsole; + return QTest::qWaitFor( + [&]() { + for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) { + if (console->buffer.line(i) == text) { + return true; + } + } + return false; + }, + timeoutMs); + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + deleteDirectory(path); + } + + void deleteDirectory(const QString& path) + { + QDir dir(path); + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "InsertTextCapTest.moc" +QTEST_MAIN(InsertTextCapTest) diff --git a/test/functional_tests/MapCloseDuringImportTest.cpp b/test/functional_tests/MapCloseDuringImportTest.cpp new file mode 100644 index 000000000..968ebdd23 --- /dev/null +++ b/test/functional_tests/MapCloseDuringImportTest.cpp @@ -0,0 +1,248 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression guard for #9520: closing a profile while its map was being + * imported or exported used to free the TMap while its own loop was still + * running. + * + * Mudlet is single threaded, so nothing here is a race. The interleaving is + * re-entrancy: TMap::readJsonMapFile() and TMap::writeJsonMapFile() call + * qApp->processEvents() once per area to keep their progress display alive and + * its Abort button clickable, and that pump delivers whatever else the event + * loop is holding - including the zero-millisecond timer that + * mudlet::slot_closeProfileByName() posts to run mudlet::closeHost(). That call + * takes the profile's QSharedPointer<Host> out of the host pool, which destroys + * the Host and, with it, the TMap whose loop is still on the stack. Everything + * the reader touches after that is freed memory. + * + * These tests stage exactly that, through the same public slot the tab close + * and closeProfile() use, and let the operation's own pump deliver the timer. A + * QPointer to the map is how they tell: it goes null the moment the TMap is + * destroyed, so the failure is reported rather than left to whatever the freed + * memory happens to hold. Without the fix that assertion fails - and under ASan + * the run additionally reports the use-after-free that follows it. + * + * Run with: ctest -R MapCloseDuringImportTest -V + */ + +#include <QtTest/QtTest> + +#include <QDeadlineTimer> +#include <QPointer> +#include <QTemporaryDir> + +#include <chrono> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TMap.h" +#include "TRoomDB.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForMapCloseDuringImportTest(); + +class MapCloseDuringImportTest : public QObject +{ + Q_OBJECT + +private: + const QString mSourceName = qsl("MapCloseDuringImportSource-Test"); + // A name of its own per test: a test that fails part way through can leave + // its deferred close pending on a timer, and a later test reusing the name + // would have that close land on its profile instead. + const QString mImportTargetName = qsl("MapCloseDuringImportTarget-Test"); + const QString mExportTargetName = qsl("MapCloseDuringExportTarget-Test"); + QTemporaryDir mConfigDir; + QTemporaryDir mSaveDir; + QByteArray mSavedXdg; + QString mMapFile; + + // Enough areas that the operation pumps the event loop many times over: the + // progress increment that delivers the close is reached once per area. + static constexpr int areaCount = 40; + + void buildMap(Host* pHost) + { + TMap* pMap = pHost->mpMap.data(); + TRoomDB* pDB = pMap->mpRoomDB.get(); + int roomId = 1; + for (int area = 0; area < areaCount; ++area) { + const int areaId = pDB->addArea(qsl("Area %1").arg(area)); + QVERIFY(areaId > 0); + for (int room = 0; room < 5; ++room, ++roomId) { + QVERIFY(pMap->addRoom(roomId)); + QVERIFY(pMap->setRoomArea(roomId, areaId, false)); + QVERIFY(pMap->setRoomCoordinates(roomId, room, area, 0)); + } + } + } + + Host* addProfile(const QString& name) + { + auto& hostManager = mudlet::self()->getHostManager(); + if (!hostManager.addHost(name, qsl("23"), QString(), QString())) { + return nullptr; + } + return hostManager.getHost(name); + } + + // Runs the event loop until the profile is gone. The close is deferred + // until the map operation has unwound, so this is where it lands. + bool waitForProfileToClose(const QString& name) + { + QDeadlineTimer deadline(10s); + while (mudlet::self()->getHostManager().getHost(name)) { + if (deadline.hasExpired()) { + return false; + } + qApp->processEvents(QEventLoop::AllEvents, 20); + } + return true; + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForMapCloseDuringImportTest(); + + QVERIFY(mConfigDir.isValid()); + QVERIFY(mSaveDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + // Kept for the whole run, so that closing the profile under test never + // leaves Mudlet with no profiles at all and popping its connection + // dialog at an offscreen test. + Host* pSource = addProfile(mSourceName); + QVERIFY2(pSource, "failed to create the source Host"); + buildMap(pSource); + if (QTest::currentTestFailed()) { + return; + } + + mMapFile = qsl("%1/close-during-import.json").arg(mSaveDir.path()); + const auto [wrote, writeMessage] = pSource->mpMap->writeJsonMapFile(mMapFile); + QVERIFY2(wrote, qPrintable(writeMessage)); + } + + void cleanupTestCase() + { + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + void test_closingTheProfileDuringAJsonImportDoesNotFreeTheMap() + { + Host* pTarget = addProfile(mImportTargetName); + QVERIFY2(pTarget, "failed to create the target Host"); + TMap* pTargetMap = pTarget->mpMap.data(); + const QPointer<TMap> mapWatch(pTargetMap); + + bool closeRequested = false; + const QMetaObject::Connection closeOnProgress = connect(pTargetMap, &TMap::signal_mapProgressSetValue, pTargetMap, [&]() { + if (closeRequested) { + return; + } + closeRequested = true; + // The same slot the tab's close button and closeProfile() use: it + // posts closeHost() as a zero-millisecond timer, which the import's + // own processEvents() then delivers with the import on the stack. + mudlet::self()->slot_closeProfileByName(mImportTargetName); + }); + const auto [read, readMessage] = pTargetMap->readJsonMapFile(mMapFile); + disconnect(closeOnProgress); + + QVERIFY2(closeRequested, "the import never announced any progress, so no close was delivered into its pump"); + QVERIFY2(!mapWatch.isNull(), "the TMap was destroyed while its own import loop was still on the stack"); + // A close asked for mid-import stops it rather than reading a whole map + // into a profile that is going away: + QVERIFY2(!read, "the import was expected to stop once the close asked it to"); + QCOMPARE(readMessage, qsl("aborted by user")); + // ...and deferring the close must not drop it: + QVERIFY2(waitForProfileToClose(mImportTargetName), "the deferred close never completed once the import had unwound"); + QVERIFY2(mapWatch.isNull(), "the TMap outlived the profile it belongs to"); + } + + // The export half of the same loop, which pumps the event loop the same way. + void test_closingTheProfileDuringAJsonExportDoesNotFreeTheMap() + { + Host* pTarget = addProfile(mExportTargetName); + QVERIFY2(pTarget, "failed to create the target Host"); + TMap* pTargetMap = pTarget->mpMap.data(); + buildMap(pTarget); + if (QTest::currentTestFailed()) { + return; + } + const QPointer<TMap> mapWatch(pTargetMap); + + bool closeRequested = false; + const QMetaObject::Connection closeOnProgress = connect(pTargetMap, &TMap::signal_mapProgressSetValue, pTargetMap, [&]() { + if (closeRequested) { + return; + } + closeRequested = true; + mudlet::self()->slot_closeProfileByName(mExportTargetName); + }); + const auto [wrote, writeMessage] = pTargetMap->writeJsonMapFile(qsl("%1/close-during-export.json").arg(mSaveDir.path())); + disconnect(closeOnProgress); + + QVERIFY2(closeRequested, "the export never announced any progress, so no close was delivered into its pump"); + QVERIFY2(!mapWatch.isNull(), "the TMap was destroyed while its own export loop was still on the stack"); + // As with the import: the close stops the operation rather than writing + // a whole map out of a profile that is going away. + QVERIFY2(!wrote, "the export was expected to stop once the close asked it to"); + QCOMPARE(writeMessage, qsl("aborted by user")); + QVERIFY2(waitForProfileToClose(mExportTargetName), "the deferred close never completed once the export had unwound"); + QVERIFY2(mapWatch.isNull(), "the TMap outlived the profile it belongs to"); + } +}; + +void initializeQRCResourcesForMapCloseDuringImportTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "MapCloseDuringImportTest.moc" +QTEST_MAIN(MapCloseDuringImportTest) diff --git a/test/functional_tests/MapProgressDialogSeamTest.cpp b/test/functional_tests/MapProgressDialogSeamTest.cpp new file mode 100644 index 000000000..f17acfbfa --- /dev/null +++ b/test/functional_tests/MapProgressDialogSeamTest.cpp @@ -0,0 +1,297 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Tests the map-progress seam introduced for the libmudlet split (#8681, + * #9011): the Qt-Widgets-free TMap no longer owns a QProgressDialog and instead + * emits pre-translated payloads for the frontend to render, while cancellation + * returns through TMap::slot_mapProgressDialogCancelled(). + * + * These tests stand in for the frontend with plain signal recorders and drive + * the engine directly, so they verify the engine half of the seam without any + * widget: + * - the download/XML transfer-progress state machine emits the right signals + * and keeps its own maximum/active state (the old QProgressDialog read-backs) + * - a JSON export and re-import announce and close their progress dialogs and + * leave no stuck "operation already in progress" state + * - a cancel delivered through the seam mid-import makes the JSON reader abort, + * the exact behaviour that used to depend on QProgressDialog::wasCanceled() + * + * Run with: ctest -R MapProgressDialogSeamTest -V + */ + +#include <QtTest/QtTest> + +#include <QSignalSpy> +#include <QTemporaryDir> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TMap.h" +#include "TRoomDB.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForMapProgressDialogSeamTest(); + +class MapProgressDialogSeamTest : public QObject +{ + Q_OBJECT + +private: + Host* mpSource = nullptr; + Host* mpTarget = nullptr; + const QString mSourceName = qsl("MapProgressSeamSource-Test"); + const QString mTargetName = qsl("MapProgressSeamTarget-Test"); + QTemporaryDir mSaveDir; + + void buildSmallMap(Host* pHost) + { + TMap* pMap = pHost->mpMap.data(); + TRoomDB* pDB = pMap->mpRoomDB.get(); + const int areaA = pDB->addArea(qsl("Area A")); + const int areaB = pDB->addArea(qsl("Area B")); + QVERIFY(areaA > 0); + QVERIFY(areaB > 0); + int id = 1; + for (const int areaId : {areaA, areaB}) { + for (int i = 0; i < 3; ++i, ++id) { + QVERIFY(pMap->addRoom(id)); + QVERIFY(pMap->setRoomArea(id, areaId, false)); + QVERIFY(pMap->setRoomCoordinates(id, i, i, 0)); + } + } + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + if (dir.exists()) { + dir.removeRecursively(); + } + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForMapProgressDialogSeamTest(); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mSourceName); + deleteProfileDirectory(mTargetName); + + QVERIFY(mSaveDir.isValid()); + + auto& hostManager = mudlet::self()->getHostManager(); + QVERIFY2(hostManager.addHost(mSourceName, qsl("23"), QString(), QString()), "failed to create the source Host"); + mpSource = hostManager.getHost(mSourceName); + QVERIFY(mpSource); + QVERIFY2(hostManager.addHost(mTargetName, qsl("23"), QString(), QString()), "failed to create the target Host"); + mpTarget = hostManager.getHost(mTargetName); + QVERIFY(mpTarget); + + buildSmallMap(mpSource); + if (QTest::currentTestFailed()) { + return; + } + } + + void cleanupTestCase() + { + mpSource = nullptr; + mpTarget = nullptr; + deleteProfileDirectory(mSourceName); + deleteProfileDirectory(mTargetName); + delete mudlet::self(); + } + + // The download/XML transfer path: with no visible mapper the engine takes + // the standalone-dialog branch, which must now be pure signals plus the + // engine-side state that replaced the QProgressDialog read-backs. + void test_transferProgressStateMachine() + { + TMap* pMap = mpSource->mpMap.data(); + QSignalSpy startSpy(pMap, &TMap::signal_mapTransferProgressStart); + QSignalSpy rangeSpy(pMap, &TMap::signal_mapProgressSetRange); + QSignalSpy valueSpy(pMap, &TMap::signal_mapProgressSetValue); + QSignalSpy labelSpy(pMap, &TMap::signal_mapProgressSetLabel); + QSignalSpy disableSpy(pMap, &TMap::signal_mapProgressDisableCancel); + QSignalSpy closeSpy(pMap, &TMap::signal_mapProgressClose); + QVERIFY(startSpy.isValid()); + + QVERIFY(!pMap->hasActiveTransferProgress()); + + pMap->createTransferProgress(qsl("A title"), qsl("A label"), true); + QCOMPARE(startSpy.count(), 1); + QCOMPARE(startSpy.at(0).at(0).toString(), qsl("A title")); + QCOMPARE(startSpy.at(0).at(1).toString(), qsl("A label")); + // cancelable == true carries the pre-translated Abort button text: + QCOMPARE(startSpy.at(0).at(2).toString(), qsl("Abort")); + QVERIFY(pMap->hasActiveTransferProgress()); + QCOMPARE(pMap->transferProgressMaximum(), 0); + + pMap->updateTransferProgressRange(0, 100); + QCOMPARE(rangeSpy.count(), 1); + QCOMPARE(rangeSpy.at(0).at(1).toInt(), 100); + // Read-back must come from the engine's cached maximum, not a widget: + QCOMPARE(pMap->transferProgressMaximum(), 100); + + pMap->updateTransferProgressValue(42); + QCOMPARE(valueSpy.count(), 1); + QCOMPARE(valueSpy.at(0).at(0).toInt(), 42); + + pMap->updateTransferProgressLabel(qsl("Working")); + QCOMPARE(labelSpy.count(), 1); + QCOMPARE(labelSpy.at(0).at(0).toString(), qsl("Working")); + + pMap->disableTransferProgressCancel(); + QCOMPARE(disableSpy.count(), 1); + + pMap->clearTransferProgress(); + QCOMPARE(closeSpy.count(), 1); + QVERIFY(!pMap->hasActiveTransferProgress()); + } + + void test_jsonExportImportDrivesProgressSignals() + { + TMap* pSourceMap = mpSource->mpMap.data(); + const QString file = qsl("%1/seam.json").arg(mSaveDir.path()); + + QSignalSpy exportStartSpy(pSourceMap, &TMap::signal_mapJsonProgressStart); + QSignalSpy exportCloseSpy(pSourceMap, &TMap::signal_mapProgressClose); + const auto [wrote, writeMsg] = pSourceMap->writeJsonMapFile(file); + QVERIFY2(wrote, qPrintable(writeMsg)); + QCOMPARE(exportStartSpy.count(), 1); + QCOMPARE(exportStartSpy.at(0).at(0).toString(), qsl("Map JSON export")); + // Exactly one close: a second would mean the dialog was torn down twice: + QCOMPARE(exportCloseSpy.count(), 1); + // The engine must not stay "in progress" (that would reject the next op): + QVERIFY(!pSourceMap->hasActiveTransferProgress()); + + TMap* pTargetMap = mpTarget->mpMap.data(); + QSignalSpy importStartSpy(pTargetMap, &TMap::signal_mapJsonProgressStart); + QSignalSpy importCloseSpy(pTargetMap, &TMap::signal_mapProgressClose); + const auto [read, readMsg] = pTargetMap->readJsonMapFile(file); + QVERIFY2(read, qPrintable(readMsg)); + QCOMPARE(importStartSpy.count(), 1); + QCOMPARE(importStartSpy.at(0).at(0).toString(), qsl("Map JSON import")); + QCOMPARE(importCloseSpy.count(), 1); + QVERIFY(!pTargetMap->hasActiveTransferProgress()); + } + + // The highest-risk seam: the JSON reader used to poll + // QProgressDialog::wasCanceled(); it now polls a flag set by + // slot_mapProgressDialogCancelled(). Acting as the frontend, deliver a + // cancel the instant the import announces its dialog and confirm the read + // aborts with the user-cancel result and clears its state. + void test_jsonImportCancellationAbortsViaSeam() + { + TMap* pSourceMap = mpSource->mpMap.data(); + const QString file = qsl("%1/cancel.json").arg(mSaveDir.path()); + // Spying on both ends of a dialog's life also stands in for a wired-up + // frontend, so the engine's "nobody is showing this" warning stays quiet: + QSignalSpy exportStartSpy(pSourceMap, &TMap::signal_mapJsonProgressStart); + QSignalSpy exportCloseSpy(pSourceMap, &TMap::signal_mapProgressClose); + const auto [wrote, writeMsg] = pSourceMap->writeJsonMapFile(file); + QVERIFY2(wrote, qPrintable(writeMsg)); + QCOMPARE(exportStartSpy.count(), 1); + QCOMPARE(exportCloseSpy.count(), 1); + + TMap* pTargetMap = mpTarget->mpMap.data(); + QSignalSpy importCloseSpy(pTargetMap, &TMap::signal_mapProgressClose); + const QMetaObject::Connection cancelOnStart = connect(pTargetMap, &TMap::signal_mapJsonProgressStart, pTargetMap, [pTargetMap]() { + pTargetMap->slot_mapProgressDialogCancelled(); + }); + const auto [read, readMsg] = pTargetMap->readJsonMapFile(file); + disconnect(cancelOnStart); + + QVERIFY(!read); + QCOMPARE(readMsg, qsl("aborted by user")); + // An aborted import must still take its progress dialog down with it: + QCOMPARE(importCloseSpy.count(), 1); + QVERIFY(!pTargetMap->hasActiveTransferProgress()); + } + + // An XML map import started while a JSON operation owns the progress dialog + // must be refused: readXmlMapFile() would otherwise mistake the JSON + // operation's dialog for its own and mapClear() the map mid-import. The + // re-entrancy is real - a Lua loadMap() from a timer lands in the + // qApp->processEvents() the JSON reader pumps. + void test_xmlImportRefusedWhileJsonOperationOwnsProgress() + { + TMap* pSourceMap = mpSource->mpMap.data(); + const QString file = qsl("%1/reentrancy.json").arg(mSaveDir.path()); + QSignalSpy exportStartSpy(pSourceMap, &TMap::signal_mapJsonProgressStart); + QSignalSpy exportCloseSpy(pSourceMap, &TMap::signal_mapProgressClose); + const auto [wrote, writeMsg] = pSourceMap->writeJsonMapFile(file); + QVERIFY2(wrote, qPrintable(writeMsg)); + QCOMPARE(exportStartSpy.count(), 1); + QCOMPARE(exportCloseSpy.count(), 1); + + TMap* pTargetMap = mpTarget->mpMap.data(); + QSignalSpy importCloseSpy(pTargetMap, &TMap::signal_mapProgressClose); + bool importAttempted = false; + bool importAccepted = true; + QString importError; + const QMetaObject::Connection reenter = connect(pTargetMap, &TMap::signal_mapJsonProgressStart, pTargetMap, [&]() { + importAttempted = true; + QFile xmlMap(qsl("%1/no-such-map.xml").arg(mSaveDir.path())); + importAccepted = pTargetMap->importMap(xmlMap, &importError); + }); + const auto [read, readMsg] = pTargetMap->readJsonMapFile(file); + disconnect(reenter); + + QVERIFY(importAttempted); + QVERIFY2(!importAccepted, "importMap() ran on top of an in-flight JSON import"); + // Refused by the in-progress guard, not by failing to read the file: + QVERIFY2(importError.contains(qsl("already in progress")), qPrintable(importError)); + // ...and the JSON operation it interrupted still completed: + QVERIFY2(read, qPrintable(readMsg)); + QCOMPARE(importCloseSpy.count(), 1); + QVERIFY(!pTargetMap->hasActiveTransferProgress()); + } +}; + +void initializeQRCResourcesForMapProgressDialogSeamTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "MapProgressDialogSeamTest.moc" +QTEST_MAIN(MapProgressDialogSeamTest) diff --git a/test/functional_tests/MapRoundTripTest.cpp b/test/functional_tests/MapRoundTripTest.cpp index 7176c9ef3..0d435ed08 100644 --- a/test/functional_tests/MapRoundTripTest.cpp +++ b/test/functional_tests/MapRoundTripTest.cpp @@ -38,6 +38,7 @@ #include <QtTest/QtTest> +#include <QFile> #include <QSaveFile> #include <QTemporaryDir> @@ -113,10 +114,6 @@ private: AreaBounds mBoundsB; QImage mLabelImage; QSizeF mLabelSize; - // Once the source map has been saved at a format below 19 its mUserData - // carries stray system.fallback_mapSymbolFont* keys forever - see the - // QEXPECT_FAIL in verifyMap(): - bool mSourcePollutedByPre19Save = false; static QMap<QString, QString> expectedMapUserData() { return {{qsl("map.author 日本語"), qsl("величина <>&\"' ]]>")}, {qsl("plain"), qsl("value")}}; } @@ -268,6 +265,23 @@ private: return file.commit(); } + // QDataStream stores QStrings as a length prefix plus the string encoded as UTF-16BE, + // so the raw file can be scanned for a serialized string's bytes: + static bool fileContainsSerializedString(const QString& fileName, const QString& needle) + { + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + const QByteArray raw = file.readAll(); + QByteArray needleBytes; + QDataStream out(&needleBytes, QIODevice::WriteOnly); + out << needle; + // The length prefix stays in the needle so a key cannot match a longer key it is + // a byte prefix of ("system.fallback_mapSymbolFont" vs. "...FontFudgeFactor"): + return raw.contains(needleBytes); + } + void verifyArea(TArea* pArea, const AreaBounds& bounds, const QString& areaLabel) { QVERIFY2(pArea, qPrintable(qsl("%1 is missing").arg(areaLabel))); @@ -290,6 +304,7 @@ private: QCOMPARE(pDB->getAreaNamesMap().value(mAreaB), scmAreaBName); TArea* pAreaA = pDB->getArea(mAreaA); + QVERIFY(pAreaA); verifyArea(pAreaA, mBoundsA, qsl("area A")); if (QTest::currentTestFailed()) { return; @@ -298,6 +313,7 @@ private: QCOMPARE(pAreaA->mUserData, expectedAreaAUserData()); TArea* pAreaB = pDB->getArea(mAreaB); + QVERIFY(pAreaB); verifyArea(pAreaB, mBoundsB, qsl("area B")); if (QTest::currentTestFailed()) { return; @@ -344,14 +360,9 @@ private: QCOMPARE(pR1->customLinesColor, (QMap<QString, QColor>{{qsl("n"), scmCustomLineColor}})); QCOMPARE(pR1->customLinesStyle, (QMap<QString, Qt::PenStyle>{{qsl("n"), Qt::DashLine}})); QCOMPARE(pR1->customLinesArrow, (QMap<QString, bool>{{qsl("n"), true}})); - if (savedVersion == 19) { - // Finding: TMap::serialize() stores the symbol fallback for - // mSaveVersion <= 19 but TRoom::restore() only removes it again - // for version < 19, so a map saved at exactly format 19 leaves a - // stray "system.fallback_symbol" entry in the room's userData - // after loading: - QEXPECT_FAIL("", "system.fallback_symbol is written at save version 19 (TMap::serialize) but only stripped for versions below 19 (TRoom::restore)", Continue); - } + // The format 19 leg runs after the format 17/18 ones, so this also + // guards against a < 19 save leaving a stray system.fallback_symbol + // entry behind in the live source room's user data: QCOMPARE(pR1->userData, expectedRoom1UserData()); TRoom* pR2 = pDB->getRoom(scmRoom2); @@ -394,17 +405,10 @@ private: QCOMPARE(pR4->getOut(), scmRoom2); QCOMPARE(pR4->getNorthwest(), scmRoom3); - if (savedVersion >= 19 && mSourcePollutedByPre19Save) { - // Finding: TMap::serialize() inserts system.fallback_mapSymbolFont, - // system.fallback_mapSymbolFontFudgeFactor and - // system.fallback_onlyUseMapSymbolFont into the live map's - // mUserData when saving at format < 19 and never removes them - // afterwards, so every subsequent save at format >= 19 embeds - // those stale keys and TMap::restore() only strips them again for - // format < 19 loads: - QEXPECT_FAIL( - "", "saving at format < 19 permanently pollutes TMap::mUserData with system.fallback_mapSymbolFont* keys (TMap::serialize) which leak into later format >= 19 saves", Continue); - } + // The format 19 leg runs after the format 17/18 ones, so this also + // guards against a < 19 save leaving stray + // system.fallback_mapSymbolFont* entries behind in the live source + // map's user data: QCOMPARE(pMap->mUserData, expectedMapUserData()); QCOMPARE(pMap->mEnvColors, (QMap<int, int>{{5, 2}, {12, 7}})); QCOMPARE(pMap->mCustomEnvColors.value(300), QColor(12, 34, 56)); @@ -428,9 +432,13 @@ private: { const QString fileName = qsl("%1/map_v%2.dat").arg(mSaveDir.path()).arg(saveVersion); QVERIFY2(saveMapToFile(mpSource->mpMap.data(), fileName, saveVersion), qPrintable(qsl("failed to save map at format version %1").arg(saveVersion))); - if (saveVersion < 19) { - mSourcePollutedByPre19Save = true; - } + + // Saving at any format must not leak system.fallback_* keys into the + // live source map's or rooms' user data - room 1 carries a symbol and + // a symbol color, room 3 is hidden: + QCOMPARE(mpSource->mpMap->mUserData, expectedMapUserData()); + QCOMPARE(mpSource->mpMap->mpRoomDB->getRoom(scmRoom1)->userData, expectedRoom1UserData()); + QVERIFY(mpSource->mpMap->mpRoomDB->getRoom(scmRoom3)->userData.isEmpty()); TMap* pTargetMap = mpTarget->mpMap.data(); pTargetMap->mapClear(); @@ -506,6 +514,52 @@ private slots: QFETCH(int, saveVersion); roundTripAtVersion(saveVersion); } + + void test_taintedMapSelfCleansOnFormat19PlusLoad() + { + // Simulate a map already tainted in the wild by past versions whose + // saving in a format below 19 left the fallback keys behind in the + // live user data - which then rode along in every format >= 19 save: + TMap* pSourceMap = mpSource->mpMap.data(); + TRoom* pSourceR1 = pSourceMap->mpRoomDB->getRoom(scmRoom1); + QVERIFY(pSourceR1); + pSourceMap->mUserData.insert(qsl("system.fallback_mapSymbolFont"), qsl("Stale Font,10,-1,5,400,0,0,0,0,0")); + pSourceMap->mUserData.insert(qsl("system.fallback_mapSymbolFontFudgeFactor"), qsl("9.99")); + pSourceMap->mUserData.insert(qsl("system.fallback_onlyUseMapSymbolFont"), qsl("false")); + // The unique value doubles as the proof below that this key really + // made it into the file: + pSourceR1->userData.insert(qsl("system.fallback_symbol"), qsl("stale-room-symbol-junk")); + + const int saveVersion = pSourceMap->mDefaultVersion; + QVERIFY(saveVersion >= 19); + const QString fileName = qsl("%1/map_tainted_v%2.dat").arg(mSaveDir.path()).arg(saveVersion); + QVERIFY(saveMapToFile(pSourceMap, fileName, saveVersion)); + + // Undo the tainting of the live source map: + pSourceMap->mUserData = expectedMapUserData(); + pSourceR1->userData = expectedRoom1UserData(); + + // The junk keys really did make it into the serialized stream: + QVERIFY(fileContainsSerializedString(fileName, qsl("system.fallback_mapSymbolFont"))); + QVERIFY(fileContainsSerializedString(fileName, qsl("system.fallback_onlyUseMapSymbolFont"))); + QVERIFY(fileContainsSerializedString(fileName, qsl("stale-room-symbol-junk"))); + + TMap* pTargetMap = mpTarget->mpMap.data(); + pTargetMap->mapClear(); + QVERIFY(pTargetMap->restore(fileName)); + pTargetMap->audit(); + + // Loading strips the junk keys while the legitimate user data - and + // the authoritative values stored directly in the stream - survive: + QCOMPARE(pTargetMap->mUserData, expectedMapUserData()); + QCOMPARE(pTargetMap->mMapSymbolFont.family(), qsl("DejaVu Serif")); + QCOMPARE(pTargetMap->mMapSymbolFontFudgeFactor, 1.25); + QVERIFY(pTargetMap->mIsOnlyMapSymbolFontToBeUsed); + TRoom* pTargetR1 = pTargetMap->mpRoomDB->getRoom(scmRoom1); + QVERIFY(pTargetR1); + QCOMPARE(pTargetR1->userData, expectedRoom1UserData()); + QCOMPARE(pTargetR1->mSymbol, qsl("⚔")); + } }; void initializeQRCResourcesForMapRoundTripTest() diff --git a/test/functional_tests/ModuleSaveTeardownTest.cpp b/test/functional_tests/ModuleSaveTeardownTest.cpp new file mode 100644 index 000000000..1b9ab6a1b --- /dev/null +++ b/test/functional_tests/ModuleSaveTeardownTest.cpp @@ -0,0 +1,523 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Coverage for the background half of a profile save that has modules to write, + * and for that write outliving the profile that ordered it. + * + * A profile save hands the modules that are set to sync to a thread pool task and + * returns. Two ways of closing a profile then wait for nothing: answering "No" to + * "Save profile?", and any close that finds the main console already gone. Either + * destroys the Host with the write still going, and the write went on reading the + * Host it was queued from - its XMLexport, and then its name. Under + * AddressSanitizer that kills the run inside Host::writeModuleFiles(); in a + * release build it is a crash or a corrupted .mpackage on the way out. The watcher + * that reports the write finished was unowned in the same window, so nothing was + * left to delete it once the profile it reported to had gone. + * + * This is the same shape as #9653 "uninstalling a package then quitting is a + * use-after-free", but it stayed hidden because no test profile had a module in + * it: with none installed the save's module list comes out empty and the write + * returns at its first line. So the point of this file is a profile that genuinely + * carries a synced module, which is what makes the hazardous path run at all. + * + * test_aSyncedModuleIsWrittenOutOnSave() is that coverage - the module really is + * serialized and its archive really is rewritten. The teardown test after it is + * the regression: it holds the thread pool so the write is provably still queued + * when the Host is destroyed, and then lets it run. + * + * Run with: ctest -R ModuleSaveTeardownTest -V + */ + +#include <QtTest/QtTest> + +#include <QFutureWatcher> +#include <QMessageBox> +#include <QRunnable> +#include <QScopeGuard> +#include <QSemaphore> +#include <QTemporaryDir> +#include <QThreadPool> +#include <chrono> +#include <zip.h> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TelnetServerStub.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForModuleSaveTeardownTest(); + +class ModuleSaveTeardownTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mProfileName = qsl("ModuleSaveTeardown-Test"); + const QString mModuleName = qsl("module-save-teardown"); + const QString mLocalhost = qsl("localhost"); + QString mPort; // the stub's actual ephemeral port, assigned in initTestCase() + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + // A synced module has its own archive rewritten by every profile save, so it + // lives in a scratch directory rather than anywhere the repository can see. + QTemporaryDir mArchiveDir; + QString mModuleArchivePath; + int mHeldPoolThreads = 0; + int mOriginalMaxPoolThreads = 0; + QSemaphore mPoolBlockersStarted; + QSemaphore mPoolRelease; + + static void deleteProfileDirectory(const QString& profileName) + { + QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName)); + if (dir.exists()) { + dir.removeRecursively(); + } + } + + // What the module archive is built from. Deliberately nothing like the module + // XML Mudlet writes: that one carries a <HelpPackage> element and this one does + // not, which is how the tests below tell "the module has been written out" from + // "this is still the file the archive was unpacked with". + static QByteArray sourceModuleXml() + { + return QByteArray("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<!DOCTYPE MudletPackage>\n" + "<MudletPackage version=\"1.001\">\n" + "<AliasPackage>\n" + "<Alias isActive=\"yes\" isFolder=\"no\">\n" + "<name>module-save-teardown alias</name>\n" + "<script>send(\"hello\")</script>\n" + "<command></command>\n" + "<packageName></packageName>\n" + "<regex>^module-save-teardown$</regex>\n" + "</Alias>\n" + "</AliasPackage>\n" + "</MudletPackage>\n"); + } + + static bool writeArchive(const QString& path, const QString& entryName, const QByteArray& contents) + { + int errorCode = 0; + zip* archive = zip_open(path.toUtf8().constData(), ZIP_CREATE | ZIP_TRUNCATE, &errorCode); + if (!archive) { + return false; + } + zip_source* source = zip_source_buffer(archive, contents.constData(), contents.size(), 0); + if (!source || zip_file_add(archive, entryName.toUtf8().constData(), source, ZIP_FL_ENC_UTF_8) < 0) { + zip_source_free(source); + zip_discard(archive); + return false; + } + return zip_close(archive) == 0; + } + + static QByteArray archiveEntry(const QString& path, const QString& entryName) + { + int errorCode = 0; + zip* archive = zip_open(path.toUtf8().constData(), ZIP_RDONLY, &errorCode); + if (!archive) { + return {}; + } + zip_stat_t entryStat; + QByteArray contents; + if (zip_stat(archive, entryName.toUtf8().constData(), 0, &entryStat) == 0) { + if (zip_file* file = zip_fopen(archive, entryName.toUtf8().constData(), 0); file) { + contents.resize(static_cast<qsizetype>(entryStat.size)); + if (zip_fread(file, contents.data(), entryStat.size) != static_cast<zip_int64_t>(entryStat.size)) { + contents.clear(); + } + zip_fclose(file); + } + } + zip_discard(archive); + return contents; + } + + QString moduleXmlPath() const { return mudlet::getMudletPath(enums::profilePackagePathFileName, mProfileName, mModuleName); } + + static QByteArray readFile(const QString& path) + { + QFile file(path); + if (!file.open(QFile::ReadOnly)) { + return {}; + } + return file.readAll(); + } + + static bool moduleWasWrittenOut(const QByteArray& xml) { return xml.contains("<HelpPackage"); } + + // Puts the module back the way installing it left it - both the unpacked XML and + // the archive - so that "has the module been written out yet?" has an answer again + // after an earlier save. Resetting only the unpacked XML would leave the archive + // carrying the previous save's work, and every assertion about it vacuously true. + bool resetModuleOnDisk() const + { + if (!writeArchive(mModuleArchivePath, qsl("%1.xml").arg(mModuleName), sourceModuleXml())) { + return false; + } + QFile file(moduleXmlPath()); + if (!file.open(QFile::WriteOnly | QFile::Truncate)) { + return false; + } + const QByteArray xml = sourceModuleXml(); + const bool written = file.write(xml) == xml.size(); + file.close(); + return written; + } + + QStringList moduleBackupFiles() const { return QDir(mudlet::getMudletPath(enums::moduleBackupsPath)).entryList(QStringList{qsl("%1*").arg(mModuleName)}, QDir::Files); } + + // A backup is named after the second it was taken in, and QFile::copy() will not + // overwrite, so counting backups across two saves in the same second proves + // nothing. Clearing them first makes "was one taken?" a plain yes or no. + void clearModuleBackups() const + { + QDir backups(mudlet::getMudletPath(enums::moduleBackupsPath)); + for (const auto& backup : moduleBackupFiles()) { + backups.remove(backup); + } + } + + int profileOwnedWatchers() const { return mpHost->findChildren<QFutureWatcherBase*>(QString(), Qt::FindDirectChildrenOnly).count(); } + + // Occupies every thread the global pool has until it is let go. QThreadPool's own + // reserveThread() is not enough - a thread woken by a newly queued task takes it + // regardless of the reservation - so this holds the threads with actual work. + class PoolBlocker : public QRunnable + { + public: + PoolBlocker(QSemaphore* started, QSemaphore* release) + : mpStarted(started) + , mpRelease(release) + { + setAutoDelete(true); + } + void run() override + { + mpStarted->release(); + mpRelease->acquire(); + } + + private: + QSemaphore* mpStarted = nullptr; + QSemaphore* mpRelease = nullptr; + }; + + // Holds the pool so that a task queued after this cannot start until releasePool(). + // That is what turns "the profile was destroyed while the module write was still + // going" from a race into something a test can state plainly. + bool holdPool() + { + auto* pool = QThreadPool::globalInstance(); + mOriginalMaxPoolThreads = pool->maxThreadCount(); + // The module write waits on the profile XML save before it starts, so leave the + // pool room to run both once they are let go. The count goes back only after + // the pool has drained, so that room is actually there when they run. + mHeldPoolThreads = qMax(4, mOriginalMaxPoolThreads); + pool->setMaxThreadCount(mHeldPoolThreads); + for (int i = 0; i < mHeldPoolThreads; ++i) { + pool->start(new PoolBlocker(&mPoolBlockersStarted, &mPoolRelease)); + } + // Every thread has to be taken before anything else is queued, or the save + // below could still find one free. Bounded so that a pool thread left busy by + // something else fails the test rather than wedging it until ctest's timeout. + return mPoolBlockersStarted.tryAcquire(mHeldPoolThreads, 10000); + } + + void releasePoolBlockers() + { + mPoolRelease.release(mHeldPoolThreads); + mHeldPoolThreads = 0; + } + + void restorePoolThreadCount() + { + if (mOriginalMaxPoolThreads) { + QThreadPool::globalInstance()->setMaxThreadCount(mOriginalMaxPoolThreads); + mOriginalMaxPoolThreads = 0; + } + } + + // Installs the fixture module and turns syncing on for it - only a synced module + // is written out by a profile save, so only a synced one reaches the background + // write this file is about. + bool installSyncedModule() + { + if (!mArchiveDir.isValid()) { + return false; + } + mModuleArchivePath = mArchiveDir.filePath(qsl("%1.mpackage").arg(mModuleName)); + if (!writeArchive(mModuleArchivePath, qsl("%1.xml").arg(mModuleName), sourceModuleXml())) { + return false; + } + mpHost->waitForProfileSave(); // an install during a save is postponed and answered with a bare true + auto [installed, message] = mpHost->installPackage(mModuleArchivePath, enums::PackageModuleType::ModuleFromScript, true); + if (!installed) { + qWarning().noquote() << "installing the fixture module failed:" << message; + return false; + } + if (!mpHost->mModulesLoadedOk.contains(mModuleName)) { + return false; + } + auto [synced, syncMessage] = mpHost->changeModuleSync(mModuleName, QLatin1String("1")); + if (!synced) { + qWarning().noquote() << "enabling sync on the fixture module failed:" << syncMessage; + return false; + } + return true; + } + + // Closes the profile the way a user does who has turned the "save profile on + // exit" preference off and then answers "No" to "Save profile?". That branch of + // TMainConsole::closeEvent() waits for nothing - which is the point: a module + // write queued beforehand is still going once the close is over. (The close that + // finds the main console already gone waits for nothing either, and needs no + // dialog at all, but a profile can only be closed once, so one test gets one of + // the two.) + bool closeProfileWithoutSaving() + { + mpHost->mFORCE_SAVE_ON_EXIT = false; + QTimer answerNo; + int ticks = 0; + connect(&answerNo, &QTimer::timeout, qApp, [&ticks]() { + auto* modal = QApplication::activeModalWidget(); + if (!modal) { + return; + } + if (auto* box = qobject_cast<QMessageBox*>(modal); box) { + if (auto* no = box->button(QMessageBox::No); no) { + no->click(); + return; + } + } + // Whatever this dialog is, it is not the one expected, and requestClose() + // is blocked in its event loop: shut it so the test can fail and say so + // rather than hang until ctest gives up on it. + if (++ticks > 40) { + modal->close(); + } + }); + answerNo.start(50ms); + const bool closed = mpHost->requestClose(); + answerNo.stop(); + return closed; + } + + // Utility function to manually start a profile like a user would do via the GUI + void startProfile(const QString& profileName, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), profileName); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForModuleSaveTeardownTest(); + + // Keep the test hermetic: point the config dir resolution at a temporary + // directory instead of the user's real profiles. + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator"))); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mProfileName); + + startProfile(mProfileName, mLocalhost, mPort); + QVERIFY2(mpHost, "No active host after profile creation"); + QVERIFY2(installSyncedModule(), "The fixture module could not be installed"); + // installing a module owes the profile a save; let it come and go + QTRY_VERIFY_WITH_TIMEOUT(!mpHost->hasPendingProfileSave(), 5000); + mpHost->waitForProfileSave(); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mProfileName); + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // The coverage this file exists for: with a synced module installed, a profile + // save really does serialize it and really does rewrite its archive. Without a + // module in the profile the whole background write returns immediately, and + // nothing below it can go wrong in a way a test would notice. + void test_aSyncedModuleIsWrittenOutOnSave() + { + QVERIFY2(resetModuleOnDisk(), "Could not put the module back the way it was installed"); + QVERIFY2(!moduleWasWrittenOut(readFile(moduleXmlPath())), "The module XML looked written out before the save"); + clearModuleBackups(); + + auto [ok, filename, error] = mpHost->saveProfile(); + QVERIFY2(ok, qPrintable(error)); + mpHost->waitForProfileSave(); + + const QByteArray writtenXml = readFile(moduleXmlPath()); + QVERIFY2(moduleWasWrittenOut(writtenXml), "The profile save did not write the module out"); + // The document handed to the background write is a copy taken element by + // element, so check the parts that live outside the root element survived it - + // a module XML without them is not one Mudlet can read back in. + QVERIFY2(writtenXml.startsWith("<?xml"), "The written module XML lost its declaration"); + QVERIFY2(writtenXml.contains("<!DOCTYPE MudletPackage>"), "The written module XML lost its doctype"); + QVERIFY2(moduleWasWrittenOut(archiveEntry(mModuleArchivePath, qsl("%1.xml").arg(mModuleName))), "The profile save did not update the module's archive"); + QVERIFY2(!moduleBackupFiles().isEmpty(), "The profile save did not back the module up before overwriting it"); + // The watcher the save made has to go once it has reported, or a long-lived + // profile collects one per save. + QTRY_COMPARE(profileOwnedWatchers(), 0); + } + + // ...and an autosave deliberately does not back the module up, or every autosave + // tick would leave another timestamped copy of every synced module behind. + void test_anAutosaveWritesTheModuleWithoutBackingItUp() + { + QVERIFY2(resetModuleOnDisk(), "Could not put the module back the way it was installed"); + clearModuleBackups(); + + auto [ok, filename, error] = mpHost->saveProfile(QString(), qsl("autosave")); + QVERIFY2(ok, qPrintable(error)); + mpHost->waitForProfileSave(); + + QVERIFY2(moduleWasWrittenOut(readFile(moduleXmlPath())), "The autosave did not write the module out"); + QVERIFY2(moduleBackupFiles().isEmpty(), "The autosave backed the module up, which every tick of it would then do"); + } + + // ...and closing the profile while that write is still going may neither reach + // the destroyed Host nor abandon the write. This one destroys the Host, so it has + // to stay last: anything after it would run without a profile. + void test_theModuleWriteOutlivesTheProfile() + { + QVERIFY2(resetModuleOnDisk(), "Could not put the module back the way it was installed"); + + QVERIFY2(holdPool(), "The thread pool could not be held - a pool thread was busy with something else"); + auto releaseGuard = qScopeGuard([this]() { + if (mHeldPoolThreads) { + releasePoolBlockers(); + } + restorePoolThreadCount(); + }); + + auto [ok, filename, error] = mpHost->saveProfile(); + QVERIFY2(ok, qPrintable(error)); + + // Checked after the teardown below, but they have to be taken hold of here. + // Every watcher this save made is expected to belong to the profile, so that + // destroying it takes them too; before the fix none of them did. + QList<QPointer<QObject>> watcherGuards; + for (auto* watcher : mpHost->findChildren<QFutureWatcherBase*>(QString(), Qt::FindDirectChildrenOnly)) { + watcherGuards.append(QPointer<QObject>(watcher)); + } + + // If this fails the pool was not actually held, and there is no teardown + // race left for the rest of the test to be about. + QVERIFY2(!moduleWasWrittenOut(readFile(moduleXmlPath())), "The module write ran before the profile was closed - the thread pool was not held"); + + QVERIFY2(closeProfileWithoutSaving(), "Closing the profile was refused"); + QVERIFY2(!moduleWasWrittenOut(readFile(moduleXmlPath())), "Closing the profile waited for the module write - this test needs a close that does not"); + + mpHost = nullptr; + mudlet::self()->getHostManager().deleteHost(mProfileName); + + // Now let the write run against a profile that is gone. Before the fix this + // kills the run under AddressSanitizer, reaching through the destroyed Host + // for its XMLexport and then reading its name. + releasePoolBlockers(); + QThreadPool::globalInstance()->waitForDone(); + + // Abandoning the write instead would be no fix: the module's changes would + // be lost, and its archive left half-rewritten. + QVERIFY2(moduleWasWrittenOut(readFile(moduleXmlPath())), "The module write was dropped when the profile went away"); + QVERIFY2(moduleWasWrittenOut(archiveEntry(mModuleArchivePath, qsl("%1.xml").arg(mModuleName))), "The module's archive was left un-updated when the profile went away"); + + // Nothing but the profile owns these, so destroying it has to have taken them: + // the deleteLater() they are also wired to needs an event loop that is still + // running to be delivered, and on the way out there is not one. + QVERIFY2(!watcherGuards.isEmpty(), "The save made no watcher the profile owns"); + for (const auto& watcherGuard : watcherGuards) { + QVERIFY2(watcherGuard.isNull(), "A save watcher outlived the profile it belongs to, with nothing left to delete it"); + } + } +}; + +void initializeQRCResourcesForModuleSaveTeardownTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ModuleSaveTeardownTest.moc" +QTEST_MAIN(ModuleSaveTeardownTest) diff --git a/test/functional_tests/MxpFramePlacementTest.cpp b/test/functional_tests/MxpFramePlacementTest.cpp new file mode 100644 index 000000000..4150ad0ac --- /dev/null +++ b/test/functional_tests/MxpFramePlacementTest.cpp @@ -0,0 +1,424 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QSignalSpy> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForMxpFramePlacementTest(); + +// Covers issue #9698: a package that reserves space with setBorderRight() and +// friends - the base UI does exactly that - must not have MXP frames placed on +// top of the space it claimed. +class MxpFramePlacementTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "MxpFramePlacement-Test-Host"; + QString mPort; + const QString mLocalhost = "localhost"; + + void runLua(const QString& script) { QVERIFY2(mpHost->getLuaInterpreter()->compileAndExecuteScript(script), qPrintable(script)); } + + // The space frames may be placed in: the main window, less whatever a + // package has reserved for itself. This repeats TMxpFrameManager's own + // formula, so tests that need an anchor independent of it assert against a + // literal or against another widget's geometry instead. + QRect area() const { return QRect(QPoint(0, 0), mpHost->mpConsole->getMainWindowSize()).marginsRemoved(mpHost->userBorders()); } + + QRect frameGeometry(const QString& name) const + { + const TMxpFrame* frame = mpHost->mMxpFrameManager.getFrame(name); + if (!frame || !frame->widget) { + return {}; + } + return frame->widget->geometry(); + } + + bool createFrame(const QString& name, const QString& align, const QString& width, const QString& height, const QMap<QString, QString>& extraAttributes = {}) + { + QMap<QString, QString> attributes = extraAttributes; + attributes.insert(qsl("NAME"), name); + attributes.insert(qsl("ALIGN"), align); + if (!width.isEmpty()) { + attributes.insert(qsl("WIDTH"), width); + } + if (!height.isEmpty()) { + attributes.insert(qsl("HEIGHT"), height); + } + const bool created = mpHost->mMxpFrameManager.createFrame(name, attributes); + settle(); + return created; + } + + // border changes and window resizes reposition frames from a zero timer + void settle() { QTest::qWait(50ms); } + +private slots: + void initTestCase() + { + initializeQRCResourcesForMxpFramePlacementTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QDir(mudlet::getMudletPath(enums::profileHomePath, mHostname)).removeRecursively(); + + QTimer::singleShot(0ms, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + + mudlet::self()->resize(1200, 800); + QTest::qWait(100ms); + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + mpHost = nullptr; + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + delete mudlet::self(); + QDir(path).removeRecursively(); + } + + void init() + { + QVERIFY(mpHost); + QVERIFY(mpHost->mpConsole); + mpHost->mMxpProcessor.enable(); + mudlet::self()->resize(1200, 800); + settle(); + } + + // runs even when a QVERIFY aborts a test body, so no state carries into the + // next test - or, through the window geometry Mudlet saves on exit, into the + // next run of this binary + void cleanup() + { + mpHost->mMxpFrameManager.resetAllFrames(); + runLua(qsl("setBorderSizes(0)")); + mudlet::self()->resize(1200, 800); + settle(); + } + + void test_rightFrameKeepsClearOfAReservedRightBorder() + { + runLua(qsl("setBorderRight(300)")); + settle(); + const QRect reservedArea = area(); + + QVERIFY(createFrame(qsl("status"), qsl("right"), qsl("200px"), qsl("100%"))); + + const QRect frame = frameGeometry(qsl("status")); + QCOMPARE(frame.width(), 200); + QCOMPARE(frame.x(), reservedArea.right() + 1 - 200); + // the console and the frame have to tile the unreserved space between them + QCOMPARE(mpHost->mpConsole->mpMainDisplay->geometry().right() + 1, frame.x()); + } + + // the console has to give up room for the frame on top of what the package took + void test_frameBorderStacksOnTopOfTheUserBorder() + { + runLua(qsl("setBorderRight(300)")); + settle(); + + QVERIFY(createFrame(qsl("status"), qsl("right"), qsl("200px"), qsl("100%"))); + + QCOMPARE(mpHost->userBorders().right(), 300); + QCOMPARE(mpHost->borders().right(), 500); + QVERIFY2(mpHost->mpConsole->mpMainDisplay->geometry().right() < frameGeometry(qsl("status")).left(), "the main display overlaps the frame"); + } + + // with nothing reserved a right frame still goes right up to the edge + void test_rightFrameHugsTheEdgeWithoutAUserBorder() + { + QVERIFY(createFrame(qsl("status"), qsl("right"), qsl("200px"), qsl("100%"))); + + QCOMPARE(frameGeometry(qsl("status")).right() + 1, area().width()); + QCOMPARE(mpHost->mpConsole->mpMainDisplay->geometry().right() + 1, frameGeometry(qsl("status")).x()); + } + + void test_frameFollowsABorderThatChangesAfterwards() + { + runLua(qsl("setBorderRight(300)")); + settle(); + + QVERIFY(createFrame(qsl("status"), qsl("right"), qsl("200px"), qsl("100%"))); + + runLua(qsl("setBorderRight(100)")); + settle(); + + QCOMPARE(frameGeometry(qsl("status")).x(), area().right() + 1 - 200); + QCOMPARE(mpHost->borders().right(), 300); + + // growing the reservation is the direction that would leave the frame + // sitting inside it + runLua(qsl("setBorderRight(400)")); + settle(); + + QCOMPARE(frameGeometry(qsl("status")).x(), area().right() + 1 - 200); + QCOMPARE(mpHost->borders().right(), 600); + } + + void test_frameFollowsAWindowResize() + { + runLua(qsl("setBorderRight(300)")); + settle(); + + QVERIFY(createFrame(qsl("status"), qsl("right"), qsl("200px"), qsl("100%"))); + const int widthBefore = mpHost->mpConsole->mpMainFrame->width(); + + mudlet::self()->resize(1000, 700); + settle(); + + QVERIFY2(mpHost->mpConsole->mpMainFrame->width() != widthBefore, "the window did not actually resize"); + QCOMPARE(frameGeometry(qsl("status")).x(), area().right() + 1 - 200); + // a container that moves without its text area following it would look + // to the player like the frame did not move at all + const TMxpFrame* frame = mpHost->mMxpFrameManager.getFrame(qsl("status")); + QVERIFY(frame && frame->console); + QCOMPARE(frame->console->size(), frame->widget->size()); + } + + void test_leftFrameStartsAfterAReservedLeftBorder() + { + runLua(qsl("setBorderLeft(150)")); + settle(); + + QVERIFY(createFrame(qsl("nav"), qsl("left"), qsl("120px"), qsl("100%"))); + + QCOMPARE(frameGeometry(qsl("nav")).x(), 150); + QCOMPARE(mpHost->borders().left(), 270); + } + + void test_topFrameStartsAfterAReservedTopBorder() + { + runLua(qsl("setBorderTop(150)")); + settle(); + + QVERIFY(createFrame(qsl("banner"), qsl("top"), qsl("100%"), qsl("60px"))); + + const QRect frame = frameGeometry(qsl("banner")); + QCOMPARE(frame.y(), 150); + QCOMPARE(frame.height(), 60); + QCOMPARE(mpHost->borders().top(), 210); + } + + void test_bottomFrameKeepsClearOfAReservedBottomBorder() + { + runLua(qsl("setBorderBottom(120)")); + settle(); + const QRect reservedArea = area(); + + QVERIFY(createFrame(qsl("chat"), qsl("bottom"), qsl("100%"), qsl("80px"))); + + const QRect frame = frameGeometry(qsl("chat")); + QCOMPARE(frame.height(), 80); + QCOMPARE(frame.y(), reservedArea.bottom() + 1 - 80); + QCOMPARE(mpHost->borders().bottom(), 200); + // an anchor that does not go through the same formula: the frame has to + // clear the command line as well as the reserved strip + QCOMPARE(frame.bottom() + 1, mpHost->mpConsole->height() - mpHost->mpConsole->mpCommandLine->height() - 120); + } + + // WIDTH defaults to a percentage, which now resolves against the space the + // package left rather than the whole window + void test_percentageWidthResolvesAgainstTheUnreservedSpace() + { + runLua(qsl("setBorderRight(400)")); + settle(); + const QRect reservedArea = area(); + + QVERIFY(createFrame(qsl("status"), qsl("right"), QString(), qsl("100%"))); + + QCOMPARE(frameGeometry(qsl("status")).width(), reservedArea.width() / 4); + } + + // a frame opened while a DEST is active nests inside it and takes no space + // from the main console + void test_nestedFrameLeavesTheBordersAlone() + { + QVERIFY(createFrame(qsl("outer"), qsl("right"), qsl("300px"), qsl("100%"))); + const QMargins bordersWithOuter = mpHost->borders(); + + mpHost->mMxpFrameManager.setDestination(qsl("outer"), false, false); + QVERIFY(createFrame(qsl("nested"), qsl("top"), qsl("100%"), qsl("40px"))); + mpHost->mMxpFrameManager.clearDestination(); + + QCOMPARE(mpHost->borders(), bordersWithOuter); + QVERIFY2(frameGeometry(qsl("outer")).contains(frameGeometry(qsl("nested"))), "the nested frame is not inside its parent"); + + // Relayouts have to be idempotent: a top-aligned nested frame sits at its + // parent's top edge, and usedHeight accumulates, so without a reset each + // pass would march it further down. + for (int i = 0; i < 3; ++i) { + runLua(qsl("setBorderLeft(%1)").arg(i * 10)); + settle(); + QCOMPARE(frameGeometry(qsl("nested")).y(), frameGeometry(qsl("outer")).y()); + } + + QCOMPARE(mpHost->borders().right(), bordersWithOuter.right()); + } + + // frames stack inwards, so the second one has to clear both the reserved + // border and its neighbour + void test_twoRightFramesStackInwardsFromTheReservedBorder() + { + runLua(qsl("setBorderRight(200)")); + settle(); + const QRect reservedArea = area(); + + QVERIFY(createFrame(qsl("outer"), qsl("right"), qsl("150px"), qsl("100%"))); + QVERIFY(createFrame(qsl("inner"), qsl("right"), qsl("100px"), qsl("100%"))); + + QCOMPARE(frameGeometry(qsl("outer")).x(), reservedArea.right() + 1 - 150); + QCOMPARE(frameGeometry(qsl("inner")).x(), reservedArea.right() + 1 - 150 - 100); + QCOMPARE(mpHost->borders().right(), 450); + } + + // an EXTERNAL frame lives in its own window: it neither takes space from the + // main console nor may be dragged into main window coordinates by a relayout + void test_externalFrameIsLeftAloneByARelayout() + { + QVERIFY(createFrame(qsl("popup"), qsl("left"), qsl("200px"), qsl("150px"), {{qsl("EXTERNAL"), qsl("true")}})); + const TMxpFrame* frame = mpHost->mMxpFrameManager.getFrame(qsl("popup")); + QVERIFY(frame); + QVERIFY2(frame->widget && frame->widget->isWindow(), "the external frame is not a window of its own"); + const QRect geometryBefore = frame->widget->geometry(); + QCOMPARE(mpHost->borders(), QMargins()); + + mudlet::self()->resize(1000, 700); + settle(); + + QCOMPARE(mpHost->borders(), QMargins()); + QCOMPARE(frame->widget->geometry(), geometryBefore); + } + + // closing the outer frame has to pull the inner one back out to the edge + void test_closingAFrameRepositionsTheRest() + { + runLua(qsl("setBorderRight(200)")); + settle(); + const QRect reservedArea = area(); + + QVERIFY(createFrame(qsl("outer"), qsl("right"), qsl("150px"), qsl("100%"))); + QVERIFY(createFrame(qsl("inner"), qsl("right"), qsl("100px"), qsl("100%"))); + + QVERIFY(mpHost->mMxpFrameManager.closeFrame(qsl("outer"))); + settle(); + + QCOMPARE(frameGeometry(qsl("inner")).x(), reservedArea.right() + 1 - 100); + QCOMPARE(mpHost->borders().right(), 300); + } + + // How the base UI reserves its space, so this is #9698 as reported. Declared + // last on purpose: an adjustable container leaves deferred timers of its own + // behind that resize the main window out from under whatever runs next, so + // add new tests above this one rather than below it. For the same reason the + // expectation is evaluated at assert time rather than captured up front. + void test_rightFrameKeepsClearOfAnAttachedAdjustableContainer() + { + runLua(qsl("panel = Adjustable.Container:new({name = 'mxpTestPanel', x = '-25%', y = 0, width = '25%', height = '100%', autoSave = false, autoLoad = false})\n" + "panel:attachToBorder('right')")); + settle(); + const int reservedRight = mpHost->userBorders().right(); + QVERIFY2(reservedRight > 0, "the adjustable container did not reserve a border"); + + QVERIFY(createFrame(qsl("status"), qsl("right"), qsl("200px"), qsl("100%"))); + settle(); + + QCOMPARE(frameGeometry(qsl("status")).x(), area().right() + 1 - 200); + QCOMPARE(mpHost->borders().right(), reservedRight + 200); + + runLua(qsl("panel:detach() panel:hide()")); + settle(); + } +}; + +void initializeQRCResourcesForMxpFramePlacementTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "MxpFramePlacementTest.moc" +QTEST_MAIN(MxpFramePlacementTest) diff --git a/test/functional_tests/NarrowWindowWrapTest.cpp b/test/functional_tests/NarrowWindowWrapTest.cpp new file mode 100644 index 000000000..d70ab334d --- /dev/null +++ b/test/functional_tests/NarrowWindowWrapTest.cpp @@ -0,0 +1,419 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Makers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QtTest/QtTest> + +#include <atomic> +#include <functional> +#include <thread> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResources(); + +// A wrap width that cannot hold a single glyph - because it is zero, because +// the glyph is wider than the width, or because the indentation uses the width +// up - made TBuffer::getWrapInfo() break the line at the character it was +// already sitting on, so the scan never advanced and Mudlet hung (#9622). +// Every step that can reach the wrapping therefore runs under a watchdog: a +// regression is an endless loop on the main thread, so no assertion after it +// would ever be reached. +class NarrowWindowWrapTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mHostname = "Test-NarrowWrap"; + QString mPort; // assigned the stub's actual loopback port in init() + const QString mLocalhost = "localhost"; + const QString mMiniConsole = "wrapTest"; + // U+6F22 U+5B57 - East Asian Wide, so two columns are needed per glyph + const QString mWideText = QString(QChar(0x6F22)) + QChar(0x5B57); + +private slots: + void initTestCase() { initializeQRCResources(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + // Port 0 asks the OS for an ephemeral port so parallel test runs do + // not collide on a hardcoded one + mpServer->start(mLocalhost, 0); + QVERIFY2(mpServer->isListening(), "TelnetServerStub failed to bind a loopback port"); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + } + + // The report from #9622, at the level the Lua API no longer allows: + // TConsole::setWrapAt() is still reachable from C++, so the wrapping itself + // has to cope with a width of zero instead of spinning forever. + void test_zeroWrapWidthDoesNotHang() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + console->setWrapAt(0); + + runWithWatchdog("echo at a wrap width of zero", [this]() { + runLua(qsl("echo('%1', 'abcdef\\n')").arg(mMiniConsole)); + }); + + // no width can hold a character, so every character ends up on a line + // of its own - and not one of them may be dropped or duplicated + QCOMPARE(nonEmptyLineCount(console), 6); + QCOMPARE(joinedText(console), qsl("abcdef")); + } + + // Newlines inside the echoed text take their own path through the wrapping + // scan, which has to keep its bookkeeping straight alongside the forced + // per-glyph breaks. + void test_embeddedNewlinesAtZeroWrapWidthDoNotHang() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + console->setWrapAt(0); + + runWithWatchdog("echo of embedded newlines at a wrap width of zero", [this]() { + runLua(qsl("echo('%1', 'ab\\ncd\\n')").arg(mMiniConsole)); + }); + + QCOMPARE(joinedText(console), qsl("abcd")); + } + + // A width of one column with two-column glyphs is the same dead end, and + // unlike a width of zero it is a perfectly ordinary thing to ask for. + void test_wrapWidthNarrowerThanTheGlyphDoesNotHang() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + runLua(qsl("setWindowWrap('%1', 1)").arg(mMiniConsole)); + + runWithWatchdog("echo of a wide glyph at a wrap width of one", [this]() { + runLua(qsl("echo('%1', '%2\\n')").arg(mMiniConsole, mWideText)); + }); + + QCOMPARE(nonEmptyLineCount(console), 2); + QCOMPARE(joinedText(console), mWideText); + } + + // Indentation is subtracted from the wrap width, so a legal width and a + // legal indent together can still leave less room than one glyph needs. + void test_indentEatingTheWrapWidthDoesNotHang() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + runLua(qsl("setWindowWrap('%1', 5)").arg(mMiniConsole)); + // both, so that whichever of the two a line uses leaves a single column + runLua(qsl("setWindowWrapIndent('%1', 4)").arg(mMiniConsole)); + runLua(qsl("setWindowWrapHangingIndent('%1', 4)").arg(mMiniConsole)); + + runWithWatchdog("echo of a wide glyph with the indent using up the wrap width", [this]() { + runLua(qsl("echo('%1', '%2\\n')").arg(mMiniConsole, mWideText)); + }); + + QCOMPARE(textIgnoringIndentation(console), mWideText); + } + + // An indent at or beyond the wrap width is not range-checked anywhere. + // wrapLine() drops such an indent instead of leaving no room at all, and + // that is what keeps this case out of the trap the one above falls into. + void test_indentWiderThanTheWrapWidthDoesNotHang() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + runLua(qsl("setWindowWrap('%1', 5)").arg(mMiniConsole)); + runLua(qsl("setWindowWrapIndent('%1', 10)").arg(mMiniConsole)); + runLua(qsl("setWindowWrapHangingIndent('%1', 10)").arg(mMiniConsole)); + + runWithWatchdog("echo with an indent wider than the wrap width", [this]() { + runLua(qsl("echo('%1', '%2%2\\n')").arg(mMiniConsole, mWideText)); + }); + + QCOMPARE(textIgnoringIndentation(console), mWideText + mWideText); + } + + // insertText() wraps against the screen width and the profile's own indent + // rather than the console's, so it reaches the wrapping by a different + // route than echo() does. + void test_insertTextIntoTheMainConsoleDoesNotHang() + { + mpServer->setWelcomeMessage(qsl("HELLO\r\n")); + startProfile(); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY2(waitForMainConsoleText(qsl("HELLO")), "Welcome text never reached the buffer"); + + // leave a single column free of the screen width the insert wraps at - + // both indents, since only the first segment of a line uses the plain + // one and every segment after it uses the hanging one + const int indent = host->mScreenWidth - 1; + QVERIFY2(indent > 1, "the main console reported no usable screen width"); + runLua(qsl("setWindowWrapIndent('main', %1)").arg(indent)); + runLua(qsl("setWindowWrapHangingIndent('main', %1)").arg(indent)); + + // mid-line, so the insert goes through insertInLine() rather than the + // append path the cursor at the end of the buffer would take + const int welcomeLine = mainConsoleLineOf(qsl("HELLO")); + QVERIFY2(welcomeLine >= 0, "the welcome line went missing from the buffer"); + QVERIFY2(host->mpConsole->moveCursor(2, welcomeLine), "could not position the user cursor mid-line"); + + runWithWatchdog("insertText of a wide glyph with the indent using up the screen width", [this, host]() { + // the newline is what makes the insert re-wrap the line it landed in + host->mpConsole->insertText(mWideText + QChar::LineFeed + mWideText); + }); + + QVERIFY2(mainConsoleContains(mWideText), "the inserted text did not survive wrapping"); + } + + // Nothing can be shown in a window that is zero columns wide, so the Lua + // API turns such a width away rather than let it reach the wrapping. + void test_setWindowWrapRejectsWidthsBelowOne() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + // wide enough that the reported result is not itself wrapped + runLua(qsl("setWindowWrap('%1', 200)").arg(mMiniConsole)); + + // under the watchdog as well: were the width to be accepted, the echo + // reporting the result would be the thing that hangs + runWithWatchdog("setWindowWrap() with a width of zero", [this]() { + runLua(qsl("local ok, err = setWindowWrap('%1', 0) echo('%1', 'RESULT:'..tostring(ok)..':'..tostring(err))").arg(mMiniConsole)); + }); + + const QString result = joinedText(console); + QVERIFY2(result.startsWith(qsl("RESULT:nil:")), qPrintable(qsl("setWindowWrap() did not refuse a wrap width of zero, it returned: %1").arg(result))); + QVERIFY2(result.contains(qsl("greater than zero")), qPrintable(qsl("the refusal did not say why: %1").arg(result))); + // the rejected call must not have changed the width either + QCOMPARE(console->getWrapAt(), 200); + } + + // An accepted width answers true, so that the usual `if not ok then` check + // does not read every successful call as a failure. + void test_setWindowWrapReportsSuccess() + { + startProfile(); + auto* console = createTestMiniConsole(); + QVERIFY(console); + + runLua(qsl("local ok = setWindowWrap('%1', 200) echo('%1', 'RESULT:'..tostring(ok))").arg(mMiniConsole)); + + QCOMPARE(joinedText(console), qsl("RESULT:true")); + QCOMPARE(console->getWrapAt(), 200); + } + + // The main console's width is mirrored into the profile and reported to the + // game, so a refused width must not reach either. + void test_rejectedMainConsoleWidthLeavesTheProfileUntouched() + { + startProfile(); + auto* host = mudlet::self()->getActiveHost(); + runLua(qsl("setWindowWrap(80)")); + QCOMPARE(host->mWrapAt, 80); + + runWithWatchdog("setWindowWrap() with a width of zero on the main console", [this]() { + runLua(qsl("setWindowWrap(0)")); + }); + + QCOMPARE(host->mWrapAt, 80); + QCOMPARE(host->mpConsole->getWrapAt(), 80); + } + + void cleanup() + { + const QString profilePath = mudlet::getMudletPath(enums::profileHomePath, mHostname); + + // Tear down Mudlet (and with it the live cTelnet connection) before the + // stub server it is talking to, so the socket is closed from the client + // side rather than being yanked out from under an active connection. + delete mudlet::self(); + delete mpServer; + mpServer = nullptr; + deleteDirectory(profilePath); + } + +private: + // Runs work on the main thread with a hard deadline: should the wrapping + // regress into an endless loop, kill the test process with a useful message + // rather than leave the whole ctest run to sit until its own timeout. + void runWithWatchdog(const char* what, const std::function<void()>& work, int timeoutSeconds = 10) + { + std::atomic_bool finished{false}; + std::thread watchdog([&finished, what, timeoutSeconds]() { + for (int i = 0; i < timeoutSeconds * 10 && !finished.load(); ++i) { + QThread::msleep(100); + } + if (!finished.load()) { + qFatal("%s did not finish within %d seconds - the wrapping is stuck in a loop", what, timeoutSeconds); + } + }); + work(); + finished.store(true); + watchdog.join(); + } + + void runLua(const QString& script) + { + auto host = mudlet::self()->getActiveHost(); + host->getLuaInterpreter()->compileAndExecuteScript(script); + } + + // a miniconsole keeps the assertions free of the main console's connection + // messages, and wraps its text through exactly the same code + TConsole* createTestMiniConsole() + { + runLua(qsl("createMiniConsole('%1', 0, 0, 300, 300)").arg(mMiniConsole)); + return mudlet::self()->getActiveHost()->mpConsole->mSubConsoleMap.value(mMiniConsole); + } + + void startProfile() + { + QTimer::singleShot(0, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy connectedSpy(&(host->mTelnet), &cTelnet::signal_connected); + if (!connectedSpy.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + // the buffer carries empty lines of its own (one is always kept ready for + // the next text), so only the lines with something in them are counted + static int nonEmptyLineCount(TConsole* console) + { + int count = 0; + for (int i = 0, total = console->buffer.getLastLineNumber(); i <= total; ++i) { + if (!console->buffer.line(i).isEmpty()) { + ++count; + } + } + return count; + } + + // every line of the console joined back together - the wrapping only breaks + // lines, so this has to come back out exactly as it went in + static QString joinedText(TConsole* console) + { + QString text; + for (int i = 0, total = console->buffer.getLastLineNumber(); i <= total; ++i) { + text.append(console->buffer.line(i)); + } + return text; + } + + // as joinedText(), but with every space dropped, for the cases where the + // wrapping pads lines out with indentation. Spaces in the text itself are + // lost along with it, so these cases echo text that has none. + static QString textIgnoringIndentation(TConsole* console) { return joinedText(console).remove(QChar::Space); } + + static int mainConsoleLineOf(const QString& text) + { + auto console = mudlet::self()->getActiveHost()->mpConsole; + for (int i = 0, total = console->buffer.getLastLineNumber(); i <= total; ++i) { + if (console->buffer.line(i).contains(text)) { + return i; + } + } + return -1; + } + + static bool mainConsoleContains(const QString& text) { return mainConsoleLineOf(text) >= 0; } + + bool waitForMainConsoleText(const QString& text, int timeoutMs = 5000) + { + return QTest::qWaitFor( + [this, &text]() { + return mainConsoleContains(text); + }, + timeoutMs); + } + + void deleteProfileDirectory(const QString& profileName) { deleteDirectory(mudlet::getMudletPath(enums::profileHomePath, profileName)); } + + void deleteDirectory(const QString& path) + { + QDir dir(path); + if (dir.exists()) { + dir.removeRecursively(); + } + } +}; + +void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "NarrowWindowWrapTest.moc" +QTEST_MAIN(NarrowWindowWrapTest) diff --git a/test/functional_tests/NewReleaseDialogTeardownTest.cpp b/test/functional_tests/NewReleaseDialogTeardownTest.cpp new file mode 100644 index 000000000..345ddea1f --- /dev/null +++ b/test/functional_tests/NewReleaseDialogTeardownTest.cpp @@ -0,0 +1,122 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vperetokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include "updater.h" +#include "updater/UpdateDialog.h" +#include "utils.h" + +#include <QtTest/QtTest> + +#include <QApplication> +#include <QPointer> +#include <QSettings> +#include <QStandardPaths> +#include <QTemporaryDir> +#include <QTimer> +#include <QWidget> + +#include <memory> + +/* + * Regression test for https://github.com/Mudlet/Mudlet/issues/9122: + * STATUS_HEAP_CORRUPTION at application close on Windows. + * + * The Updater is parented to the application object (#9388) and used to delete + * its unparented top-level UpdateDialog in ~Updater. That destructor only runs + * inside the application's own destructor - after ~QApplication has torn down + * all widget infrastructure - and deleting a QWidget that late corrupts the + * heap. The dialog must instead be destroyed on aboutToQuit, while the + * application is still fully alive. + * + * The timing is pinned from both sides: the dialog must still be alive when + * the last window closes (its purpose is to offer an update at exactly that + * point, #9388), and must be gone once the event loop has exited. Note that + * with no update to offer the dialog's own last-window-closed handler calls + * quit(), which in Qt 6 emits aboutToQuit synchronously - so the dialog is + * destroyed inside that cascade, before close() even returns. + * + * QTEST_APPLESS_MAIN is used because the test itself must own the + * QApplication lifetime to walk it through quit and destruction. + */ +class NewReleaseDialogTeardownTest : public QObject +{ + Q_OBJECT + +private slots: + void updateDialogDestroyedBeforeApplicationTeardown(); +}; + +void NewReleaseDialogTeardownTest::updateDialogDestroyedBeforeApplicationTeardown() +{ + // Keeps checkUpdatesOnStart() away from real user data - on Windows it + // deletes stale installer files from the genuine GenericDataLocation + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir settingsDir; + QVERIFY(settingsDir.isValid()); + QSettings settings(settingsDir.filePath(qsl("updater-test.ini")), QSettings::IniFormat); + + int argc = 1; + char appName[] = "NewReleaseDialogTeardownTest"; + char* argv[] = {appName, nullptr}; + const auto app = std::make_unique<QApplication>(argc, argv); + + auto* updater = new Updater(app.get(), &settings); + + // Connected before checkUpdatesOnStart() creates the dialog, so with + // direct connections firing in connection order this probe runs before + // the dialog's own last-window-closed handler - the last moment the + // dialog is guaranteed to exist, as that handler quits when there is no + // update to offer and the quit destroys the dialog + QPointer<dblsqd::UpdateDialog> dialog; + bool dialogAliveAtLastWindowClosed = false; + connect(app.get(), &QGuiApplication::lastWindowClosed, this, [&dialog, &dialogAliveAtLastWindowClosed]() { + dialogAliveAtLastWindowClosed = !dialog.isNull(); + }); + + // Also fires the feed's update check; the request is torn down with the + // application before any response arrives and nothing below depends on it + updater->checkUpdatesOnStart(); + + const auto topLevels = QApplication::topLevelWidgets(); + for (auto* widget : topLevels) { + if ((dialog = qobject_cast<dblsqd::UpdateDialog*>(widget))) { + break; + } + } + QVERIFY2(dialog, "expected the Updater to have created its UpdateDialog"); + + auto* window = new QWidget; + window->show(); + QTimer::singleShot(0, app.get(), [&window]() { + window->close(); + delete window; + // The dialog's own last-window-closed handler quits when no update is + // available; quit explicitly so the test cannot hang if an update is + // available (the dialog then shows itself and waits for the user) + QCoreApplication::quit(); + }); + app->exec(); + + QVERIFY2(dialogAliveAtLastWindowClosed, "the UpdateDialog must still be alive when the last window closes so it can offer an update at that point - see #9388"); + QVERIFY2(dialog.isNull(), "UpdateDialog must be destroyed when the application quits: deleting it any later (from ~Updater, inside the application's destructor) corrupts the heap - see #9122"); +} + +QTEST_APPLESS_MAIN(NewReleaseDialogTeardownTest) +#include "NewReleaseDialogTeardownTest.moc" diff --git a/test/functional_tests/PackageRemovalSaveTeardownTest.cpp b/test/functional_tests/PackageRemovalSaveTeardownTest.cpp new file mode 100644 index 000000000..2d7565224 --- /dev/null +++ b/test/functional_tests/PackageRemovalSaveTeardownTest.cpp @@ -0,0 +1,403 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression test for the profile save that uninstalling a package puts off to + * the next event loop pass outliving the profile (#9653). + * + * Uninstalling a package cannot save the profile there and then - the + * asynchronous save mechanism would be handed a package that has just been + * taken out of memory - so the save is deferred. Closing Mudlet right after an + * uninstall then destroys the Host while that save is still owed, and the save + * ran anyway: against a freed Host, reading its writer map. Under + * AddressSanitizer that is a heap-use-after-free at + * Host::pendingXmlSaveFutures(); in a release build it is a crash or silent + * memory corruption on the way out, i.e. a "Mudlet crashed when I closed it" + * report. + * + * The two tests here pin both halves of what the fix has to hold true: the + * deferred save still happens for a profile that stays up, and nothing of it is + * left to run once the profile has been closed and its Host destroyed. The + * report itself needs the whole application to shut down (the queued call is + * delivered by the event loop pass after mudlet::closeEvent() has returned), + * which is what the busted package specs arrange; what this file adds is the + * contract the fix rests on, and a sanitizer run over the uninstall/close/ + * destroy/pump sequence itself. + * + * Run with: ctest -R PackageRemovalSaveTeardownTest -V + */ + +#include <QtTest/QtTest> + +#include <QTemporaryDir> +#include <chrono> +#include <zip.h> + +#include "Host.h" +#include "AliasUnit.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TelnetServerStub.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForPackageRemovalSaveTeardownTest(); + +class PackageRemovalSaveTeardownTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mProfileName = qsl("PackageRemovalSaveTeardown-Test"); + const QString mLocalhost = qsl("localhost"); + QString mPort; // the stub's actual ephemeral port, assigned in initTestCase() + // The refusal test below installs an archive that names itself ".." - that + // has to happen nowhere near the developer's own profiles. + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + + static void deleteProfileDirectory(const QString& profileName) + { + QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName)); + if (dir.exists()) { + dir.removeRecursively(); + } + } + + static QStringList savedProfileFiles(const QString& profileName) { return QDir(mudlet::getMudletPath(enums::profileXmlFilesPath, profileName)).entryList(QStringList{qsl("*.xml")}, QDir::Files); } + + // Whether needle appears in the profile that was saved last - what actually + // landed on disk, rather than what a save signal says was attempted. + static bool lastSavedProfileContains(const QString& profileName, const QString& needle) + { + const QDir directory(mudlet::getMudletPath(enums::profileXmlFilesPath, profileName)); + const QStringList saved = directory.entryList(QStringList{qsl("*.xml")}, QDir::Files, QDir::Name); + if (saved.isEmpty()) { + return false; + } + QFile file(directory.absoluteFilePath(saved.last())); + if (!file.open(QFile::ReadOnly | QFile::Text)) { + return false; + } + return QString::fromUtf8(file.readAll()).contains(needle); + } + + // Utility function to manually start a profile like a user would do via the GUI + void startProfile(const QString& profileName, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), profileName); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + } + + // The package itself is beside the point here - what matters is that + // uninstallPackage() has something to take away, and so owes the profile a + // save afterwards. + void uninstallPackageOwingASave(const QString& packageName) + { + mpHost->waitForProfileSave(); + mpHost->mInstalledPackages << packageName; + QVERIFY2(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package), "The package could not be uninstalled"); + QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "The package is still installed"); + QVERIFY2(mpHost->hasPendingProfileSave(), "Uninstalling a package left the profile no save to do"); + } + + // Writes an archive holding one file, i.e. one installPackage() unpacks and + // then refuses, having registered nothing from it. + static bool writeArchive(const QString& path, const QString& entryName, const QByteArray& contents) + { + int errorCode = 0; + zip* archive = zip_open(path.toUtf8().constData(), ZIP_CREATE | ZIP_TRUNCATE, &errorCode); + if (!archive) { + return false; + } + zip_source* source = zip_source_buffer(archive, contents.constData(), contents.size(), 0); + if (!source || zip_file_add(archive, entryName.toUtf8().constData(), source, ZIP_FL_ENC_UTF_8) < 0) { + zip_source_free(source); + zip_discard(archive); + return false; + } + return zip_close(archive) == 0; + } + + // ...specifically one whose config.lua renames the package to declaredName. + static bool writeConfigOnlyArchive(const QString& path, const QString& declaredName) { return writeArchive(path, qsl("config.lua"), qsl("mpackage = \"%1\"\n").arg(declaredName).toUtf8()); } + + QString profileFilePath(const QString& relativePath) const { return qsl("%1/%2").arg(mudlet::getMudletPath(enums::profileHomePath, mProfileName), relativePath); } + +private slots: + void initTestCase() + { + initializeQRCResourcesForPackageRemovalSaveTeardownTest(); + + // Keep the test hermetic: point the config dir resolution at a temporary + // directory instead of the user's real profiles - one of the tests below + // drives an archive that tries to have the profiles folder deleted. + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator"))); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mProfileName); + + startProfile(mProfileName, mLocalhost, mPort); + QVERIFY2(mpHost, "No active host after profile creation"); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mProfileName); + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // The save is deferred, not dropped: a profile that stays up has to end up + // with the uninstall written out. Without this the test below could be + // passed by never saving at all. + void test_deferredSaveRunsWhileTheProfileIsUp() + { + QSignalSpy saveSpy(mpHost, &Host::profileSaveStarted); + uninstallPackageOwingASave(qsl("uninstall-save-deferred")); + QCOMPARE(saveSpy.count(), 0); // the point of the deferral: not saved on the spot + + QTRY_VERIFY_WITH_TIMEOUT(saveSpy.count() >= 1, 5000); + mpHost->waitForProfileSave(); + } + + // A batch of uninstalls owes the profile one save between them, not one + // each: restarting the timer is what the old "only one timer is running" + // flag did, and a profile save is expensive enough that the package specs + // are shaped around how many of them a run does. + void test_aBatchOfUninstallsOwesOneSave() + { + QSignalSpy saveSpy(mpHost, &Host::profileSaveStarted); + uninstallPackageOwingASave(qsl("uninstall-save-batch-one")); + // no pumping in between, so all three land in the same event loop pass + mpHost->mInstalledPackages << qsl("uninstall-save-batch-two") << qsl("uninstall-save-batch-three"); + QVERIFY(mpHost->uninstallPackage(qsl("uninstall-save-batch-two"), enums::PackageModuleType::Package)); + QVERIFY(mpHost->uninstallPackage(qsl("uninstall-save-batch-three"), enums::PackageModuleType::Package)); + + QTRY_VERIFY_WITH_TIMEOUT(saveSpy.count() >= 1, 5000); + mpHost->waitForProfileSave(); + QCOMPARE(saveSpy.count(), 1); + } + + // Refusing an archive that installed nothing takes the folder it unpacked + // away again (#9654) - and nothing else. The package name can be whatever an + // untrusted archive's config.lua says, and ".." names the folder that holds + // every profile the user has. + void test_refusingAnArchiveOnlyRemovesItsOwnFolder() + { + QTemporaryDir archiveDir; + QVERIFY2(archiveDir.isValid(), "Could not create a temporary directory for the test archive"); + const QString archivePath = archiveDir.filePath(qsl("uninstall-save-escape.mpackage")); + QVERIFY2(writeConfigOnlyArchive(archivePath, qsl("..")), "Could not write the test archive"); + + mpHost->waitForProfileSave(); // an install during a save is postponed and answered with a bare true + const QString profileHome = mudlet::getMudletPath(enums::profileHomePath, mProfileName); + const QString profilesDirectory = QFileInfo(profileHome).absolutePath(); + + auto [ok, message] = mpHost->installPackage(archivePath, enums::PackageModuleType::Package, true); + QVERIFY2(!ok, "An archive holding no package was installed"); + QVERIFY2(QDir(profilesDirectory).exists(), "Refusing the archive took the folder holding every profile with it"); + QVERIFY2(QDir(profileHome).exists(), "Refusing the archive took the profile with it"); + QVERIFY2(!savedProfileFiles(mProfileName).isEmpty(), "Refusing the archive took the saved profile with it"); + } + + // ...and it may only remove a folder it made itself. The package name is the + // archive's own file name, and then whatever its config.lua says, so it can + // just as well be "map" - the folder the profile keeps the user's maps in. + void test_refusingAnArchiveLeavesFoldersItDidNotMake() + { + const QString mapFolder = profileFilePath(qsl("map")); + const QString mapFile = qsl("%1/spec-map.dat").arg(mapFolder); + QVERIFY2(QDir().mkpath(mapFolder), "Could not create the map folder the profile would have"); + QFile map(mapFile); + QVERIFY2(map.open(QFile::WriteOnly), "Could not write the map file this test is about"); + map.write("map data that was here before any package was installed"); + map.close(); + + QTemporaryDir archiveDir; + QVERIFY2(archiveDir.isValid(), "Could not create a temporary directory for the test archives"); + mpHost->waitForProfileSave(); // an install during a save is postponed and answered with a bare true + + // named through config.lua, from an archive called something harmless + const QString viaConfig = archiveDir.filePath(qsl("uninstall-save-mapgrab.mpackage")); + QVERIFY2(writeConfigOnlyArchive(viaConfig, qsl("map")), "Could not write the test archive"); + auto [configOk, configMessage] = mpHost->installPackage(viaConfig, enums::PackageModuleType::Package, true); + QVERIFY2(!configOk, "An archive holding no package was installed"); + QVERIFY2(QFile::exists(mapFile), "Refusing the archive took the profile's map folder with it"); + // the folder the install did make is this one, and it does have to go + QVERIFY2(!QDir(profileFilePath(qsl("uninstall-save-mapgrab"))).exists(), "Refusing the archive left the folder it unpacked behind"); + + // ...and the same through the archive's file name alone, no config.lua + mpHost->waitForProfileSave(); + const QString viaFileName = archiveDir.filePath(qsl("map.mpackage")); + QVERIFY2(writeArchive(viaFileName, qsl("readme.txt"), QByteArray("no package in here")), "Could not write the test archive"); + auto [fileNameOk, fileNameMessage] = mpHost->installPackage(viaFileName, enums::PackageModuleType::Package, true); + QVERIFY2(!fileNameOk, "An archive holding no package was installed"); + QVERIFY2(QFile::exists(mapFile), "Refusing the archive took the profile's map folder with it"); + } + + // The refusal is about archives nothing could be read out of, not about + // archives whose XML turns out to be no good - those are a different case, + // and one this deliberately leaves alone. + void test_anArchiveWithABadXmlIsStillARemovablePackage() + { + QTemporaryDir archiveDir; + QVERIFY2(archiveDir.isValid(), "Could not create a temporary directory for the test archives"); + + // 1. well-formed XML that is not a Mudlet package at all. XMLimport only + // reports the XML reader's own errors, so the import of this one + // SUCCEEDS - checking the import result would not refuse it either. + mpHost->waitForProfileSave(); + const QString notAPackage = archiveDir.filePath(qsl("spec-notapackage.mpackage")); + QVERIFY2(writeArchive(notAPackage, qsl("spec-notapackage.xml"), QByteArray("<?xml version=\"1.0\"?>\n<something-else/>\n")), "Could not write the test archive"); + auto [notAPackageOk, notAPackageMessage] = mpHost->installPackage(notAPackage, enums::PackageModuleType::Package, true); + QVERIFY2(notAPackageOk, qPrintable(notAPackageMessage)); + QVERIFY2(mpHost->mInstalledPackages.contains(qsl("spec-notapackage")), "The package was not registered"); + mpHost->waitForProfileSave(); // installing a package saves, and an uninstall during a save is refused + QVERIFY2(mpHost->uninstallPackage(qsl("spec-notapackage"), enums::PackageModuleType::Package), "The package could not be uninstalled"); + QVERIFY2(!QDir(profileFilePath(qsl("spec-notapackage"))).exists(), "Uninstalling left the package folder behind"); + + // 2. XML the reader does fail on, after it has already read items out of + // it. The import answers false, but the alias it created is in the + // profile - refusing the archive here would delete the folder and + // strand what was imported, and the package is registered either way, + // so it is listed and can be uninstalled. That is what #9654 was about. + mpHost->waitForProfileSave(); + const QString truncated = archiveDir.filePath(qsl("spec-truncatedxml.mpackage")); + const QByteArray truncatedXml = QByteArray("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<!DOCTYPE MudletPackage>\n" + "<MudletPackage version=\"1.001\">\n" + "<AliasPackage>\n" + "<Alias isActive=\"yes\" isFolder=\"no\">\n" + "<name>spec-truncatedxml alias</name>\n" + "<script>send(\"hello\")</script>\n" + "<command></command>\n" + "<packageName></packageName>\n" + "<regex>^spec-truncatedxml$</regex>\n" + "</Alias>\n" + "</AliasPackage>\n" + "<ActionPackage"); + QVERIFY2(writeArchive(truncated, qsl("spec-truncatedxml.xml"), truncatedXml), "Could not write the test archive"); + auto [truncatedOk, truncatedMessage] = mpHost->installPackage(truncated, enums::PackageModuleType::Package, true); + QVERIFY2(truncatedOk, qPrintable(truncatedMessage)); + QVERIFY2(mpHost->getAliasUnit()->findFirstAlias(qsl("spec-truncatedxml alias")), "The alias read before the XML gave out was not created"); + QVERIFY2(mpHost->mInstalledPackages.contains(qsl("spec-truncatedxml")), "The package was not registered"); + mpHost->waitForProfileSave(); + QVERIFY2(mpHost->uninstallPackage(qsl("spec-truncatedxml"), enums::PackageModuleType::Package), "The package could not be uninstalled"); + QVERIFY2(!QDir(profileFilePath(qsl("spec-truncatedxml"))).exists(), "Uninstalling left the package folder behind"); + } + + // ...and closing the profile straight after an uninstall must leave nothing + // of that save behind: it would run on a destroyed Host. + void test_deferredSaveDoesNotOutliveTheProfile() + { + const QString packageName = qsl("uninstall-save-teardown"); + QSignalSpy saveSpy(mpHost, &Host::profileSaveStarted); + uninstallPackageOwingASave(packageName); + + // The close path Mudlet takes when the application is closed + // (mudlet::closeEvent): forceClose() keeps TMainConsole::closeEvent() + // from asking whether to save, which would block on a modal dialog. + // deleteHost() is the step of the mudlet::closeHost() that follows which + // destroys the Host - the rest of it is tab and dock bookkeeping, and is + // private to mudlet. + mpHost->forceClose(); + QVERIFY2(mpHost->requestClose(), "Closing the profile was refused"); + QVERIFY2(!mpHost->hasPendingProfileSave(), "Closing the profile left a package save still owed"); + // Dropping that save is only right because the uninstall reached the disk + // on the way out - by the close's own save, or by the deferred one going + // first. Assert the profile that was written, not that a save was tried: + QVERIFY2(saveSpy.count() >= 1, "Closing the profile after an uninstall saved it nowhere"); + QVERIFY2(!lastSavedProfileContains(mProfileName, packageName), "The saved profile still carries the uninstalled package"); + mpHost = nullptr; + mudlet::self()->getHostManager().deleteHost(mProfileName); + + // Nothing the uninstall queued may reach the destroyed Host now. Under + // AddressSanitizer a queued save that does reach it aborts the run here; + // without the sanitizer, the save it writes is what gives it away. + const QStringList savedBefore = savedProfileFiles(mProfileName); + QTest::qWait(500ms); + QCOMPARE(savedProfileFiles(mProfileName), savedBefore); + } +}; + +void initializeQRCResourcesForPackageRemovalSaveTeardownTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "PackageRemovalSaveTeardownTest.moc" +QTEST_MAIN(PackageRemovalSaveTeardownTest) diff --git a/test/functional_tests/PackageSelfRemovalTest.cpp b/test/functional_tests/PackageSelfRemovalTest.cpp new file mode 100644 index 000000000..2028bc0cb --- /dev/null +++ b/test/functional_tests/PackageSelfRemovalTest.cpp @@ -0,0 +1,674 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression test for use-after-free when a package uninstalls itself from its + * own timer script or event-handler script (#9337 class, the remaining timer + * and script cases). + * + * Package auto-updaters commonly call uninstallPackage()+installPackage() on + * their own package from one of the package's own items. Since the + * *Unit::uninstall() methods started deleting items immediately (instead of + * just unregistering them), doing that from a timer deleted the very TTimer + * whose execute() was still on the call stack; doing it from an event handler + * deleted TScript objects that Host::raiseEvent() was still iterating over; and + * doing it from a script's top-level body deleted the very TScript that + * compileScript() was in the middle of compiling - heap corruption every way. + * TriggerUnit/AliasUnit/KeyUnit gained a processing-depth deferral in #9383; + * this covers TimerUnit and ScriptUnit (both the event-dispatch and the + * compile-time body paths). + * + * Under AddressSanitizer the pre-fix code aborts with heap-use-after-free + * inside TTimer::execute() / Host::raiseEvent() / TScript::compileScript(); with + * the deferral in place all scenarios complete cleanly. + * + * The second half covers the other side of that deferral: an item whose delete + * is outstanding is still registered, and must not be written back into the + * profile by a save taken before the unit goes idle. + * + * Run with: ctest -R PackageSelfRemovalTest -V + */ + +#include <QtTest/QtTest> + +#include <QScopeGuard> +#include <QTemporaryDir> + +#include "ActionUnit.h" +#include "AliasUnit.h" +#include "Host.h" +#include "HostManager.h" +#include "LuaInterface.h" +#include "MudletInstanceCoordinator.h" +#include "ScriptUnit.h" +#include "TAction.h" +#include "TAlias.h" +#include "TEvent.h" +#include "TScript.h" +#include "TTimer.h" +#include "TTrigger.h" +#include "TimerUnit.h" +#include "TriggerUnit.h" +#include "VarUnit.h" +#include "XMLexport.h" +#include "XMLimport.h" +#include "mudlet.h" + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lauxlib.h> +#include <lua5.1/lua.h> +#else +#include <lauxlib.h> +#include <lua.h> +#endif +} + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForPackageSelfRemovalTest(); + +// TriggerUnit only holds its depth inside processDataStream(), so a save at +// depth has to come from a trigger's own script. Stands in for the Lua +// saveProfile() one would call - a bare test Host has no console for that. +static Host* gpMidPassExportHost = nullptr; +static QString gMidPassExportPath; +static QString gMidPassExportedXml; + +static int exportProfileMidPass(lua_State* L) +{ + Q_UNUSED(L) + gMidPassExportedXml.clear(); + if (!gpMidPassExportHost) { + return 0; + } + auto writer = std::make_shared<XMLexport>(gpMidPassExportHost); + // variables included: the only export here that builds the variable tree, + // and it does so with a Lua call frame live + if (!writer->exportPackage(gMidPassExportPath, true, false)) { + qWarning() << "exportProfileMidPass() - the export itself failed"; + return 0; + } + QFile file(gMidPassExportPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "exportProfileMidPass() - could not read back" << gMidPassExportPath; + return 0; + } + gMidPassExportedXml = QString::fromUtf8(file.readAll()); + file.close(); + QFile::remove(gMidPassExportPath); + return 0; +} + +class PackageSelfRemovalTest : public QObject +{ + Q_OBJECT + +private: + const QString mProfileName = qsl("PackageSelfRemoval-Test"); + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + Host* mpHost = nullptr; + +private slots: + void initTestCase() + { + initializeQRCResourcesForPackageSelfRemovalTest(); + + // Keep the test hermetic: point the config dir resolution at a + // temporary directory instead of the user's real profiles. + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host"); + mpHost = mudlet::self()->getHostManager().getHost(mProfileName); + QVERIFY(mpHost); + // A bare Host blocks script compilation until the full profile boot + // would normally clear this; the test's scripts need to compile: + mpHost->mBlockScriptCompile = false; + // NB: mLoadedOk is left false on purpose - the deferred saveProfile() + // that uninstallPackage() schedules then declines to run, which this + // console-less test Host could not service anyway. + createKeeperItems(); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // A package's event-handler script uninstalls its own package while + // Host::raiseEvent() is mid-dispatch. The second handler in the same + // package is what the pre-fix code would have called through a freed + // TScript pointer. + void test_scriptEventHandlerSelfUninstall() + { + const QString packageName = qsl("selfuninstall-script"); + mpHost->mInstalledPackages << packageName; + + auto pUninstaller = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pUninstaller); + pUninstaller->mPackageName = packageName; + pUninstaller->setName(qsl("selfUninstallHandler")); + QVERIFY2(pUninstaller->setScript(qsl("function selfUninstallHandler(event)\n uninstallPackage(\"%1\")\nend\n").arg(packageName)), "uninstaller handler script failed to compile"); + pUninstaller->setEventHandlerList(QStringList{qsl("testSelfUninstallEvent")}); + pUninstaller->setIsActive(true); + + auto pBystander = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pBystander); + pBystander->mPackageName = packageName; + pBystander->setName(qsl("selfUninstallBystander")); + QVERIFY2(pBystander->setScript(qsl("function selfUninstallBystander(event)\nend\n")), "bystander handler script failed to compile"); + pBystander->setEventHandlerList(QStringList{qsl("testSelfUninstallEvent")}); + pBystander->setIsActive(true); + + TEvent event{}; + event.mArgumentList.append(qsl("testSelfUninstallEvent")); + event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING); + // Pre-fix this dispatch freed both TScripts under raiseEvent()'s feet: + mpHost->raiseEvent(event); + + QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled"); + // The deferred deletes must have been flushed once the dispatch ended: + QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallHandler")).empty(), "uninstalled script is still registered"); + QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallBystander")).empty(), "uninstalled script is still registered"); + } + + // A package script's TOP-LEVEL body (not an event handler) uninstalls its own + // package while it is being compiled - the path profile boot and reset take + // when they run "all the Lua code outside of functions" via ScriptUnit::compileAll(). + // Pre-fix, ScriptUnit::uninstall() deleted the script immediately: compileScript() + // then wrote to the freed TScript (heap-use-after-free WRITE at TScript::compileScript), + // and compileAll()'s range-for walked onto the freed std::list node it had unlinked. + void test_scriptBodySelfUninstallOnCompile() + { + const QString packageName = qsl("selfuninstall-script-compile"); + mpHost->mInstalledPackages << packageName; + + // Defer compilation so the bodies run through compileAll() below rather than + // from setScript() here, mirroring how a freshly loaded package's scripts are + // compiled at profile boot (mudlet::loadProfile) and reset (Host::resetProfile_phase2): + mpHost->mBlockScriptCompile = true; + + auto pUninstaller = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pUninstaller); + pUninstaller->mPackageName = packageName; + pUninstaller->setName(qsl("selfUninstallOnCompile")); + // A bare uninstallPackage() at the top level runs the moment the script is compiled: + pUninstaller->setScript(qsl("uninstallPackage(\"%1\")").arg(packageName)); + pUninstaller->setIsActive(true); + + // A second script in the same package, registered AFTER the uninstaller, is + // the node the pre-fix immediate delete unlinks from under compileAll()'s loop: + auto pBystander = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pBystander); + pBystander->mPackageName = packageName; + pBystander->setName(qsl("selfUninstallCompileBystander")); + pBystander->setScript(qsl("local noop = true\n")); + pBystander->setIsActive(true); + + mpHost->mBlockScriptCompile = false; + // Pre-fix this trips heap-use-after-free; post-fix the delete is deferred until + // after the loop and then flushed by compileAll(): + mpHost->getScriptUnit()->compileAll(); + + QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled"); + // The deferred deletes must have been flushed at the end of compileAll(): + QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallOnCompile")).empty(), "uninstalled script is still registered"); + QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallCompileBystander")).empty(), "uninstalled bystander script is still registered"); + } + + // A package script's top-level body uninstalls its own package from a plain + // setScript() compile - the path permScript()/setScript() take when invoked from + // an alias, key or the command line, none of which sit inside compileAll(), the + // editor's saveScript(), or raiseEvent(). Pre-fix this hit the same + // heap-use-after-free in compileScript() as the compileAll() case. The compile + // guard defers the delete here too; because this entry point has no synchronous + // flush of its own, the deferred script lingers (deactivated) until a catch-all + // flush - ScriptUnit::doCleanup() via Host::incomingStreamProcessor()/ + // slot_purgeTemps(), or the doCleanup() Host::uninstallPackage()'s queued save runs + // - collects it, which must happen without leaking or double-freeing. + void test_scriptBodySelfUninstallFromSetScript() + { + const QString packageName = qsl("selfuninstall-script-setscript"); + mpHost->mInstalledPackages << packageName; + + auto pUninstaller = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pUninstaller); + pUninstaller->mPackageName = packageName; + pUninstaller->setName(qsl("selfUninstallFromSetScript")); + // mBlockScriptCompile is false, so setScript() compiles and runs the top-level + // body immediately - uninstallPackage() fires from inside compileScript(): + pUninstaller->setScript(qsl("uninstallPackage(\"%1\")").arg(packageName)); + + QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled"); + + // No synchronous flush point covers this entry, so the script is still + // registered (deferred, deactivated) right after setScript() returns: + QVERIFY2(!mpHost->getScriptUnit()->findItems(qsl("selfUninstallFromSetScript")).empty(), "self-uninstalling script should still be deferred, not yet deleted"); + + // The catch-all flush (as wired into incomingStreamProcessor()/slot_purgeTemps()) + // must then collect it cleanly - no leak, no double-free: + mpHost->getScriptUnit()->doCleanup(); + QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallFromSetScript")).empty(), "deferred uninstalled script was not flushed"); + } + + // A package's timer script uninstalls its own package while + // TTimer::execute() is still on the call stack for that timer. Pre-fix, + // execute() would resume on a freed `this`. + void test_timerScriptSelfUninstall() + { + const QString packageName = qsl("selfuninstall-timer"); + mpHost->mInstalledPackages << packageName; + + auto pTimer = new TTimer(qsl("selfUninstallTimer"), QTime(0, 0, 0, 250), mpHost); + mpHost->getTimerUnit()->registerTimer(pTimer); + pTimer->mPackageName = packageName; + // The trailing error() is what makes this a genuine regression test: after + // uninstallPackage() has (pre-fix) freed this timer, the error aborts the + // Lua call so it returns false, and TTimer::execute() then resumes past the + // call and reads the freed `this` at `mpQTimer->stop()` - the heap-use-after-free + // the deferral prevents. Without the error the call returns cleanly and + // execute() never touches `this` again, so the bug would go undetected. + QVERIFY2(pTimer->setScript(qsl("uninstallPackage(\"%1\")\nerror(\"boom\")").arg(packageName)), "timer script failed to compile"); + pTimer->setIsActive(true); + pTimer->enableTimer(); + + // Let the timer fire and take its package (and itself) down: + QTRY_VERIFY_WITH_TIMEOUT(!mpHost->mInstalledPackages.contains(packageName), 5000); + + // Allow any queued activity (the declined deferred save, further timer + // ticks) to surface problems: + QTest::qWait(500); + // mudlet::slot_timerFires() flushes the deferred delete as soon as the + // uninstalling timer's execute() has finished, so by now the timer must + // be properly gone - not lingering deactivated where the next profile + // save would serialize it back in: + QVERIFY2(!mpHost->getTimerUnit()->findFirstTimer(qsl("selfUninstallTimer")), "uninstalled package timer is still registered"); + } + + // The trigger route, driven the whole way: the package's own trigger fires, + // uninstalls its package and saves, all inside the pass. Its export is the + // only one here that includes the variables, so it doubles as the check that + // the variable tree can be built from inside a live Lua call frame. + void test_saveFromTriggerScriptDoesNotResurrectItsPackage() + { + const QString packageName = qsl("resurrect-trigger"); + mpHost->mInstalledPackages << packageName; + + gpMidPassExportHost = mpHost; + gMidPassExportPath = qsl("%1/mid-pass-export.xml").arg(mConfigDir.path()); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_register(L, "qaExportProfileMidPass", exportProfileMidPass); + QCOMPARE(luaL_dostring(L, "midPassSavedVar = 'saved from inside the pass'"), 0); + mpHost->getLuaInterface()->getVarUnit()->savedVars.insert(qsl("midPassSavedVar")); + + auto pGroup = new TTrigger(nullptr, mpHost); + pGroup->setIsFolder(true); + pGroup->registerTrigger(); + pGroup->setName(qsl("resurrectTriggerGroup")); + pGroup->mPackageName = packageName; + pGroup->setIsActive(true); + + auto pKicker = new TTrigger(pGroup, mpHost); + pKicker->setRegexCodeList({qsl("^resurrect me$")}, {REGEX_PERL}); + pKicker->registerTrigger(); + QVERIFY2(pKicker->setScript(qsl("uninstallPackage(\"%1\")\nqaExportProfileMidPass()").arg(packageName)), "trigger script failed to compile"); + pKicker->setName(qsl("resurrectTriggerKicker")); + pKicker->setIsActive(true); + + // a sibling that never runs: the whole group must go, not just the one + // that fired + auto pBystander = new TTrigger(pGroup, mpHost); + pBystander->setRegexCodeList({qsl("^never matched$")}, {REGEX_PERL}); + pBystander->registerTrigger(); + pBystander->setName(qsl("resurrectTriggerBystander")); + pBystander->setIsActive(true); + + QVERIFY2(exportedProfileXml().contains(qsl("resurrectTriggerGroup")), "the trigger group should be in the profile before its package is uninstalled"); + + mpHost->getTriggerUnit()->processDataStream(qsl("resurrect me"), -1); + + QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled"); + QVERIFY2(!gMidPassExportedXml.isEmpty(), "the mid-pass export produced nothing to check"); + QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerGroup")), "a save taken mid-pass wrote the uninstalled package's trigger group back into the profile"); + QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerKicker")), "a save taken mid-pass wrote the uninstalled package's trigger back into the profile"); + QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerBystander")), "a save taken mid-pass wrote the uninstalled package's trigger back into the profile"); + const QString keeperError = keepersMissingFrom(gMidPassExportedXml); + QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError)); + QVERIFY2(gMidPassExportedXml.contains(qsl("saved from inside the pass")), "the variables were not read out of Lua by a save taken from inside a script"); + + QVERIFY2(mpHost->getTriggerUnit()->findItems(qsl("resurrectTriggerKicker")).empty(), "uninstalled trigger is still registered"); + + mpHost->getLuaInterface()->getVarUnit()->savedVars.remove(qsl("midPassSavedVar")); + gpMidPassExportHost = nullptr; + } + + // The alias route: a package shipping its own "uninstall" alias. + void test_saveFromAliasScriptDoesNotResurrectItsPackage() + { + const QString packageName = qsl("resurrect-alias"); + mpHost->mInstalledPackages << packageName; + + gpMidPassExportHost = mpHost; + gMidPassExportPath = qsl("%1/mid-pass-alias-export.xml").arg(mConfigDir.path()); + lua_register(mpHost->mLuaInterpreter.getLuaGlobalState(), "qaExportProfileMidPass", exportProfileMidPass); + + auto pAlias = new TAlias(qsl("resurrectAlias"), mpHost); + pAlias->setRegexCode(qsl("^resurrect me$")); + mpHost->getAliasUnit()->registerAlias(pAlias); + pAlias->mPackageName = packageName; + QVERIFY2(pAlias->setScript(qsl("uninstallPackage(\"%1\")\nqaExportProfileMidPass()").arg(packageName)), "alias script failed to compile"); + pAlias->setIsActive(true); + + QVERIFY2(exportedProfileXml().contains(qsl("resurrectAlias")), "the alias should be in the profile before its package is uninstalled"); + + mpHost->getAliasUnit()->processDataStream(qsl("resurrect me")); + + QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled"); + QVERIFY2(!gMidPassExportedXml.isEmpty(), "the mid-pass export produced nothing to check"); + QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectAlias")), "a save taken mid-pass wrote the uninstalled package's alias back into the profile"); + const QString keeperError = keepersMissingFrom(gMidPassExportedXml); + QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError)); + + QVERIFY2(!mpHost->getAliasUnit()->findFirstAlias(qsl("resurrectAlias")), "uninstalled alias is still registered"); + gpMidPassExportHost = nullptr; + } + + // The timer route. beginProcessing()/endProcessing() below are the calls + // TTimer::execute() wraps its whole callback in. + void test_saveDuringTimerCallbackDoesNotResurrectItsPackage() + { + const QString packageName = qsl("resurrect-timer"); + mpHost->mInstalledPackages << packageName; + + auto pTimer = new TTimer(qsl("resurrectTimer"), QTime(0, 0, 30), mpHost); + mpHost->getTimerUnit()->registerTimer(pTimer); + pTimer->mPackageName = packageName; + QVERIFY2(pTimer->setScript(qsl("local noop = true\n")), "timer script failed to compile"); + + QVERIFY2(exportedProfileXml().contains(qsl("resurrectTimer")), "the timer should be in the profile before its package is uninstalled"); + + QString xml; + { + mpHost->getTimerUnit()->beginProcessing(); + // a failed QVERIFY returns from the slot; a level left on would + // wedge every later test's doCleanup() + const auto depthGuard = qScopeGuard([this]() { + mpHost->getTimerUnit()->endProcessing(); + mpHost->getTimerUnit()->doCleanup(); + }); + QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package)); + xml = exportedProfileXml(); + } + + QVERIFY2(!xml.isEmpty(), "the export produced nothing to check"); + QVERIFY2(!xml.contains(qsl("resurrectTimer")), "a save taken during a timer callback wrote the uninstalled package's timer back into the profile"); + const QString keeperError = keepersMissingFrom(xml); + QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError)); + QVERIFY2(!mpHost->getTimerUnit()->findFirstTimer(qsl("resurrectTimer")), "uninstalled timer is still registered"); + } + + // The button route: TAction::execute() holds ActionUnit's depth the same way. + void test_saveDuringButtonScriptDoesNotResurrectItsPackage() + { + const QString packageName = qsl("resurrect-action"); + mpHost->mInstalledPackages << packageName; + + auto pAction = new TAction(qsl("resurrectAction"), mpHost); + mpHost->getActionUnit()->registerAction(pAction); + pAction->mPackageName = packageName; + QVERIFY2(pAction->setScript(qsl("local noop = true\n")), "button script failed to compile"); + + QVERIFY2(exportedProfileXml().contains(qsl("resurrectAction")), "the button should be in the profile before its package is uninstalled"); + + QString xml; + { + mpHost->getActionUnit()->beginProcessing(); + const auto depthGuard = qScopeGuard([this]() { + mpHost->getActionUnit()->endProcessing(); + mpHost->getActionUnit()->doCleanup(); + }); + QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package)); + xml = exportedProfileXml(); + } + + QVERIFY2(!xml.isEmpty(), "the export produced nothing to check"); + QVERIFY2(!xml.contains(qsl("resurrectAction")), "a save taken during a button script wrote the uninstalled package's button back into the profile"); + const QString keeperError = keepersMissingFrom(xml); + QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError)); + QVERIFY2(!mpHost->getActionUnit()->findAction(qsl("resurrectAction")), "uninstalled button is still registered"); + } + + // The event-handler route: Host::raiseEvent() holds ScriptUnit's depth. + void test_saveDuringEventDispatchDoesNotResurrectItsPackage() + { + const QString packageName = qsl("resurrect-script"); + mpHost->mInstalledPackages << packageName; + + auto pScript = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pScript); + pScript->mPackageName = packageName; + pScript->setName(qsl("resurrectScript")); + QVERIFY2(pScript->setScript(qsl("local noop = true\n")), "script failed to compile"); + + QVERIFY2(exportedProfileXml().contains(qsl("resurrectScript")), "the script should be in the profile before its package is uninstalled"); + + QString xml; + { + mpHost->getScriptUnit()->beginProcessing(); + const auto depthGuard = qScopeGuard([this]() { + mpHost->getScriptUnit()->endProcessing(); + mpHost->getScriptUnit()->doCleanup(); + }); + QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package)); + xml = exportedProfileXml(); + } + + QVERIFY2(!xml.isEmpty(), "the export produced nothing to check"); + QVERIFY2(!xml.contains(qsl("resurrectScript")), "a save taken during an event dispatch wrote the uninstalled package's script back into the profile"); + const QString keeperError = keepersMissingFrom(xml); + QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError)); + QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("resurrectScript")).empty(), "uninstalled script is still registered"); + } + + // Host::reloadModule() - reachable from Lua - uninstalls and reinstalls a + // module back to back, so from a script the old items are still registered + // when the new ones arrive. + void test_moduleSaveDuringReloadDoesNotDuplicateItsItems() + { + const QString moduleName = qsl("resurrect-module"); + registerModuleAs(moduleName); + QVERIFY2(importModuleTimerNamed(moduleName, qsl("moduleTimerBeforeReload")), "could not import the module's timer"); + + QVERIFY2(exportedModuleXml(moduleName).contains(qsl("moduleTimerBeforeReload")), "the module's timer should be in its file before the reload"); + + QString xml; + { + mpHost->getTimerUnit()->beginProcessing(); + const auto depthGuard = qScopeGuard([this]() { + mpHost->getTimerUnit()->endProcessing(); + mpHost->getTimerUnit()->doCleanup(); + }); + // the uninstall half: at depth the old timer only gets deactivated + QVERIFY(mpHost->uninstallPackage(moduleName, enums::PackageModuleType::ModuleSync)); + // ... and the reinstall half brings the module back with fresh items + registerModuleAs(moduleName); + QVERIFY2(importModuleTimerNamed(moduleName, qsl("moduleTimerAfterReload")), "could not re-import the module's timer"); + + xml = exportedModuleXml(moduleName); + } + + QVERIFY2(!xml.isEmpty(), "the module export produced nothing to check"); + QVERIFY2(xml.contains(qsl("moduleTimerAfterReload")), "the reloaded module's timer must be written to its file"); + QVERIFY2(!xml.contains(qsl("moduleTimerBeforeReload")), "a module save taken mid-reload wrote the pre-reload copy of the timer back into the module file"); + } + +private: + // Items of a package that is never uninstalled. Every other assertion here + // is an absence, so without these an over-broad filter passes the whole file + // while emptying the user's profile. + void createKeeperItems() + { + const QString keeperPackage = qsl("keeper-package"); + mpHost->mInstalledPackages << keeperPackage; + + auto pTrigger = new TTrigger(nullptr, mpHost); + pTrigger->setRegexCodeList({qsl("^never matched$")}, {REGEX_PERL}); + pTrigger->registerTrigger(); + pTrigger->setName(qsl("keeperTrigger")); + pTrigger->mPackageName = keeperPackage; + + auto pAlias = new TAlias(qsl("keeperAlias"), mpHost); + pAlias->setRegexCode(qsl("^never matched$")); + mpHost->getAliasUnit()->registerAlias(pAlias); + pAlias->mPackageName = keeperPackage; + + auto pTimer = new TTimer(qsl("keeperTimer"), QTime(0, 0, 30), mpHost); + mpHost->getTimerUnit()->registerTimer(pTimer); + pTimer->mPackageName = keeperPackage; + + auto pAction = new TAction(qsl("keeperAction"), mpHost); + mpHost->getActionUnit()->registerAction(pAction); + pAction->mPackageName = keeperPackage; + + auto pScript = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pScript); + pScript->setName(qsl("keeperScript")); + pScript->mPackageName = keeperPackage; + } + + QString keepersMissingFrom(const QString& xml) const + { + for (const auto& name : {qsl("keeperTrigger"), qsl("keeperAlias"), qsl("keeperTimer"), qsl("keeperAction"), qsl("keeperScript")}) { + if (!xml.contains(name)) { + return qsl("a save taken while a delete was outstanding dropped \"%1\", which belongs to a package that is still installed").arg(name); + } + } + return {}; + } + + void registerModuleAs(const QString& moduleName) + { + mpHost->mInstalledModules[moduleName] = QStringList{qsl("%1/%2.xml").arg(mConfigDir.path(), moduleName), qsl("0")}; + mpHost->mModulesLoadedOk << moduleName; + } + + // The module-member flag is private to XMLimport, so a genuine module item + // can only be made by importing one: that creates the module's master folder + // per unit, and renaming the timer's tells the two copies apart. + bool importModuleTimerNamed(const QString& moduleName, const QString& itemName) + { + const QString path = qsl("%1/%2-import.xml").arg(mConfigDir.path(), itemName); + auto* pSeed = new TTimer(itemName, QTime(0, 0, 30), mpHost); + mpHost->getTimerUnit()->registerTimer(pSeed); + pSeed->setScript(qsl("local noop = true\n")); + const bool exported = XMLexport(pSeed).exportTimer(path); + mpHost->getTimerUnit()->unregisterTimer(pSeed); + delete pSeed; + if (!exported) { + return false; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + XMLimport importer(mpHost); + const bool imported = importer.importPackage(&file, moduleName, 1).first; + file.close(); + QFile::remove(path); + if (!imported) { + return false; + } + TTimer* pTimer = mpHost->getTimerUnit()->findFirstTimer(moduleName); + if (!pTimer) { + return false; + } + pTimer->setName(itemName); + return true; + } + + // Builds the document writeModuleXML() produces for a save and reads it back. + QString exportedModuleXml(const QString& moduleName) + { + const QString path = qsl("%1/module-export.xml").arg(mConfigDir.path()); + XMLexport writer(mpHost); + writer.writeModuleXML(moduleName); + if (!XMLexport::saveXmlDocToFile(path, *writer.takeExportDocument())) { + return {}; + } + return readBack(path); + } + + // The writers a profile save uses, without the console Host::saveProfile() + // would need. + QString exportedProfileXml() + { + const QString path = qsl("%1/profile-export.xml").arg(mConfigDir.path()); + auto writer = std::make_shared<XMLexport>(mpHost); + if (!writer->exportPackage(path, true, true)) { + return {}; + } + return readBack(path); + } + + static QString readBack(const QString& path) + { + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + const QString xml = QString::fromUtf8(file.readAll()); + file.close(); + QFile::remove(path); + return xml; + } +}; + +void initializeQRCResourcesForPackageSelfRemovalTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "PackageSelfRemovalTest.moc" +QTEST_MAIN(PackageSelfRemovalTest) diff --git a/test/functional_tests/PipelineBenchmark.cpp b/test/functional_tests/PipelineBenchmark.cpp index 7c65e16d3..4d14715ad 100644 --- a/test/functional_tests/PipelineBenchmark.cpp +++ b/test/functional_tests/PipelineBenchmark.cpp @@ -28,6 +28,9 @@ * corpus through the production cTelnet::loopbackTest() path and prints one * `METRIC <name> <value>` line per measurement. * + * `text_*`, `trigger_*` and `peak_rss_kb` come from a profile with the default + * packages suppressed; `defaults_*` from one carrying them. + * * Built with the functional tests but deliberately NOT registered with ctest by * default (report-only and slow); run it directly, or configure with * -DREGISTER_PERF_BENCHMARK=ON to also get it under ctest: @@ -271,8 +274,6 @@ private: return n; } - // loopbackTest() writes NUL bytes up to two past the data end, so the corpus - // is over-reserved in initTestCase(). double feedCorpusBestPass(Host* host, int passes) { double best = std::numeric_limits<double>::max(); @@ -333,9 +334,6 @@ private slots: initializeQRCResources(); mCorpus = generateCorpus(kCorpusLines, mCorpusLines); mCorpusBytes = mCorpus.size(); - // loopbackTest() writes NUL bytes past the data end; reserve slack so that - // stays within the allocation. - mCorpus.reserve(mCorpus.size() + 16); // An invariant, emitted here so it is present regardless of which bench // slots run: the compare script rejects an ASan-vs-release comparison. emitMetric("build_asan", static_cast<qint64>(BENCH_BUILD_ASAN)); @@ -369,6 +367,7 @@ private slots: { Host* host = startProfile(); QVERIFY(host); + QVERIFY(noTriggersAreRunningYet(host)); const double seconds = feedCorpusBestPass(host, kFeedPasses); mTextBestPassSeconds = seconds; @@ -388,11 +387,17 @@ private slots: { Host* host = startProfile(); QVERIFY(host); + QVERIFY(noTriggersAreRunningYet(host)); bool triggersOk = true; const int triggerCount = installTriggerSet(host, triggersOk); QVERIFY2(triggerCount > 0, "no triggers were installed"); QVERIFY2(triggersOk, "a trigger failed to compile, register or take its script"); + // trigger_overhead_ms subtracts the text pass, so the count reported has + // to be the count actually running. + const int rootTriggers = static_cast<int>(host->getTriggerUnit()->getTriggerRootNodeList().size()); + QVERIFY2(rootTriggers == triggerCount, + qPrintable(qsl("installed %1 root triggers but %2 are running - something else registered triggers on this profile").arg(triggerCount).arg(rootTriggers))); const double seconds = feedCorpusBestPass(host, kFeedPasses); const int bufferedLines = host->mpConsole->buffer.getLastLineNumber(); @@ -408,7 +413,6 @@ private slots: QVERIFY(sentinel->setScript(qsl("benchSentinelFired = true"))); QVERIFY(sentinel->state()); QByteArray probe{"__bench_sentinel__\r\n"}; - probe.reserve(probe.size() + 16); host->mTelnet.loopbackTest(probe); QVERIFY2(host->getLuaInterpreter()->compileAndExecuteScript(qsl("assert(benchSentinelFired)")), "sentinel trigger did not fire - the trigger engine is not seeing pipeline data"); @@ -430,6 +434,7 @@ private slots: { Host* host = startProfile(); QVERIFY(host); + QVERIFY(noTriggersAreRunningYet(host)); // Feed one pass so the peak still reflects pipeline work when this slot // runs on its own. feedCorpusBestPass(host, 1); @@ -442,10 +447,59 @@ private slots: } } -private: - // Mirrors the profile-creation helper the other functional tests use. - Host* startProfile() + // Must run after benchPeakMemory: VmHWM is process-wide and monotonic, so + // the bare peak_rss_kb has to be read before any packaged profile exists. + // defaults_peak_rss_kb is then the high-water mark including this pass, and + // its excess over peak_rss_kb is what the packages cost. + void benchDefaultPackages() { + Host* host = startProfile(DefaultPackages::Install); + QVERIFY(host); + const int rootTriggers = static_cast<int>(host->getTriggerUnit()->getTriggerRootNodeList().size()); + // Needs a fresh HOME/XDG_CONFIG_HOME: the starter UI is gated on + // mudlet::experiencedMudletPlayer(), which answers from the machine's + // own Mudlet history, and without it this slot silently measures the + // same thing as benchTextPipeline. A trigger count would not catch that + // - the other default packages register root folders of their own. + QVERIFY2(host->mInstalledPackages.contains(qsl("mudlet-base-ui")), + "the starter UI is not installed, so this profile is not the one a new user gets and defaults_* " + "would describe something else entirely. Re-run under a fresh HOME and XDG_CONFIG_HOME."); + + const double seconds = feedCorpusBestPass(host, kFeedPasses); + const int bufferedLines = host->mpConsole->buffer.getLastLineNumber(); + QVERIFY2(bufferedLines > 1000, qPrintable(qsl("console buffer only holds %1 lines - the pipeline did not process the corpus").arg(bufferedLines))); + + emitMetric("defaults_root_triggers", static_cast<qint64>(rootTriggers)); + emitMetric("defaults_text_lines_per_sec", mCorpusLines / seconds); + emitMetric("defaults_text_best_pass_ms", seconds * 1000.0); + const qint64 peakRssKb = readPeakRssKb(); + if (peakRssKb >= 0) { + emitMetric("defaults_peak_rss_kb", peakRssKb); + } + } + +private: + enum class DefaultPackages { Skip, Install }; + + // Called before the benchmark installs any of its own, so anything running + // came from elsewhere and would be timed as pipeline cost. + bool noTriggersAreRunningYet(Host* host) + { + const size_t rootTriggers = host->getTriggerUnit()->getTriggerRootNodeList().size(); + if (rootTriggers == 0) { + return true; + } + qWarning("%s", + qPrintable(qsl("%1 root triggers are running on a profile that should have none - a package or a " + "leftover profile is being measured as pipeline cost") + .arg(rootTriggers))); + return false; + } + + // Mirrors the profile-creation helper the other functional tests use. + Host* startProfile(DefaultPackages defaultPackages = DefaultPackages::Skip) + { + mudlet::self()->mSkipDefaultPackageInstall = (defaultPackages == DefaultPackages::Skip); const QString port = QString::number(mPort); QTimer::singleShot(0, qApp, [this, port]() { mudlet::self()->startAutoLogin({}); diff --git a/test/functional_tests/ProfileDeletionSafetyTest.cpp b/test/functional_tests/ProfileDeletionSafetyTest.cpp new file mode 100644 index 000000000..e31cc2ac3 --- /dev/null +++ b/test/functional_tests/ProfileDeletionSafetyTest.cpp @@ -0,0 +1,524 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Removing a profile from the connection dialog must never reach outside that + * one profile's folder. A name like "." addresses the profiles directory + * itself and ".." the whole Mudlet configuration directory, so a name that is + * not a folder of its own must never reach removeRecursively(). Also covers + * the confirmation the user gets before any of their data goes, and the names + * that must keep working. + * + * Run with: ctest -R ProfileDeletionSafetyTest -V + */ + +#include <QtTest/QtTest> + +#include "MudletInstanceCoordinator.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForProfileDeletionSafetyTest(); + +class ProfileDeletionSafetyTest : public QObject +{ + Q_OBJECT + +private: + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + const QString mKeeper = qsl("QA Keeper"); + + QString profilePath(const QString& profile) const { return mudlet::getMudletPath(enums::profileHomePath, profile); } + + // setupConfig() consults portable.txt ahead of the XDG logic; skip rather + // than run against an unexpected config dir (see ConfigDirOverrideTest) + bool portableMarkerPresent() const + { + return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath())); + } + + void makeProfileWithSavedGame(const QString& profile) const + { + QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileXmlFilesPath, profile))); + QFile savedGame(qsl("%1/%2.xml").arg(mudlet::getMudletPath(enums::profileXmlFilesPath, profile), profile)); + QVERIFY(savedGame.open(QIODevice::WriteOnly)); + savedGame.write("<MudletPackage></MudletPackage>"); + savedGame.close(); + } + + // The list takes ownership, and rebuilding it drops the entry again + void addListEntry(dlgConnectionProfiles* dlg, const QString& profile) const + { + auto* item = new QListWidgetItem(); + item->setData(dlgConnectionProfiles::csmNameRole, profile); + dlg->listWidget_profiles->insertItem(0, item); + dlg->listWidget_profiles->setCurrentItem(item); + } + + // slot_itemClicked() ignores a repeat of the profile it was last given + // within 100ms, and that guard is static, so it outlives the dialog + void selectProfile(dlgConnectionProfiles* dlg, const QString& profile) const + { + const auto items = dlg->findData(*dlg->listWidget_profiles, profile, dlgConnectionProfiles::csmNameRole); + QVERIFY2(!items.isEmpty(), qPrintable(qsl("profile '%1' was not listed").arg(profile))); + dlg->listWidget_profiles->setCurrentItem(items.first()); + QTest::qWait(120); + dlg->slot_itemClicked(items.first()); + } + + QDialog* confirmation(dlgConnectionProfiles* dlg) const { return dlg->findChild<QDialog*>(qsl("delete_profile_confirmation")); } + + // The .ui wires the delete button's clicked() to the dialog's accept() + void confirmRemovalOf(dlgConnectionProfiles* dlg, const QString& profile) const + { + auto* confirmationDialog = confirmation(dlg); + QVERIFY(confirmationDialog); + auto* nameEntry = confirmationDialog->findChild<QLineEdit*>(qsl("delete_profile_lineedit")); + auto* deleteButton = confirmationDialog->findChild<QPushButton*>(qsl("delete_button")); + QVERIFY(nameEntry && deleteButton); + + nameEntry->setText(profile.left(profile.size() - 1)); + QVERIFY2(!deleteButton->isEnabled(), "a partial profile name enabled the delete button"); + + nameEntry->setText(profile); + QVERIFY2(deleteButton->isEnabled(), "typing the profile name did not enable this confirmation's delete button"); + deleteButton->click(); + } + + void removeProfileAndConfirm(dlgConnectionProfiles* dlg, const QString& profile) const + { + dlg->slot_deleteProfile(); + if (confirmation(dlg)) { + confirmRemovalOf(dlg, profile); + } + } + + dlgConnectionProfiles* openDialog() const + { + auto* dlg = new dlgConnectionProfiles(); + dlg->show(); + dlg->fillout_form(); + return dlg; + } + + void closeDialog(dlgConnectionProfiles* dlg) const + { + dlg->deleteLater(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + + // The Connect and Offline buttons are the dialog's only AcceptRole buttons + bool acceptButtonsEnabled(dlgConnectionProfiles* dlg) const + { + for (auto* button : dlg->dialog_buttonbox->buttons()) { + if (dlg->dialog_buttonbox->buttonRole(button) == QDialogButtonBox::AcceptRole && !button->isEnabled()) { + return false; + } + } + return true; + } + +private slots: + void initTestCase() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - cannot redirect the config dir for this test"); + } + initializeQRCResourcesForProfileDeletionSafetyTest(); + + QVERIFY(mConfigDir.isValid()); + // $XDG_CONFIG_HOME/mudlet/profiles is the opt-in that makes setupConfig() + // adopt it, so the test never goes near the user's own profiles + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + QCOMPARE(mudlet::getMudletPath(enums::mainPath), qsl("%1/mudlet").arg(mConfigDir.path())); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + makeProfileWithSavedGame(mKeeper); + mudlet::self()->writeProfileData(mKeeper, qsl("url"), qsl("mudlet.org")); + mudlet::self()->writeProfileData(mKeeper, qsl("port"), qsl("23")); + } + + void cleanupTestCase() + { + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + void test_removingCurrentDirectoryProfileKeepsEveryProfile() + { + auto* dlg = openDialog(); + addListEntry(dlg, qsl(".")); + + dlg->slot_deleteProfile(); + QVERIFY2(confirmation(dlg), "removal went ahead without asking"); + confirmRemovalOf(dlg, qsl(".")); + + QVERIFY2(QDir(mudlet::getMudletPath(enums::profilesPath)).exists(), "the profiles directory was deleted"); + QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was deleted"); + QVERIFY2(QFile::exists(qsl("%1/%2.xml").arg(mudlet::getMudletPath(enums::profileXmlFilesPath, mKeeper), mKeeper)), "an unrelated profile's saved game was deleted"); + QVERIFY2(!dlg->notificationAreaMessageBox->text().isEmpty(), "the refusal was not reported to the user"); + + closeDialog(dlg); + } + + void test_removingParentDirectoryProfileKeepsTheConfigurationDirectory() + { + auto* dlg = openDialog(); + addListEntry(dlg, qsl("..")); + + dlg->slot_deleteProfile(); + QVERIFY2(confirmation(dlg), "removal went ahead without asking"); + confirmRemovalOf(dlg, qsl("..")); + + QVERIFY2(QDir(mudlet::getMudletPath(enums::mainPath)).exists(), "Mudlet's configuration directory was deleted"); + QVERIFY2(QDir(mudlet::getMudletPath(enums::profilesPath)).exists(), "the profiles directory was deleted"); + QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was deleted"); + QVERIFY2(!dlg->notificationAreaMessageBox->text().isEmpty(), "the refusal was not reported to the user"); + + closeDialog(dlg); + } + + void test_onlyDirectChildrenOfTheProfilesDirectoryAreProfiles() + { + const QString profilesPath = mudlet::getMudletPath(enums::profilesPath); + + QCOMPARE(dlgConnectionProfiles::profileFolderPath(profilesPath, mKeeper), qsl("%1/%2").arg(profilesPath, mKeeper)); + QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl(".")).isEmpty()); + QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("..")).isEmpty()); + QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("%1/current").arg(mKeeper)).isEmpty()); + + // a predefined game has no folder until it is saved + const QString neverPlayed = qsl("QA Never Played"); + QVERIFY(!QDir(profilePath(neverPlayed)).exists()); + QCOMPARE(dlgConnectionProfiles::profileFolderPath(profilesPath, neverPlayed), qsl("%1/%2").arg(profilesPath, neverPlayed)); + } + + void test_profileWithOnlyAMapIsConfirmedBeforeRemoval() + { + const QString mapped = qsl("QA Mapped"); + QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileMapsPath, mapped))); + QVERIFY(!QDir(mudlet::getMudletPath(enums::profileXmlFilesPath, mapped)).exists()); + + auto* dlg = openDialog(); + selectProfile(dlg, mapped); + + dlg->slot_deleteProfile(); + QVERIFY2(confirmation(dlg), "removal went ahead without asking"); + QVERIFY2(QDir(profilePath(mapped)).exists(), "the profile was deleted before the user confirmed"); + + confirmation(dlg)->reject(); + QVERIFY2(QDir(profilePath(mapped)).exists(), "the profile was deleted after the user cancelled"); + closeDialog(dlg); + } + + void test_profileWithOnlyConnectionDetailsIsRemovedWithoutConfirmation() + { + const QString unplayed = qsl("QA Unplayed"); + QVERIFY(QDir().mkpath(profilePath(unplayed))); + mudlet::self()->writeProfileData(unplayed, qsl("url"), qsl("mudlet.org")); + mudlet::self()->writeProfileData(unplayed, qsl("port"), qsl("23")); + + auto* dlg = openDialog(); + selectProfile(dlg, unplayed); + + dlg->slot_deleteProfile(); + QVERIFY2(!confirmation(dlg), "a profile with nothing but connection details should not need confirming"); + QVERIFY2(!QDir(profilePath(unplayed)).exists(), "the profile was not removed"); + closeDialog(dlg); + } + + // test_profileWithOnlyConnectionDetailsIsRemovedWithoutConfirmation only + // holds while dlgConnectionProfiles::scmConnectionDetailFiles still covers + // what the connection form writes, so fill one in the way a user does and + // hold what lands on disk against that list + void test_newProfileOnlyWritesListedConnectionDetails() + { + const QString unplayed = qsl("QA Just Set Up"); + QVERIFY(!QDir(profilePath(unplayed)).exists()); + + auto* dlg = openDialog(); + dlg->slot_addProfile(); + dlg->profile_name_entry->setText(unplayed); + // what leaving the name field does, and what creates the profile's + // folder - it only gets that far synchronously because initTestCase() + // turned secure password storage off, else it waits on the keychain + dlg->slot_saveName(); + QVERIFY2(QDir(profilePath(unplayed)).exists(), "naming a new profile did not create its folder"); + + // the rest of the connection form. The character name and the password + // are left out on purpose: they are the user's own, so a profile + // holding either is still confirmed - see the two cases below + dlg->host_name_entry->setText(qsl("mudlet.org")); + dlg->port_entry->setText(qsl("23")); + dlg->port_ssl_tsl->setChecked(true); + dlg->autologin_checkBox->setChecked(true); + dlg->auto_reconnect->setChecked(true); + dlg->mud_description_textedit->setPlainText(qsl("a game to try later")); + + // refilling the form by selecting the profile writes through the same + // field signals, so the selection path is covered as well + selectProfile(dlg, unplayed); + + const QStringList written = QDir(profilePath(unplayed)).entryList(QDir::Files | QDir::Hidden); + // a field that stops saving would otherwise quietly shrink what the + // check below covers, while still passing it + for (const QString& expected : {qsl("url"), qsl("port"), qsl("ssl_tsl"), qsl("autologin"), qsl("autoreconnect"), qsl("description")}) { + QVERIFY2(written.contains(expected), qPrintable(qsl("filling the form in no longer writes '%1', so this case has stopped covering it").arg(expected))); + } + for (const QString& fileName : written) { + QVERIFY2(dlgConnectionProfiles::scmConnectionDetailFiles.contains(fileName), + qPrintable(qsl("setting a profile up wrote '%1', which dlgConnectionProfiles::scmConnectionDetailFiles does not list - add it there, or " + "reconsider removing such a profile without confirmation") + .arg(fileName))); + } + + dlg->slot_deleteProfile(); + QVERIFY2(!confirmation(dlg), "a profile that was only ever set up should not need confirming"); + QVERIFY2(!QDir(profilePath(unplayed)).exists(), "the profile was not removed"); + closeDialog(dlg); + } + + // A stored password sits loose in the folder rather than in a sub-directory + void test_profileWithOnlyAStoredPasswordIsConfirmedBeforeRemoval() + { + const QString secretive = qsl("QA Secretive"); + QVERIFY(QDir().mkpath(profilePath(secretive))); + mudlet::self()->writeProfileData(secretive, qsl("password"), qsl("hunter2")); + QVERIFY(QDir(profilePath(secretive)).entryList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot).isEmpty()); + + auto* dlg = openDialog(); + selectProfile(dlg, secretive); + + dlg->slot_deleteProfile(); + QVERIFY2(confirmation(dlg), "a profile holding a stored password was removed without asking"); + QVERIFY2(QDir(profilePath(secretive)).exists(), "the profile was deleted before the user confirmed"); + + confirmation(dlg)->reject(); + closeDialog(dlg); + } + + // The character name typed into the login field is the user's own text + // rather than one of the connection details the shortcut waves through + void test_profileWithACharacterNameIsConfirmedBeforeRemoval() + { + const QString named = qsl("QA Named"); + QVERIFY(QDir().mkpath(profilePath(named))); + mudlet::self()->writeProfileData(named, qsl("url"), qsl("mudlet.org")); + mudlet::self()->writeProfileData(named, qsl("login"), qsl("Aurelius")); + + auto* dlg = openDialog(); + selectProfile(dlg, named); + + dlg->slot_deleteProfile(); + QVERIFY2(confirmation(dlg), "a profile holding a character name was removed without asking"); + QVERIFY2(QDir(profilePath(named)).exists(), "the profile was deleted before the user confirmed"); + + confirmation(dlg)->reject(); + closeDialog(dlg); + } + + // Nothing stops a second confirmation being raised over the first + void test_eachConfirmationRemovesItsOwnProfile() + { + const QString first = qsl("QA First"); + const QString second = qsl("QA Second"); + makeProfileWithSavedGame(first); + makeProfileWithSavedGame(second); + + auto* dlg = openDialog(); + selectProfile(dlg, first); + dlg->slot_deleteProfile(); + auto* firstConfirmation = confirmation(dlg); + QVERIFY(firstConfirmation); + + selectProfile(dlg, second); + dlg->slot_deleteProfile(); + const auto confirmations = dlg->findChildren<QDialog*>(qsl("delete_profile_confirmation")); + QCOMPARE(confirmations.size(), 2); + auto* secondConfirmation = confirmations.first() == firstConfirmation ? confirmations.last() : confirmations.first(); + + auto* nameEntry = firstConfirmation->findChild<QLineEdit*>(qsl("delete_profile_lineedit")); + auto* deleteButton = firstConfirmation->findChild<QPushButton*>(qsl("delete_button")); + QVERIFY(nameEntry && deleteButton); + nameEntry->setText(first); + QVERIFY2(deleteButton->isEnabled(), "the first confirmation did not accept its own profile name"); + deleteButton->click(); + + QVERIFY2(!QDir(profilePath(first)).exists(), "the first confirmation did not remove its own profile"); + QVERIFY2(QDir(profilePath(second)).exists(), "the first confirmation removed the second profile instead"); + + secondConfirmation->reject(); + closeDialog(dlg); + } + + void test_profileWithDataIsStillRemovable() + { + const QString doomed = qsl("QA Doomed"); + makeProfileWithSavedGame(doomed); + + auto* dlg = openDialog(); + selectProfile(dlg, doomed); + + dlg->slot_deleteProfile(); + QVERIFY2(confirmation(dlg), "removal of a profile with data went ahead without asking"); + QVERIFY2(QDir(profilePath(doomed)).exists(), "the profile was deleted before the user confirmed"); + + confirmRemovalOf(dlg, doomed); + + QVERIFY2(!QDir(profilePath(doomed)).exists(), "the confirmed profile was not removed"); + QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was removed too"); + closeDialog(dlg); + } + + // A name Mudlet would turn down as a new profile is still a profile on disk + void test_nonAsciiProfileIsStillRemovable() + { + const QString doomed = qsl("Мудлет café"); + makeProfileWithSavedGame(doomed); + + auto* dlg = openDialog(); + selectProfile(dlg, doomed); + + removeProfileAndConfirm(dlg, doomed); + + QVERIFY2(!QDir(profilePath(doomed)).exists(), "the confirmed profile was not removed"); + QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was removed too"); + closeDialog(dlg); + } + + // The confirmation is not modal, so the selection can move on behind it + void test_confirmationRemovesTheProfileItNamed() + { + const QString doomed = qsl("QA Doomed Too"); + makeProfileWithSavedGame(doomed); + + auto* dlg = openDialog(); + selectProfile(dlg, doomed); + dlg->slot_deleteProfile(); + QVERIFY(confirmation(dlg)); + + selectProfile(dlg, mKeeper); + + confirmRemovalOf(dlg, doomed); + + QVERIFY2(QDir(profilePath(mKeeper)).exists(), "the newly selected profile was removed instead"); + QVERIFY2(!QDir(profilePath(doomed)).exists(), "the confirmed profile was not removed"); + closeDialog(dlg); + } + + void test_typedNamesThatAreNotProfilesAreRejected_data() + { + QTest::addColumn<QString>("name"); + + QTest::newRow("current directory") << qsl("."); + QTest::newRow("parent directory") << qsl(".."); + QTest::newRow("embedded parent directory") << qsl("Achaea..Beta"); + } + + void test_typedNamesThatAreNotProfilesAreRejected() + { + QFETCH(QString, name); + + auto* dlg = openDialog(); + selectProfile(dlg, mKeeper); + // else the assertion below also holds for an unrelated reason + QVERIFY2(acceptButtonsEnabled(dlg), "the profile was already unusable before the name was edited"); + + // setText() drives the same textChanged path as typing does + dlg->profile_name_entry->setText(name); + QVERIFY2(!acceptButtonsEnabled(dlg), qPrintable(qsl("'%1' was accepted as a profile name").arg(name))); + QVERIFY(QDir(profilePath(mKeeper)).exists()); + + dlg->profile_name_entry->setText(mKeeper); + closeDialog(dlg); + } + + // A folder on disk is the user's data whatever it is called + void test_folderOnDiskWithATurnedDownNameIsStillUsable() + { + const QString awkward = qsl("QA..Dots"); + QVERIFY(!dlgConnectionProfiles::profileNameUsableAsIs(awkward)); + makeProfileWithSavedGame(awkward); + mudlet::self()->writeProfileData(awkward, qsl("url"), qsl("mudlet.org")); + mudlet::self()->writeProfileData(awkward, qsl("port"), qsl("23")); + + auto* dlg = openDialog(); + selectProfile(dlg, awkward); + + QCOMPARE(dlg->profile_name_entry->text(), awkward); + QVERIFY2(acceptButtonsEnabled(dlg), "a profile folder already on disk was refused"); + QVERIFY2(QDir(profilePath(awkward)).exists(), "the folder was renamed behind the user's back"); + closeDialog(dlg); + } + + void test_typedNamesThatAreProfilesAreAccepted_data() + { + QTest::addColumn<QString>("name"); + + QTest::newRow("version number") << qsl("QA Game 2.0"); + QTest::newRow("parentheses") << qsl("QA Keeper (2)"); + } + + void test_typedNamesThatAreProfilesAreAccepted() + { + QFETCH(QString, name); + + auto* dlg = openDialog(); + selectProfile(dlg, mKeeper); + + dlg->profile_name_entry->setText(name); + QCOMPARE(dlg->profile_name_entry->text(), name); + QVERIFY2(acceptButtonsEnabled(dlg), qPrintable(qsl("'%1' was refused as a profile name").arg(name))); + + // ~QDialog fires editingFinished() into slot_saveName(), which renames + dlg->profile_name_entry->setText(mKeeper); + closeDialog(dlg); + } +}; + +void initializeQRCResourcesForProfileDeletionSafetyTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ProfileDeletionSafetyTest.moc" +QTEST_MAIN(ProfileDeletionSafetyTest) diff --git a/test/functional_tests/ProfileFolderNameTest.cpp b/test/functional_tests/ProfileFolderNameTest.cpp new file mode 100644 index 000000000..25a2b2c2f --- /dev/null +++ b/test/functional_tests/ProfileFolderNameTest.cpp @@ -0,0 +1,221 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Drives the real connection dialog against profile folders created outside + * of Mudlet, the way a file manager makes them (e.g. copying a profile to + * "test (2)"). Guards the on-disk exemption in + * dlgConnectionProfiles::validateProfile(): selecting such a folder must keep + * its name intact (no silent character stripping, which used to rename the + * folder on disk and lose its stored password) and must leave the + * Connect/Offline buttons enabled - while a freshly typed name must still + * have disallowed characters filtered out. + * + * Run with: ctest -R ProfileFolderNameTest -V + */ + +#include "MudletInstanceCoordinator.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +#include <QtTest/QtTest> + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); + +static void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +class ProfileFolderNameTest : public QObject +{ + Q_OBJECT + +private: + QTemporaryDir mXdgDir; + QByteArray mSavedXdg; + + // The name a file manager typically produces when copying a folder; every + // character is in the allowed set now that parentheses are permitted: + const QString mCopiedName = qsl("test (2)"); + // Contains a character that is NOT in the allowed set, so only the + // on-disk exemption lets it through unmangled: + const QString mForeignName = qsl("café"); + // Trailing whitespace: the entered name arrives trimmed, so the exemption + // has to match against the trimmed folder name to cover this one: + const QString mPaddedName = qsl("padded café "); + + // setupConfig() consults portable.txt before the XDG logic; skip rather + // than run against an unexpected config dir (see ConfigDirOverrideTest). + bool portableMarkerPresent() const + { + return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath())); + } + + void makeExternalProfileFolder(const QString& name) const + { + QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileHomePath, name))); + // A folder copied by a file manager carries the original's connection + // data files with it: + QVERIFY(mudlet::self()->writeProfileData(name, qsl("url"), qsl("mudlet.org")).first); + QVERIFY(mudlet::self()->writeProfileData(name, qsl("port"), qsl("23")).first); + } + + // The Connect and Offline buttons are the only AcceptRole buttons in the + // dialog's button box (they are private members of the dialog itself): + QList<QAbstractButton*> acceptButtons(dlgConnectionProfiles* dialog) const + { + QList<QAbstractButton*> buttons; + for (auto* button : dialog->dialog_buttonbox->buttons()) { + if (dialog->dialog_buttonbox->buttonRole(button) == QDialogButtonBox::AcceptRole) { + buttons << button; + } + } + return buttons; + } + + dlgConnectionProfiles* selectProfile(const QString& name) + { + auto* dialog = mudlet::self()->mpConnectionDialog.data(); + if (!dialog) { + return nullptr; + } + const auto items = dialog->findData(*dialog->listWidget_profiles, name, dlgConnectionProfiles::csmNameRole); + if (items.isEmpty()) { + return nullptr; + } + dialog->listWidget_profiles->setCurrentItem(items.first()); + dialog->slot_itemClicked(items.first()); + return dialog; + } + +private slots: + void initTestCase() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - cannot redirect the config dir for this test"); + } + initializeQRCResources(); + + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(mXdgDir.isValid()); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mXdgDir.path()))); // profiles/ = XDG opt-in + qputenv("XDG_CONFIG_HOME", mXdgDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + // never touch the user's real profiles: + QVERIFY(mudlet::getMudletPath(enums::profilesPath).startsWith(mXdgDir.path())); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + makeExternalProfileFolder(mCopiedName); + makeExternalProfileFolder(mForeignName); + makeExternalProfileFolder(mPaddedName); + + mudlet::self()->startAutoLogin({}); + QVERIFY(QTest::qWaitFor( + []() { + return mudlet::self()->mpConnectionDialog != nullptr; + }, + 5000)); + } + + void cleanupTestCase() + { + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + delete mudlet::self(); + } + + void test_copiedFolderWithParenthesesIsUsable() + { + auto* dialog = selectProfile(mCopiedName); + QVERIFY2(dialog, "profile folder with parentheses was not listed in the dialog"); + + QCOMPARE(dialog->profile_name_entry->text(), mCopiedName); + const auto buttons = acceptButtons(dialog); + QCOMPARE(buttons.size(), 2); + for (auto* button : buttons) { + QVERIFY2(button->isEnabled(), qPrintable(qsl("'%1' button is disabled for profile '%2'").arg(button->text(), mCopiedName))); + } + // the folder must not have been renamed behind the user's back: + QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mCopiedName)).exists()); + } + + void test_folderWithDisallowedCharacterIsNotMangled() + { + auto* dialog = selectProfile(mForeignName); + QVERIFY2(dialog, "profile folder with a disallowed character was not listed in the dialog"); + + QCOMPARE(dialog->profile_name_entry->text(), mForeignName); + const auto buttons = acceptButtons(dialog); + QCOMPARE(buttons.size(), 2); + for (auto* button : buttons) { + QVERIFY2(button->isEnabled(), qPrintable(qsl("'%1' button is disabled for profile '%2'").arg(button->text(), mForeignName))); + } + QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mForeignName)).exists()); + } + + void test_folderWithTrailingWhitespaceIsNotMangled() + { + auto* dialog = selectProfile(mPaddedName); + QVERIFY2(dialog, "profile folder with trailing whitespace was not listed in the dialog"); + + QCOMPARE(dialog->profile_name_entry->text(), mPaddedName); + const auto buttons = acceptButtons(dialog); + QCOMPARE(buttons.size(), 2); + for (auto* button : buttons) { + QVERIFY2(button->isEnabled(), qPrintable(qsl("'%1' button is disabled for profile '%2'").arg(button->text(), mPaddedName))); + } + QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mPaddedName)).exists()); + } + + // The exemption must not disable validation of names the user types: + void test_editedNameIsStillValidated() + { + auto* dialog = selectProfile(mCopiedName); + QVERIFY(dialog); + + // setText() drives the same textChanged path as typing does + dialog->profile_name_entry->setText(qsl("test |2")); + QVERIFY(!dialog->profile_name_entry->text().contains(QLatin1Char('|'))); + + // restore the on-disk selection so no rename can be left pending + dialog->profile_name_entry->setText(mCopiedName); + QCOMPARE(dialog->profile_name_entry->text(), mCopiedName); + } +}; + +QTEST_MAIN(ProfileFolderNameTest) +#include "ProfileFolderNameTest.moc" diff --git a/test/functional_tests/ProfileLifecycleTest.cpp b/test/functional_tests/ProfileLifecycleTest.cpp new file mode 100644 index 000000000..3da8d3469 --- /dev/null +++ b/test/functional_tests/ProfileLifecycleTest.cpp @@ -0,0 +1,684 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * The profile lifecycle API - loadProfile(), setActiveProfile(), + * closeProfile(), closeMudlet() and the cross-profile half of + * raiseGlobalEvent() - is out of reach of the busted suite, which runs inside + * one profile of an application it must leave standing. Each test here drives + * the API from a profile's own Lua state and checks the application state that + * follows, plus the refusals the three name-taking functions return. + * + * Left uncovered on purpose: closeProfile() reports true as soon as it has + * asked for the close, so a close that Host::requestClose() then refuses still + * reads as a success. Reaching that needs the modal save prompt the fixture + * below is deliberately built to avoid. + * + * Run with: ctest -R ProfileLifecycleTest -V + */ + +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TTabBar.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lauxlib.h> +#include <lua5.1/lua.h> +#include <lua5.1/lualib.h> +#else +#include <lauxlib.h> +#include <lua.h> +#include <lualib.h> +#endif +} + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForProfileLifecycleTest(); + +class ProfileLifecycleTest : public QObject +{ + Q_OBJECT + +private: + // What a Lua call returned: the type of the first value matters as much as + // the value, since these functions disagree on whether a refusal is a nil + // or a false + struct LuaOutcome + { + QString error; // non-null when the chunk failed, at compile or at run time + QString firstType; + bool first = false; + QString message; + }; + + // enough for a real connection to the local stub on a loaded machine + static constexpr int csmConnectBudgetMs = 15000; + // What an offline profile is given to prove it does not connect. Shorter + // than the budget above deliberately - the online test is what shows a + // connection is noticed well inside a wait of this size. + static constexpr int csmStayOfflineBudgetMs = 3000; + static constexpr int csmTeardownBudgetMs = 30000; + + QTemporaryDir mConfigDir; + QByteArray mSavedXdgConfigHome; + TelnetServerStub* mpServer = nullptr; + QString mPort; + Host* mpFirstHost = nullptr; + + const QString mLocalhost = qsl("localhost"); + const QString mFirstProfile = qsl("ProfileLifecycle-First"); + const QString mSecondProfile = qsl("ProfileLifecycle-Second"); + const QString mThirdProfile = qsl("ProfileLifecycle-Third"); + const QString mOnlineProfile = qsl("ProfileLifecycle-Online"); + const QString mUnloadedProfile = qsl("ProfileLifecycle-Unloaded"); + const QString mAbsentProfile = qsl("ProfileLifecycle-Absent"); + + // setupConfig() consults portable.txt ahead of the XDG logic; skip rather + // than run against an unexpected config dir (see ConfigDirOverrideTest) + bool portableMarkerPresent() const + { + return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath())); + } + + Host* hostFor(const QString& profileName) const { return mudlet::self()->getHostManager().getHost(profileName); } + + bool profileHasATab(const QString& profileName) const { return mudlet::self()->mpTabBar->tabIndex(profileName) != -1; } + + // Compares against the pool itself rather than a name, since a Host that + // has been closed cannot be asked for one + bool stillInTheHostPool(Host* pHost) const + { + if (!pHost) { + return false; + } + for (auto pLoadedHost : mudlet::self()->getHostManager()) { + if (pLoadedHost == pHost) { + return true; + } + } + return false; + } + + static bool reachedTheGame(Host* pHost) + { + const auto [address, port, connected] = pHost->mTelnet.getConnectionInfo(); + return connected; + } + + // The folder is all getCanonicalProfileName() matches a name against; the + // url and port are what the load then needs to reach the stub, as the Host + // constructor reads both back out of the profile's data files. + bool provisionProfileOnDisk(const QString& profileName) const + { + return QDir().mkpath(mudlet::getMudletPath(enums::profileHomePath, profileName)) && mudlet::self()->writeProfileData(profileName, qsl("url"), mLocalhost).first + && mudlet::self()->writeProfileData(profileName, qsl("port"), mPort).first; + } + + // Returns the Lua error, or a null QString when the chunk ran + QString runLua(Host* pHost, const QString& code) const + { + lua_State* L = pHost->getLuaInterpreter()->getLuaGlobalState(); + if (luaL_dostring(L, code.toUtf8().constData()) == 0) { + return QString(); + } + // an error object that is neither a string nor a number gives back a + // nullptr here, and QString::fromUtf8(nullptr) is null - which every + // caller would read as "the chunk ran" + const char* message = lua_tostring(L, -1); + const QString error = message ? QString::fromUtf8(message) : qsl("(a Lua error that is not a string)"); + lua_pop(L, 1); + return error; + } + + LuaOutcome callLua(Host* pHost, const QString& expression) const + { + LuaOutcome outcome; + outcome.error = runLua(pHost, qsl("_lifecycleResult, _lifecycleMessage = %1").arg(expression)); + if (!outcome.error.isNull()) { + return outcome; + } + + lua_State* L = pHost->getLuaInterpreter()->getLuaGlobalState(); + lua_getglobal(L, "_lifecycleResult"); + outcome.firstType = QString::fromUtf8(luaL_typename(L, -1)); + outcome.first = lua_toboolean(L, -1); + lua_pop(L, 1); + lua_getglobal(L, "_lifecycleMessage"); + if (lua_type(L, -1) == LUA_TSTRING) { + outcome.message = QString::fromUtf8(lua_tostring(L, -1)); + } + lua_pop(L, 1); + return outcome; + } + + QString luaGlobalString(Host* pHost, const QString& globalName) const + { + lua_State* L = pHost->getLuaInterpreter()->getLuaGlobalState(); + lua_getglobal(L, globalName.toUtf8().constData()); + QString value; + if (lua_type(L, -1) == LUA_TSTRING) { + value = QString::fromUtf8(lua_tostring(L, -1)); + } + lua_pop(L, 1); + return value; + } + + int luaGlobalNumber(Host* pHost, const QString& globalName) const + { + lua_State* L = pHost->getLuaInterpreter()->getLuaGlobalState(); + lua_getglobal(L, globalName.toUtf8().constData()); + const int value = static_cast<int>(lua_tointeger(L, -1)); + lua_pop(L, 1); + return value; + } + + // _lifecycleHandler holds one handler at a time, so forgetEvent() has to + // run before the next rememberEvent() or the previous handler is orphaned + // and goes on writing the same globals + QString rememberEvent(Host* pHost, const QString& eventName) const + { + return runLua(pHost, + qsl("_lifecyclePayload, _lifecycleSender, _lifecycleCalls = nil, nil, 0\n" + "_lifecycleHandler = registerAnonymousEventHandler('%1', function(_, payload, sender)\n" + " _lifecyclePayload, _lifecycleSender = payload, sender\n" + " _lifecycleCalls = _lifecycleCalls + 1\n" + "end)") + .arg(eventName)); + } + + QString forgetEvent(Host* pHost) const { return runLua(pHost, qsl("if _lifecycleHandler then killAnonymousEventHandler(_lifecycleHandler) _lifecycleHandler = nil end")); } + + void startFirstProfile() + { + const QString profileName = mFirstProfile; + const QString address = mLocalhost; + const QString port = mPort; + QTimer::singleShot(0ms, qApp, [profileName, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), profileName); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + QVERIFY2(spy.wait(csmConnectBudgetMs), "the first profile took too long to load"); + mpFirstHost = mudlet::self()->getActiveHost(); + QVERIFY2(mpFirstHost, "no active host after creating the first profile"); + // otherwise closing a profile asks whether to save it, and the modal + // question would hang the test + QVERIFY2(mpFirstHost->mFORCE_SAVE_ON_EXIT, "profiles must save without asking, or a close puts up a modal question"); + } + + // test_closeProfileNamedByAnotherProfileTearsItDown takes the second + // profile away again, and any one test can also be run on its own with + // -functions, so a test that needs another profile opens it itself. The + // declaration order still matters for the closeMudlet test, which has to + // stay last. + Host* loadProfileThroughLua(const QString& profileName) + { + if (auto* pHost = hostFor(profileName)) { + return pHost; + } + if (!provisionProfileOnDisk(profileName)) { + qWarning() << "loadProfileThroughLua: could not provision" << profileName; + return nullptr; + } + const LuaOutcome outcome = callLua(mpFirstHost, qsl("loadProfile('%1', true)").arg(profileName)); + if (!outcome.error.isNull()) { + qWarning() << "loadProfileThroughLua:" << outcome.error; + return nullptr; + } + if (!outcome.first) { + qWarning() << "loadProfileThroughLua: loadProfile() refused:" << outcome.message; + return nullptr; + } + return hostFor(profileName); + } + + bool waitFor(const std::function<bool()>& condition) const + { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < csmTeardownBudgetMs) { + if (condition()) { + return true; + } + QTest::qWait(50ms); + } + return condition(); + } + + // Removal from the host pool is finished off from a zero-timer, so the + // Host is still there until the event loop has run - even though the + // console has been closed and the profile saved by the time closeProfile() + // returns + bool waitForProfileToClose(const QString& profileName) const + { + return waitFor([this, profileName]() { + return !hostFor(profileName); + }); + } + +private slots: + void initTestCase() + { + if (portableMarkerPresent()) { + QSKIP("portable.txt present - cannot redirect the config dir for this test"); + } + initializeQRCResourcesForProfileLifecycleTest(); + + QVERIFY(mConfigDir.isValid()); + // $XDG_CONFIG_HOME/mudlet/profiles is the opt-in that makes setupConfig() + // adopt it, so the profiles these tests enumerate are only ever their own + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + mSavedXdgConfigHome = qgetenv("XDG_CONFIG_HOME"); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + // TelnetServerStub::start() only logs a failed bind, so check the port + // here: otherwise every profile is pointed at port 0 and the run fails + // later on, nowhere near the stub + QVERIFY2(mpServer->serverPort() != 0, "the telnet stub did not start listening"); + mPort = QString::number(mpServer->serverPort()); + + mudlet::start(); + mudlet::self()->setupConfig(); + QCOMPARE(mudlet::getMudletPath(enums::mainPath), qsl("%1/mudlet").arg(mConfigDir.path())); + // Written before init(), which is where a config with no keys at all + // gets stamped as a first launch. A settings file that already holds + // something is how mudletUsedBefore() recognises an existing player, so + // this both suppresses the first-run UI tour and keeps the starter UI + // package out of every profile these tests open. + mudlet::getQSettings()->setValue(qsl("uiTourShown"), true); + mudlet::getQSettings()->sync(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator"))); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + QVERIFY2(mudlet::self()->experiencedMudletPlayer(), "the first-run UI would open over these tests"); + + startFirstProfile(); + // a failed assertion in there only returns from it, so stop the whole + // run here rather than let every test dereference a null host + QVERIFY(mpFirstHost); + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + // null once the closeMudlet test has run, and deleting that is a no-op + delete mudlet::self(); + mSavedXdgConfigHome.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdgConfigHome); + } + + void test_loadProfileRefusesAnEmptyName() + { + const LuaOutcome outcome = callLua(mpFirstHost, qsl("loadProfile('')")); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("nil")); + QVERIFY2(outcome.message.contains(qsl("cannot be empty")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + } + + void test_loadProfileRefusesAProfileThatDoesNotExist() + { + QVERIFY2(!QDir(mudlet::getMudletPath(enums::profileHomePath, mAbsentProfile)).exists(), "the profile this test needs to be absent exists"); + + const LuaOutcome outcome = callLua(mpFirstHost, qsl("loadProfile('%1')").arg(mAbsentProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("nil")); + QVERIFY2(outcome.message.contains(qsl("does not exist")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + QVERIFY2(!hostFor(mAbsentProfile), "a profile that does not exist was loaded anyway"); + } + + void test_loadProfileRefusesAProfileThatIsAlreadyLoaded() + { + const int loadedBefore = mudlet::self()->getHostManager().getHostCount(); + + // asked for in the wrong case, and the refusal names the profile as it + // is spelt on disk: the case-insensitive lookup all three of these + // functions share + const LuaOutcome outcome = callLua(mpFirstHost, qsl("loadProfile('%1')").arg(mFirstProfile.toLower())); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("nil")); + QVERIFY2(outcome.message.contains(qsl("'%1' is already loaded").arg(mFirstProfile)), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + QCOMPARE(mudlet::self()->getHostManager().getHostCount(), loadedBefore); + } + + void test_loadProfileOpensASecondProfile() + { + QVERIFY(provisionProfileOnDisk(mSecondProfile)); + QVERIFY2(!hostFor(mSecondProfile), "the second profile was already loaded"); + const int loadedBefore = mudlet::self()->getHostManager().getHostCount(); + + const LuaOutcome outcome = callLua(mpFirstHost, qsl("loadProfile('%1', true)").arg(mSecondProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY2(outcome.first, qPrintable(qsl("loadProfile() refused: %1").arg(outcome.message))); + + Host* pSecondHost = hostFor(mSecondProfile); + QVERIFY2(pSecondHost, "loadProfile() reported success but the profile is not in the host pool"); + QVERIFY2(pSecondHost->mpConsole, "the loaded profile has no main console, so nothing of it is on screen"); + QVERIFY2(profileHasATab(mSecondProfile), "the loaded profile got no tab"); + QCOMPARE(mudlet::self()->getHostManager().getHostCount(), loadedBefore + 1); + + // An online load reaches the game some event loop turns later, so the + // socket is unconnected on the line after the call either way - only a + // wait that runs out says the offline flag was honoured. + // test_loadProfileConnectsWhenNotAskedForOffline is the control. + QSignalSpy connectionSpy(&pSecondHost->mTelnet, &cTelnet::signal_connected); + QVERIFY2(!connectionSpy.wait(csmStayOfflineBudgetMs) && !reachedTheGame(pSecondHost), "loadProfile(name, true) connected the profile despite being asked for offline"); + } + + // Connecting is what loadProfile() does when it is not told otherwise + void test_loadProfileConnectsWhenNotAskedForOffline() + { + QVERIFY(provisionProfileOnDisk(mOnlineProfile)); + QVERIFY2(!hostFor(mOnlineProfile), "the profile this test opens was already loaded"); + + const LuaOutcome outcome = callLua(mpFirstHost, qsl("loadProfile('%1')").arg(mOnlineProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QVERIFY2(outcome.first, qPrintable(qsl("loadProfile() refused: %1").arg(outcome.message))); + Host* pOnlineHost = hostFor(mOnlineProfile); + QVERIFY(pOnlineHost); + QSignalSpy connectionSpy(&pOnlineHost->mTelnet, &cTelnet::signal_connected); + QVERIFY2(reachedTheGame(pOnlineHost) || connectionSpy.wait(csmConnectBudgetMs), "loadProfile() with no offline argument did not connect the profile to the game"); + + QVERIFY(callLua(mpFirstHost, qsl("closeProfile('%1')").arg(mOnlineProfile)).first); + QVERIFY(waitForProfileToClose(mOnlineProfile)); + } + + void test_setActiveProfileRefusesAnEmptyName() + { + const LuaOutcome outcome = callLua(mpFirstHost, qsl("setActiveProfile('')")); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY(!outcome.first); + QVERIFY2(outcome.message.contains(qsl("cannot be empty")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + } + + void test_setActiveProfileRefusesAProfileThatDoesNotExist() + { + const LuaOutcome outcome = callLua(mpFirstHost, qsl("setActiveProfile('%1')").arg(mAbsentProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + // unlike loadProfile()/closeProfile(), this one refuses with false + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY(!outcome.first); + QVERIFY2(outcome.message.contains(qsl("does not exist")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + } + + void test_setActiveProfileRefusesAProfileThatIsNotLoaded() + { + QVERIFY(provisionProfileOnDisk(mUnloadedProfile)); + QVERIFY2(!hostFor(mUnloadedProfile), "the profile this test needs unloaded is loaded"); + Host* pActiveBefore = mudlet::self()->getActiveHost(); + + const LuaOutcome outcome = callLua(mpFirstHost, qsl("setActiveProfile('%1')").arg(mUnloadedProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY(!outcome.first); + QVERIFY2(outcome.message.contains(qsl("is not loaded")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + QCOMPARE(mudlet::self()->getActiveHost(), pActiveBefore); + } + + void test_setActiveProfileSwitchesTheActiveProfile() + { + Host* pSecondHost = loadProfileThroughLua(mSecondProfile); + QVERIFY(pSecondHost); + + LuaOutcome outcome = callLua(mpFirstHost, qsl("setActiveProfile('%1')").arg(mFirstProfile)); + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QVERIFY2(outcome.first, qPrintable(qsl("setActiveProfile() refused: %1").arg(outcome.message))); + QCOMPARE(mudlet::self()->getActiveHost(), mpFirstHost); + + outcome = callLua(mpFirstHost, qsl("setActiveProfile('%1')").arg(mSecondProfile)); + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY2(outcome.first, qPrintable(qsl("setActiveProfile() refused: %1").arg(outcome.message))); + QCOMPARE(mudlet::self()->getActiveHost(), pSecondHost); + QVERIFY(profileHasATab(mSecondProfile)); + QCOMPARE(mudlet::self()->mpTabBar->currentIndex(), mudlet::self()->mpTabBar->tabIndex(mSecondProfile)); + + // leave the profile the rest of the tests drive from in charge + QVERIFY(callLua(mpFirstHost, qsl("setActiveProfile('%1')").arg(mFirstProfile)).first); + } + + // Every other loaded profile is told the name of the profile that raised + // the event, and the one that raised it never hears it come back + void test_raiseGlobalEventReachesEveryOtherProfileOnly() + { + Host* pSecondHost = loadProfileThroughLua(mSecondProfile); + Host* pThirdHost = loadProfileThroughLua(mThirdProfile); + QVERIFY(pSecondHost && pThirdHost); + const QString eventName = qsl("ProfileLifecycleGlobalEvent"); + QVERIFY(rememberEvent(mpFirstHost, eventName).isNull()); + QVERIFY(rememberEvent(pSecondHost, eventName).isNull()); + QVERIFY(rememberEvent(pThirdHost, eventName).isNull()); + + QVERIFY(runLua(mpFirstHost, qsl("raiseGlobalEvent('%1', 'from the first')").arg(eventName)).isNull()); + + for (Host* pReceiver : {pSecondHost, pThirdHost}) { + QCOMPARE(luaGlobalString(pReceiver, qsl("_lifecyclePayload")), qsl("from the first")); + QCOMPARE(luaGlobalString(pReceiver, qsl("_lifecycleSender")), mFirstProfile); + QCOMPARE(luaGlobalNumber(pReceiver, qsl("_lifecycleCalls")), 1); + } + QCOMPARE(luaGlobalNumber(mpFirstHost, qsl("_lifecycleCalls")), 0); + + QVERIFY(runLua(pSecondHost, qsl("raiseGlobalEvent('%1', 'from the second')").arg(eventName)).isNull()); + + QCOMPARE(luaGlobalString(mpFirstHost, qsl("_lifecyclePayload")), qsl("from the second")); + QCOMPARE(luaGlobalString(mpFirstHost, qsl("_lifecycleSender")), mSecondProfile); + QCOMPARE(luaGlobalNumber(mpFirstHost, qsl("_lifecycleCalls")), 1); + QCOMPARE(luaGlobalNumber(pSecondHost, qsl("_lifecycleCalls")), 1); + + QVERIFY(forgetEvent(mpFirstHost).isNull()); + QVERIFY(forgetEvent(pSecondHost).isNull()); + QVERIFY(forgetEvent(pThirdHost).isNull()); + } + + // Arguments cross the profile boundary as strings and are rebuilt on the + // other side, so their types have to survive the trip - and the sending + // profile's name is appended after all of them, however many there are + void test_raiseGlobalEventKeepsArgumentTypesAndPutsTheSenderLast() + { + Host* pSecondHost = loadProfileThroughLua(mSecondProfile); + QVERIFY(pSecondHost); + const QString eventName = qsl("ProfileLifecycleTypedEvent"); + QVERIFY(runLua(pSecondHost, + qsl("_lifecycleTypes, _lifecycleValues = nil, nil\n" + "_lifecycleHandler = registerAnonymousEventHandler('%1', function(_, ...)\n" + " local given, types = {...}, {}\n" + " for i = 1, select('#', ...) do types[i] = type(given[i]) end\n" + " _lifecycleTypes = table.concat(types, ',')\n" + " _lifecycleValues = table.concat({tostring(given[1]), tostring(given[2]), tostring(given[4]), tostring(given[5])}, '|')\n" + "end)") + .arg(eventName)) + .isNull()); + + QVERIFY(runLua(mpFirstHost, qsl("raiseGlobalEvent('%1', 3.5, true, nil, 'text')").arg(eventName)).isNull()); + + QCOMPARE(luaGlobalString(pSecondHost, qsl("_lifecycleTypes")), qsl("number,boolean,nil,string,string")); + QCOMPARE(luaGlobalString(pSecondHost, qsl("_lifecycleValues")), qsl("3.5|true|text|%1").arg(mFirstProfile)); + + QVERIFY(forgetEvent(pSecondHost).isNull()); + } + + // Unlike raiseEvent(), which can hand a handler in the same profile a + // table through the Lua registry, nothing survives the trip to another + // profile that cannot be turned into a string. + // + // The table is passed as the very first argument on purpose. Rejecting one + // is a lua_error(), which longjmps straight out of the C function, so the + // TEvent being filled in on the stack is never destroyed and whatever it + // has already collected leaks. Refusing argument #1 is the one case that + // has collected nothing yet - a leaking case here would fail the whole + // binary under LeakSanitizer, so the realistic + // raiseGlobalEvent('name', {}) form stays untested until that is fixed. + void test_raiseGlobalEventRefusesArgumentsItCannotCarry() + { + const QString tableError = runLua(mpFirstHost, qsl("raiseGlobalEvent({})")); + QVERIFY2(!tableError.isNull(), "raiseGlobalEvent() accepted a table"); + QVERIFY2(tableError.contains(qsl("bad argument type #1")), qPrintable(tableError)); + + const QString noNameError = runLua(mpFirstHost, qsl("raiseGlobalEvent()")); + QVERIFY2(!noNameError.isNull(), "raiseGlobalEvent() accepted a call with no event name"); + QVERIFY2(noNameError.contains(qsl("missing argument #1")), qPrintable(noNameError)); + } + + void test_closeProfileRefusesAProfileThatDoesNotExist() + { + const LuaOutcome outcome = callLua(mpFirstHost, qsl("closeProfile('%1')").arg(mAbsentProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("nil")); + QVERIFY2(outcome.message.contains(qsl("does not exist")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + } + + void test_closeProfileRefusesAProfileThatIsNotLoaded() + { + QVERIFY(provisionProfileOnDisk(mUnloadedProfile)); + QVERIFY2(!hostFor(mUnloadedProfile), "the profile this test needs unloaded is loaded"); + + const LuaOutcome outcome = callLua(mpFirstHost, qsl("closeProfile('%1')").arg(mUnloadedProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("nil")); + QVERIFY2(outcome.message.contains(qsl("is not loaded")), qPrintable(qsl("unexpected message: %1").arg(outcome.message))); + } + + void test_closeProfileNamedByAnotherProfileTearsItDown() + { + QPointer<Host> pSecondHost = loadProfileThroughLua(mSecondProfile); + QVERIFY(pSecondHost); + QPointer<TMainConsole> pSecondConsole = pSecondHost->mpConsole; + QVERIFY(pSecondConsole); + const int loadedBefore = mudlet::self()->getHostManager().getHostCount(); + + const LuaOutcome outcome = callLua(mpFirstHost, qsl("closeProfile('%1')").arg(mSecondProfile)); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY(outcome.first); + QVERIFY2(waitForProfileToClose(mSecondProfile), "the profile was still in the host pool long after closeProfile() said it was closing"); + QVERIFY2(pSecondHost.isNull(), "the closed profile's Host outlived its removal from the host pool"); + // the console goes on a deferred delete of its own, so give it the + // same budget rather than assume it landed inside the wait above + QVERIFY2(waitFor([&pSecondConsole]() { + return pSecondConsole.isNull(); + }), + "the closed profile's main console was left behind"); + QVERIFY2(!profileHasATab(mSecondProfile), "the closed profile kept its tab"); + QCOMPARE(mudlet::self()->getHostManager().getHostCount(), loadedBefore - 1); + QVERIFY2(hostFor(mFirstProfile), "closing one profile took the other one with it"); + QVERIFY2(stillInTheHostPool(mudlet::self()->getActiveHost()), "closing a profile left the active profile pointing at a Host that has been destroyed"); + } + + void test_closeProfileWithNoArgumentClosesTheCallingProfile() + { + QPointer<Host> pThirdHost = loadProfileThroughLua(mThirdProfile); + QVERIFY(pThirdHost); + + const LuaOutcome outcome = callLua(pThirdHost, qsl("closeProfile()")); + + QVERIFY2(outcome.error.isNull(), qPrintable(outcome.error)); + QCOMPARE(outcome.firstType, qsl("boolean")); + QVERIFY(outcome.first); + QVERIFY2(waitForProfileToClose(mThirdProfile), "a profile that closed itself was still in the host pool long afterwards"); + QVERIFY2(pThirdHost.isNull(), "the self-closed profile's Host outlived its removal from the host pool"); + QVERIFY2(!profileHasATab(mThirdProfile), "the self-closed profile kept its tab"); + QVERIFY2(hostFor(mFirstProfile), "a profile closing itself took another profile with it"); + } + + // Last on purpose: this takes the main window with it, so no test slot can + // run after it - only cleanupTestCase(), which is written for a Mudlet + // that has already gone + void test_closeMudletShutsDownEveryProfileAndTheMainWindow() + { + QPointer<Host> pSecondHost = loadProfileThroughLua(mSecondProfile); + QVERIFY(pSecondHost); + QPointer<Host> pFirstHost = mpFirstHost; + QPointer<mudlet> pMainWindow = mudlet::self(); + + QVERIFY(runLua(mpFirstHost, qsl("closeMudlet()")).isNull()); + mpFirstHost = nullptr; + + // the main window is WA_DeleteOnClose, so it goes on a deferred delete + // once the close it arranges for has been accepted + QVERIFY2(waitFor([&pMainWindow]() { + return pMainWindow.isNull(); + }), + "closeMudlet() left the main window standing"); + QVERIFY2(pFirstHost.isNull(), "closeMudlet() left a profile loaded"); + QVERIFY2(pSecondHost.isNull(), "closeMudlet() closed one profile but not the other"); + } +}; + +void initializeQRCResourcesForProfileLifecycleTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ProfileLifecycleTest.moc" +QTEST_MAIN(ProfileLifecycleTest) diff --git a/test/functional_tests/ProfileLoadTempFileTest.cpp b/test/functional_tests/ProfileLoadTempFileTest.cpp new file mode 100644 index 000000000..87e1ef0b4 --- /dev/null +++ b/test/functional_tests/ProfileLoadTempFileTest.cpp @@ -0,0 +1,193 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression test for profile data-loss after a crash during save. + * + * Profile saves go through QSaveFile: the data is written to a randomly named + * temporary next to the target ("<name>.xml.AbCdEf") which is renamed over the + * real file on commit. If Mudlet dies mid-save, that temporary is left behind, + * empty, as the NEWEST file in the profile's current/ directory. + * + * mudlet::loadProfile() used to load the newest file of ANY name from + * current/, so after such a crash it would "load" the empty leftover instead + * of the newest real save: the profile opened with its connection settings + * (stored in separate files) intact but every trigger/alias/script seemingly + * wiped out. This test crashes a save in effigy - by planting an empty + * QSaveFile-style leftover newer than a real save - and verifies the loader + * skips it and restores the real data. + * + * Run with: ctest -R ProfileLoadTempFileTest -V + */ + +#include <QtTest/QtTest> + +#include <QTemporaryDir> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TTrigger.h" +#include "TriggerUnit.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForProfileLoadTempFileTest(); + +namespace { +// A minimal but complete profile save holding one trigger - the "guts" whose +// survival the test asserts: +const QString scmTriggerName = qsl("synthetic data-loss canary"); +const QString scmProfileXml = qsl(R"(<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE MudletPackage> +<MudletPackage version="1.001"> +<TriggerPackage> +<Trigger isActive="yes" isFolder="no" isTempTrigger="no" isMultiline="no" isPerlSlashGOption="no" isColorizerTrigger="no" isFilterTrigger="no" isSoundTrigger="no" isColorTrigger="no" isColorTriggerFg="no" isColorTriggerBg="no"> +<name>synthetic data-loss canary</name> +<script>-- intentionally empty</script> +<triggerType>0</triggerType> +<conditonLineDelta>0</conditonLineDelta> +<mStayOpen>0</mStayOpen> +<mCommand></mCommand> +<packageName></packageName> +<mFgColor>#ff0000</mFgColor> +<mBgColor>#ffff00</mBgColor> +<mSoundFile></mSoundFile> +<colorTriggerFgColor>#000000</colorTriggerFgColor> +<colorTriggerBgColor>#000000</colorTriggerBgColor> +<regexCodeList> +<string>^synthetic pattern$</string> +</regexCodeList> +<regexCodePropertyList> +<integer>1</integer> +</regexCodePropertyList> +</Trigger> +</TriggerPackage> +</MudletPackage> +)"); +} // namespace + +class ProfileLoadTempFileTest : public QObject +{ + Q_OBJECT + +private: + const QString mProfileName = qsl("ProfileLoadTempFile-Test"); + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + + static bool setModificationTime(const QString& path, const QDateTime& when) + { + QFile file(path); + if (!file.open(QIODevice::ReadWrite)) { + return false; + } + return file.setFileTime(when, QFileDevice::FileModificationTime); + } + + // setupConfig() prefers a portable.txt marker over the XDG override; skip + // rather than report a baffling failure if one is present: + bool portableMarkerPresent() const + { + return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath())); + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForProfileLoadTempFileTest(); + + // Keep the test hermetic: point the config dir resolution at a + // temporary directory instead of the user's real profiles. + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + if (portableMarkerPresent()) { + QSKIP("portable.txt marker present - config dir cannot be redirected for this test"); + } + QVERIFY2(mudlet::getMudletPath(enums::profilesPath).startsWith(mConfigDir.path()), "test config dir redirection did not take effect"); + } + + void cleanupTestCase() + { + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + void test_loaderSkipsLeftoverSaveTemporary() + { + // 1. A real save, holding one trigger: + const QString folder = mudlet::getMudletPath(enums::profileXmlFilesPath, mProfileName); + QVERIFY(QDir().mkpath(folder)); + const QString xmlPath = qsl("%1/2020-01-01#00-00-00.xml").arg(folder); + { + QFile xmlFile(xmlPath); + QVERIFY(xmlFile.open(QIODevice::WriteOnly | QIODevice::Text)); + QVERIFY(xmlFile.write(scmProfileXml.toUtf8()) > 0); + } + + // 2. What a crash mid-save leaves behind: an empty QSaveFile temporary + // that is the newest file in current/: + const QString leftoverPath = qsl("%1/2020-01-02#00-00-00.xml.AbCdEf").arg(folder); + { + QFile leftover(leftoverPath); + QVERIFY(leftover.open(QIODevice::WriteOnly)); + } + const QDateTime now = QDateTime::currentDateTime(); + QVERIFY(setModificationTime(xmlPath, now.addSecs(-3600))); + QVERIFY(setModificationTime(leftoverPath, now)); + + // 3. Load the profile through the production loader; it must pick the + // real save, not the newer empty leftover: + Host* pHost = mudlet::self()->loadProfile(mProfileName, false); + QVERIFY(pHost); + QVERIFY2(pHost->mLoadedOk, "loader tried to load a leftover QSaveFile temporary instead of the newest real save"); + QVERIFY2(pHost->getTriggerUnit()->findTrigger(scmTriggerName), "trigger from the real save is missing - the profile lost its data"); + } +}; + +void initializeQRCResourcesForProfileLoadTempFileTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ProfileLoadTempFileTest.moc" +QTEST_MAIN(ProfileLoadTempFileTest) diff --git a/test/functional_tests/ProfileSwitchShortcutTest.cpp b/test/functional_tests/ProfileSwitchShortcutTest.cpp new file mode 100644 index 000000000..68cf216e1 --- /dev/null +++ b/test/functional_tests/ProfileSwitchShortcutTest.cpp @@ -0,0 +1,409 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * The command line must claim the ShortcutOverride exactly when a live user key + * binding matches a press that would otherwise activate a profile switching + * shortcut (Ctrl+1 to Ctrl+9, Ctrl+Tab) - including presses QShortcutMap + * matches to a differently spelt shortcut - and never otherwise. Claiming it is + * the only way the binding survives, since QShortcutMap otherwise runs the + * shortcut and never delivers the KeyPress. + * + * Run with: ctest -R ProfileSwitchShortcutTest -V + */ + +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "KeyUnit.h" +#include "MudletInstanceCoordinator.h" +#include "TCommandLine.h" +#include "TKey.h" +#include "TLuaInterpreter.h" +#include "ShortcutsManager.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +#include <QShortcut> + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lauxlib.h> +#include <lua5.1/lua.h> +#include <lua5.1/lualib.h> +#else +#include <lauxlib.h> +#include <lua.h> +#include <lualib.h> +#endif +} + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForProfileSwitchShortcutTest(); + +// Qt::CTRL is Cmd on macOS, where "next profile" uses Qt::META - see mudlet::mudlet() +#if defined(Q_OS_MACOS) +static constexpr Qt::KeyboardModifier nextProfileModifier = Qt::MetaModifier; +#else +static constexpr Qt::KeyboardModifier nextProfileModifier = Qt::ControlModifier; +#endif + +class ProfileSwitchShortcutTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "ProfileSwitchShortcut-Test"; + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = "localhost"; + + TCommandLine* commandLine() const + { + if (!mpHost || !mpHost->mpConsole) { + return nullptr; + } + return mpHost->mpConsole->mpCommandLine; + } + + // QShortcutMap offers the key as an ignored ShortcutOverride and only runs + // the shortcut if nobody accepted it + bool overrideClaimed(int key, Qt::KeyboardModifiers modifiers) const + { + QKeyEvent event(QEvent::ShortcutOverride, key, modifiers); + event.ignore(); + QApplication::sendEvent(commandLine(), &event); + return event.isAccepted(); + } + + void sendKeyPress(int key, Qt::KeyboardModifiers modifiers) const + { + QKeyEvent event(QEvent::KeyPress, key, modifiers); + QApplication::sendEvent(commandLine(), &event); + } + + // Asserted where a claim is expected, so a mis-mapped sequence cannot + // masquerade as a code failure + bool shortcutInstalledFor(const QKeySequence& sequence) const + { + const auto shortcuts = mudlet::self()->findChildren<QShortcut*>(); + for (auto* shortcut : shortcuts) { + if (shortcut->key() == sequence && shortcut->isEnabled()) { + return true; + } + } + return false; + } + + // Alt+E on Linux and Windows, Ctrl+E on macOS + std::pair<int, Qt::KeyboardModifiers> scriptEditorShortcut() const + { + auto* sequence = mudlet::self()->shortcutsManager()->getSequence(qsl("Script editor")); + if (!sequence || sequence->isEmpty()) { + return {Qt::Key_unknown, Qt::NoModifier}; + } + const QKeyCombination combination = (*sequence)[0]; + return {combination.key(), combination.keyboardModifiers()}; + } + + int luaCounter(const QString& globalName) const + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, globalName.toUtf8().constData()); + const int value = static_cast<int>(lua_tointeger(L, -1)); + lua_pop(L, 1); + return value; + } + + int createCountingKey(const QString& name, int keycode, int modifier, const QString& counterName, const QString& parent = QString()) + { + QString keyName = name; + QString parentName = parent; + QString script = qsl("%1 = (%1 or 0) + 1").arg(counterName); + auto [id, message] = mpHost->mLuaInterpreter.startPermKey(keyName, parentName, keycode, modifier, script); + if (id <= 0) { + qWarning() << "createCountingKey failed:" << message; + } + return id; + } + + // Safe only while every key here is permanent and none is killed or + // uninstalled, leaving KeyUnit's deferred-delete set empty; otherwise this + // has to go through markCleanup()/doCleanup() + void removeAllKeys() + { + auto* keyUnit = mpHost->getKeyUnit(); + const auto rootKeys = keyUnit->getKeyRootNodeList(); // by value: ~TKey mutates the real list + for (auto* key : rootKeys) { + delete key; + } + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForProfileSwitchShortcutTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + + startProfile(mHostname, mLocalhost, mPort); + mpHost = mudlet::self()->getActiveHost(); + QVERIFY2(mpHost, "No active host after profile creation"); + QVERIFY2(commandLine(), "No command line available for the test"); + + // Checked ahead of the claim in TCommandLine::event(), so CtrlTab here + // would take Ctrl+Tab out of these tests' hands + mpHost->mCaretShortcut = Host::CaretShortcut::None; + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + + void cleanup() { removeAllKeys(); } + + void test_userBindingOnCtrlNumberClaimsTheShortcut() + { + QVERIFY2(shortcutInstalledFor(QKeySequence(Qt::CTRL | Qt::Key_1)), "No profile switching shortcut is installed for Ctrl+1, so this test proves nothing"); + + QVERIFY(createCountingKey(qsl("Ctrl+1 binding"), Qt::Key_1, Qt::ControlModifier, qsl("_testCtrl1")) > 0); + + QVERIFY2(overrideClaimed(Qt::Key_1, Qt::ControlModifier), "A user key binding on Ctrl+1 did not claim the key, so the profile switch shortcut swallows it"); + } + + void test_withoutUserBindingTheShortcutKeepsTheKey() + { + QVERIFY2(!overrideClaimed(Qt::Key_1, Qt::ControlModifier), "Ctrl+1 was claimed even though no user key binding matches it - profile switching would stop working"); + } + + // Only nine shortcuts are installed, so Ctrl+0 has nothing to beat + void test_bindingOnAnUnshadowedKeyIsNotClaimed() + { + QVERIFY(createCountingKey(qsl("Ctrl+0 binding"), Qt::Key_0, Qt::ControlModifier, qsl("_testCtrl0")) > 0); + + QVERIFY2(!overrideClaimed(Qt::Key_0, Qt::ControlModifier), "Ctrl+0 is not a profile switching shortcut, so the command line must not claim it"); + } + + void test_disabledUserBindingDoesNotClaimTheShortcut() + { + const QString name = qsl("Disabled Ctrl+2 binding"); + QVERIFY(createCountingKey(name, Qt::Key_2, Qt::ControlModifier, qsl("_testCtrl2")) > 0); + QVERIFY(overrideClaimed(Qt::Key_2, Qt::ControlModifier)); + + QVERIFY(mpHost->getKeyUnit()->disableKey(name)); + + QVERIFY2(!overrideClaimed(Qt::Key_2, Qt::ControlModifier), "A disabled key binding still claimed Ctrl+2"); + } + + void test_userBindingOnCtrlNineClaimsTheShortcut() + { + QVERIFY2(shortcutInstalledFor(QKeySequence(Qt::CTRL | Qt::Key_9)), "No profile switching shortcut is installed for Ctrl+9, so this test proves nothing"); + + QVERIFY(createCountingKey(qsl("Ctrl+9 binding"), Qt::Key_9, Qt::ControlModifier, qsl("_testCtrl9")) > 0); + + QVERIFY2(overrideClaimed(Qt::Key_9, Qt::ControlModifier), "A user key binding on Ctrl+9 did not claim the key"); + } + + void test_userBindingInDisabledGroupDoesNotClaimTheShortcut() + { + const int groupId = createCountingKey(qsl("Key Group"), -1, 0, qsl("_testGroup")); + QVERIFY(groupId > 0); + auto* group = mpHost->getKeyUnit()->getKey(groupId); + QVERIFY(group); + group->setIsActive(true); + + QVERIFY(createCountingKey(qsl("Grouped Ctrl+3 binding"), Qt::Key_3, Qt::ControlModifier, qsl("_testCtrl3"), qsl("Key Group")) > 0); + QVERIFY2(overrideClaimed(Qt::Key_3, Qt::ControlModifier), "A binding in an enabled group should claim Ctrl+3"); + + group->setIsActive(false); + QVERIFY2(!overrideClaimed(Qt::Key_3, Qt::ControlModifier), "A binding inside a disabled group still claimed Ctrl+3"); + } + + void test_userBindingOnCtrlTabClaimsTheShortcut() + { + QVERIFY2(shortcutInstalledFor(QKeySequence(nextProfileModifier | Qt::Key_Tab)), "No 'Next profile' shortcut is installed for Ctrl+Tab, so this test proves nothing"); + QVERIFY2(!overrideClaimed(Qt::Key_Tab, nextProfileModifier), "Ctrl+Tab was claimed with no user key binding present"); + + QVERIFY(createCountingKey(qsl("Ctrl+Tab binding"), Qt::Key_Tab, nextProfileModifier, qsl("_testCtrlTab")) > 0); + + QVERIFY2(overrideClaimed(Qt::Key_Tab, nextProfileModifier), "A user key binding on Ctrl+Tab did not claim the key"); + } + + // Shift+Tab reaches the widget as Key_Backtab while the sequence is spelt + // with Key_Tab, so the match has to bridge the two spellings + void test_userBindingOnCtrlShiftTabClaimsTheShortcut() + { + const auto modifiers = nextProfileModifier | Qt::ShiftModifier; + QVERIFY2(!overrideClaimed(Qt::Key_Backtab, modifiers), "Ctrl+Shift+Tab was claimed with no user key binding present"); + + QVERIFY(createCountingKey(qsl("Ctrl+Shift+Tab binding"), Qt::Key_Backtab, modifiers, qsl("_testCtrlShiftTab")) > 0); + + QVERIFY2(overrideClaimed(Qt::Key_Backtab, modifiers), "A user key binding on Ctrl+Shift+Tab did not claim the key"); + } + + void test_bindingOnAnotherApplicationShortcutIsNotClaimed() + { + auto [key, modifiers] = scriptEditorShortcut(); + QVERIFY2(key != Qt::Key_unknown, "Could not read the script editor shortcut"); + + QVERIFY(createCountingKey(qsl("Script editor shortcut binding"), key, modifiers, qsl("_testEditor")) > 0); + + QVERIFY2(!overrideClaimed(key, modifiers), "A key binding claimed the script editor shortcut, which is outside the profile switching set"); + } + + void test_aClearedProfileShortcutDoesNotClaimEveryKey() + { + auto* sequence = mudlet::self()->shortcutsManager()->getSequence(qsl("Switch to profile 1")); + QVERIFY2(sequence, "'Switch to profile 1' is not registered with the shortcuts manager"); + const QKeySequence saved = *sequence; + *sequence = QKeySequence(); + + auto [key, modifiers] = scriptEditorShortcut(); + QVERIFY(createCountingKey(qsl("Script editor shortcut binding"), key, modifiers, qsl("_testEditorCleared")) > 0); + QVERIFY(createCountingKey(qsl("F5 binding"), Qt::Key_F5, Qt::NoModifier, qsl("_testF5")) > 0); + + const bool editorClaimed = overrideClaimed(key, modifiers); + const bool f5Claimed = overrideClaimed(Qt::Key_F5, Qt::NoModifier); + *sequence = saved; + + QVERIFY2(!editorClaimed, "A cleared profile switching shortcut made an unrelated bound key claim the override"); + QVERIFY2(!f5Claimed, "A cleared profile switching shortcut made an unrelated bound key claim the override"); + } + + // QShortcutMap retries with the keypad modifier stripped, so Ctrl and a + // numpad digit activates the plain Ctrl+1 shortcut + void test_userBindingOnAKeypadDigitClaimsTheShortcut() + { + const auto modifiers = Qt::ControlModifier | Qt::KeypadModifier; + QVERIFY(createCountingKey(qsl("Ctrl+keypad 1 binding"), Qt::Key_1, modifiers, qsl("_testKeypad1")) > 0); + + QVERIFY2(overrideClaimed(Qt::Key_1, modifiers), "A user key binding on Ctrl and a keypad digit did not claim the key"); + } + + // Layouts needing Shift for a top-row digit (French AZERTY) record the + // binding with Shift, and QShortcutMap drops the Shift it consumed + void test_userBindingOnAShiftedDigitClaimsTheShortcut() + { + const auto modifiers = Qt::ControlModifier | Qt::ShiftModifier; + QVERIFY(createCountingKey(qsl("Ctrl+Shift+1 binding"), Qt::Key_1, modifiers, qsl("_testShift1")) > 0); + + QVERIFY2(overrideClaimed(Qt::Key_1, modifiers), "A user key binding on Ctrl+Shift and a digit did not claim the key"); + } + + void test_claimedBindingRunsExactlyOnce() + { + QVERIFY(createCountingKey(qsl("Ctrl+4 binding"), Qt::Key_4, Qt::ControlModifier, qsl("_testCtrl4")) > 0); + QCOMPARE(luaCounter(qsl("_testCtrl4")), 0); + + QVERIFY(overrideClaimed(Qt::Key_4, Qt::ControlModifier)); + QCOMPARE(luaCounter(qsl("_testCtrl4")), 0); // the probe must not execute anything + + sendKeyPress(Qt::Key_4, Qt::ControlModifier); + QCOMPARE(luaCounter(qsl("_testCtrl4")), 1); + } + +private: + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy connectionSpy(&(host->mTelnet), &cTelnet::signal_connected); + if (!connectionSpy.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResourcesForProfileSwitchShortcutTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "ProfileSwitchShortcutTest.moc" +QTEST_MAIN(ProfileSwitchShortcutTest) diff --git a/test/functional_tests/SetScriptCallbackTest.cpp b/test/functional_tests/SetScriptCallbackTest.cpp new file mode 100644 index 000000000..f8f70d76e --- /dev/null +++ b/test/functional_tests/SetScriptCallbackTest.cpp @@ -0,0 +1,356 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * A temp trigger/timer/alias/key created with a function argument stores that + * function as an anonymous callback in the Lua registry (keyed by the item + * pointer) and flags mRegisteredAnonymousLuaFunction. Replacing its script via + * setScript() must leave that callback mode: it has to release the old function + * from the registry (otherwise the entry leaks, as the destructor's mScript-based + * branch then deletes the compiled function rather than the callback) and clear + * the flag (otherwise execute() keeps calling the stale function and the new + * script never runs, for triggers/aliases/keys which gate execution on the flag). + * + * Run with: ctest -R SetScriptCallbackTest -V + */ + +#include <QtTest/QtTest> + +#include "AliasUnit.h" +#include "Host.h" +#include "KeyUnit.h" +#include "MudletInstanceCoordinator.h" +#include "TAlias.h" +#include "TKey.h" +#include "TLuaInterpreter.h" +#include "TTimer.h" +#include "TTrigger.h" +#include "TimerUnit.h" +#include "TriggerUnit.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lauxlib.h> +#include <lua5.1/lua.h> +#include <lua5.1/lualib.h> +#else +#include <lauxlib.h> +#include <lua.h> +#include <lualib.h> +#endif +} + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForSetScriptCallbackTest(); + +class SetScriptCallbackTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "SetScriptCallback-Test"; + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = "localhost"; + + void runLua(const QString& code) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + if (luaL_dostring(L, code.toUtf8().constData()) != 0) { + const QString error = QString::fromUtf8(lua_tostring(L, -1)); + lua_pop(L, 1); + QFAIL(qPrintable(qsl("Lua error running test script: %1").arg(error))); + } + } + + int luaInt(const QString& global) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, global.toUtf8().constData()); + const int value = static_cast<int>(lua_tointeger(L, -1)); + lua_pop(L, 1); + return value; + } + + // The anonymous callback is stored in the Lua registry keyed by the item + // pointer; a released callback leaves a nil entry there. + bool registryEntryIsNil(void* item) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_pushlightuserdata(L, item); + lua_rawget(L, LUA_REGISTRYINDEX); + const bool result = lua_isnil(L, -1); + lua_pop(L, 1); + return result; + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForSetScriptCallbackTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + + startProfile(mHostname, mLocalhost, mPort); + mpHost = mudlet::self()->getActiveHost(); + QVERIFY2(mpHost, "No active host after profile creation"); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + + // Trigger: execute() gates on the flag, so before the fix the stale function + // keeps firing (new script never runs) AND its registry entry leaks. + void test_triggerSetScriptReleasesCallback() + { + runLua(qsl("trigOld = 0\n" + "trigNew = 0\n" + "cbTrigId = tempRegexTrigger('^setscript_trig$', function() trigOld = trigOld + 1 end)\n")); + const int id = luaInt(qsl("cbTrigId")); + QVERIFY2(id > 0, "temp trigger with a function callback should be created"); + auto* pTrigger = mpHost->getTriggerUnit()->getTrigger(id); + QVERIFY(pTrigger); + QVERIFY2(pTrigger->mRegisteredAnonymousLuaFunction, "callback trigger should start in registered-function mode"); + + // Sanity: the registered function is what fires before we replace it. + runLua(qsl("feedTriggers('setscript_trig\\n')")); + QCOMPARE(luaInt(qsl("trigOld")), 1); + + QVERIFY(pTrigger->setScript(qsl("trigNew = trigNew + 1"))); + + QVERIFY2(!pTrigger->mRegisteredAnonymousLuaFunction, "setScript() must leave registered-function mode"); + QVERIFY2(registryEntryIsNil(pTrigger), "setScript() must release the old function from the Lua registry (no leak)"); + + // Firing now runs the new script; the stale function must not fire again. + runLua(qsl("feedTriggers('setscript_trig\\n')")); + QCOMPARE(luaInt(qsl("trigNew")), 1); + QCOMPARE(luaInt(qsl("trigOld")), 1); + } + + // Alias: execute() also gates on the flag, so the same stale-function symptom + // applies. Fire it directly through execute() since aliases match user input. + void test_aliasSetScriptReleasesCallback() + { + runLua(qsl("aliasOld = 0\n" + "aliasNew = 0\n" + "cbAliasId = tempAlias('^setscript_alias$', function() aliasOld = aliasOld + 1 end)\n")); + const int id = luaInt(qsl("cbAliasId")); + QVERIFY2(id > 0, "temp alias with a function callback should be created"); + auto* pAlias = mpHost->getAliasUnit()->getAlias(id); + QVERIFY(pAlias); + QVERIFY2(pAlias->mRegisteredAnonymousLuaFunction, "callback alias should start in registered-function mode"); + + pAlias->execute(); + QCOMPARE(luaInt(qsl("aliasOld")), 1); + + QVERIFY(pAlias->setScript(qsl("aliasNew = aliasNew + 1"))); + + QVERIFY2(!pAlias->mRegisteredAnonymousLuaFunction, "setScript() must leave registered-function mode"); + QVERIFY2(registryEntryIsNil(pAlias), "setScript() must release the old function from the Lua registry (no leak)"); + + pAlias->execute(); + QCOMPARE(luaInt(qsl("aliasNew")), 1); + QCOMPARE(luaInt(qsl("aliasOld")), 1); + } + + // Key: execute() gates on the flag as well. + void test_keySetScriptReleasesCallback() + { + runLua(qsl("keyOld = 0\n" + "keyNew = 0\n" + "cbKeyId = tempKey(65, function() keyOld = keyOld + 1 end)\n")); + const int id = luaInt(qsl("cbKeyId")); + QVERIFY2(id > 0, "temp key with a function callback should be created"); + auto* pKey = mpHost->getKeyUnit()->getKey(id); + QVERIFY(pKey); + QVERIFY2(pKey->mRegisteredAnonymousLuaFunction, "callback key should start in registered-function mode"); + + pKey->execute(); + QCOMPARE(luaInt(qsl("keyOld")), 1); + + QVERIFY(pKey->setScript(qsl("keyNew = keyNew + 1"))); + + QVERIFY2(!pKey->mRegisteredAnonymousLuaFunction, "setScript() must leave registered-function mode"); + QVERIFY2(registryEntryIsNil(pKey), "setScript() must release the old function from the Lua registry (no leak)"); + + pKey->execute(); + QCOMPARE(luaInt(qsl("keyNew")), 1); + QCOMPARE(luaInt(qsl("keyOld")), 1); + } + + // Timer: execute() discriminates on mScript rather than the flag, so the + // stale-function symptom does not surface - but the registry entry still leaks + // without the fix, which is what this asserts. A long timeout keeps the timer + // from firing on its own during the test. + void test_timerSetScriptReleasesCallback() + { + runLua(qsl("cbTimerId = tempTimer(100, function() end)\n")); + const int id = luaInt(qsl("cbTimerId")); + QVERIFY2(id > 0, "temp timer with a function callback should be created"); + auto* pTimer = mpHost->getTimerUnit()->getTimer(id); + QVERIFY(pTimer); + QVERIFY2(pTimer->mRegisteredAnonymousLuaFunction, "callback timer should start in registered-function mode"); + + QVERIFY(pTimer->setScript(qsl("noop = 1"))); + + QVERIFY2(!pTimer->mRegisteredAnonymousLuaFunction, "setScript() must leave registered-function mode"); + QVERIFY2(registryEntryIsNil(pTimer), "setScript() must release the old function from the Lua registry (no leak)"); + } + + // Clearing a callback item's script with setScript("") must also release the + // callback and stop it firing: for triggers/aliases/keys execute() then takes the + // empty-mScript early-out. + void test_triggerSetScriptEmptyReleasesCallback() + { + runLua(qsl("clearOld = 0\n" + "clearTrigId = tempRegexTrigger('^setscript_clear$', function() clearOld = clearOld + 1 end)\n")); + const int id = luaInt(qsl("clearTrigId")); + QVERIFY2(id > 0, "temp trigger with a function callback should be created"); + auto* pTrigger = mpHost->getTriggerUnit()->getTrigger(id); + QVERIFY(pTrigger); + QVERIFY(pTrigger->mRegisteredAnonymousLuaFunction); + + runLua(qsl("feedTriggers('setscript_clear\\n')")); + QCOMPARE(luaInt(qsl("clearOld")), 1); + + QVERIFY(pTrigger->setScript(QString())); + + QVERIFY2(!pTrigger->mRegisteredAnonymousLuaFunction, "setScript(\"\") must leave registered-function mode"); + QVERIFY2(registryEntryIsNil(pTrigger), "setScript(\"\") must release the old function from the Lua registry (no leak)"); + + // With no script and no callback, firing must do nothing - the stale function + // must not run. + runLua(qsl("feedTriggers('setscript_clear\\n')")); + QCOMPARE(luaInt(qsl("clearOld")), 1); + } + + // Guard the common real-world path (the script editor replacing a normal, + // string-created item's code): a non-callback item must never be pushed into + // callback mode, and setScript() must still update its behavior normally. + void test_stringCreatedTriggerSetScriptIsUnaffected() + { + runLua(qsl("plainA = 0\n" + "plainB = 0\n" + "plainTrigId = tempRegexTrigger('^setscript_plain$', 'plainA = plainA + 1')\n")); + const int id = luaInt(qsl("plainTrigId")); + QVERIFY2(id > 0, "string-created temp trigger should be created"); + auto* pTrigger = mpHost->getTriggerUnit()->getTrigger(id); + QVERIFY(pTrigger); + QVERIFY2(!pTrigger->mRegisteredAnonymousLuaFunction, "a string-created trigger is never in registered-function mode"); + + runLua(qsl("feedTriggers('setscript_plain\\n')")); + QCOMPARE(luaInt(qsl("plainA")), 1); + + QVERIFY(pTrigger->setScript(qsl("plainB = plainB + 1"))); + QVERIFY2(!pTrigger->mRegisteredAnonymousLuaFunction, "setScript() must not push a string item into registered-function mode"); + + runLua(qsl("feedTriggers('setscript_plain\\n')")); + QCOMPARE(luaInt(qsl("plainB")), 1); + QCOMPARE(luaInt(qsl("plainA")), 1); + } + + // Helpers (reused from the EnableDisableByNameTest/TFeedTriggersRecursionTest pattern) + + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResourcesForSetScriptCallbackTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "SetScriptCallbackTest.moc" +QTEST_MAIN(SetScriptCallbackTest) diff --git a/test/functional_tests/SgrUnderlineStyleTest.cpp b/test/functional_tests/SgrUnderlineStyleTest.cpp new file mode 100644 index 000000000..7238f30a8 --- /dev/null +++ b/test/functional_tests/SgrUnderlineStyleTest.cpp @@ -0,0 +1,271 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Tests for the SGR colon-form underline sub-parameter decoding (ESC[4:Nm). + * + * The sub-parameter values follow the widely-adopted kitty/VTE convention: + * 4:0 none, 4:1 single, 4:2 double, 4:3 curly, 4:4 dotted, 4:5 dashed. + * These are decoded in TBuffer::decodeSGR(). This test injects each sequence + * and asserts the resulting cell carries the expected internal underline + * attributes - in particular that 4:5 yields a dashed underline rather than + * clearing the underline entirely. + * + * Uses loopbackTest() to inject data directly into the telnet processing + * pipeline, avoiding per-test TCP connections and profile creation. + * + * Run with: ctest -R SgrUnderlineStyleTest -V + */ + +#include <QtTest/QtTest> + +#include "MudletInstanceCoordinator.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForUnderlineTest(); + +class SgrUnderlineStyleTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "SGR-Underline-Test-Host"; + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = "localhost"; + + // Injects raw telnet data into the processing pipeline via loopback and + // waits for the buffer to process it. + void injectData(const QString& message) + { + QByteArray data = (message + qsl("\r\n")).toUtf8(); + mpHost->mTelnet.loopbackTest(data); + QTest::qWait(50); + } + + // Scans the buffer for the first cell whose grapheme matches marker and + // returns its TChar, or std::nullopt if none is found. + std::optional<TChar> findCell(QChar marker) + { + TMainConsole* console = mpHost->mpConsole; + for (int line = 0; line <= console->buffer.getLastLineNumber(); ++line) { + const QString& text = console->buffer.lineBuffer.at(line); + for (int col = 0; col < text.length(); ++col) { + if (text.at(col) == marker) { + return console->buffer.buffer.at(line).at(col); + } + } + } + return std::nullopt; + } + +private slots: + // Start mudlet and create a profile once for all tests. + void initTestCase() + { + initializeQRCResourcesForUnderlineTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + QDir(path).removeRecursively(); + + QTimer::singleShot(0, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(1000)) { + QFAIL("Profile took too long to load."); + } + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(500)) { + QFAIL("Could not connect with the host."); + } + } + + // Clear buffer before each test for isolation. + void init() + { + QVERIFY(mpHost); + QVERIFY(mpHost->mpConsole); + mpHost->mpConsole->buffer.clear(); + } + + // Data-driven test: each ESC[4:Nm sequence must map to the expected internal + // underline attributes following the kitty/VTE convention. + void test_ColonUnderlineStyle_data() + { + QTest::addColumn<QString>("sequence"); + QTest::addColumn<bool>("underlined"); + QTest::addColumn<bool>("wavy"); + QTest::addColumn<bool>("dotted"); + QTest::addColumn<bool>("dashed"); + + // under wavy dotted dashed + QTest::newRow("4:0 none") << qsl("\x1b[4:0m") << false << false << false << false; + QTest::newRow("4:1 single") << qsl("\x1b[4:1m") << true << false << false << false; + // Mudlet has no distinct double-underline style, so 4:2 shows as single. + QTest::newRow("4:2 double") << qsl("\x1b[4:2m") << true << false << false << false; + QTest::newRow("4:3 curly") << qsl("\x1b[4:3m") << true << true << false << false; + QTest::newRow("4:4 dotted") << qsl("\x1b[4:4m") << true << false << true << false; + QTest::newRow("4:5 dashed") << qsl("\x1b[4:5m") << true << false << false << true; + } + + void test_ColonUnderlineStyle() + { + QFETCH(QString, sequence); + QFETCH(bool, underlined); + QFETCH(bool, wavy); + QFETCH(bool, dotted); + QFETCH(bool, dashed); + + // Reset the pen with ESC[0m so no prior test's underline state leaks in, + // then apply the sequence and inspect the marker 'U' cell. + injectData(qsl("\x1b[0m") + sequence + qsl("U")); + + auto cell = findCell(QLatin1Char('U')); + QVERIFY2(cell.has_value(), "Marker character 'U' not found in buffer"); + + QCOMPARE(cell->isUnderlined(), underlined); + QCOMPARE(cell->isUnderlineWavy(), wavy); + QCOMPARE(cell->isUnderlineDotted(), dotted); + QCOMPARE(cell->isUnderlineDashed(), dashed); + } + + // Data-driven test: applying a colon style over an existing curly underline + // must clear the sibling style flags, actually turn the underline off for + // 4:0, and fall back to no underline for out-of-range values. This guards + // against the stale-state carry-over class of bug the fix addresses. + void test_ColonUnderlineStyleTransition_data() + { + QTest::addColumn<QString>("sequence"); + QTest::addColumn<bool>("underlined"); + QTest::addColumn<bool>("wavy"); + QTest::addColumn<bool>("dotted"); + QTest::addColumn<bool>("dashed"); + + // under wavy dotted dashed + QTest::newRow("curly then 4:0 clears") << qsl("\x1b[4:0m") << false << false << false << false; + QTest::newRow("curly then 4:4 dotted") << qsl("\x1b[4:4m") << true << false << true << false; + QTest::newRow("curly then 4:5 dashed") << qsl("\x1b[4:5m") << true << false << false << true; + // Out-of-range values hit the default arm and clear the underline. + QTest::newRow("curly then 4:6 out-of-range") << qsl("\x1b[4:6m") << false << false << false << false; + } + + void test_ColonUnderlineStyleTransition() + { + QFETCH(QString, sequence); + QFETCH(bool, underlined); + QFETCH(bool, wavy); + QFETCH(bool, dotted); + QFETCH(bool, dashed); + + // Establish a curly underline first, then apply the sequence under test + // to the same pen so sibling-flag clearing is exercised. + injectData(qsl("\x1b[0m\x1b[4:3m") + sequence + qsl("U")); + + auto cell = findCell(QLatin1Char('U')); + QVERIFY2(cell.has_value(), "Marker character 'U' not found in buffer"); + + QCOMPARE(cell->isUnderlined(), underlined); + QCOMPARE(cell->isUnderlineWavy(), wavy); + QCOMPARE(cell->isUnderlineDotted(), dotted); + QCOMPARE(cell->isUnderlineDashed(), dashed); + } + + // The plain numeric ESC[4m (no colon) must remain a single underline - the + // fix only touches the colon sub-parameter path. + void test_PlainUnderlineUnchanged() + { + injectData(qsl("\x1b[0m\x1b[4mU")); + + auto cell = findCell(QLatin1Char('U')); + QVERIFY2(cell.has_value(), "Marker character 'U' not found in buffer"); + + QVERIFY(cell->isUnderlined()); + QVERIFY(!cell->isUnderlineWavy()); + QVERIFY(!cell->isUnderlineDotted()); + QVERIFY(!cell->isUnderlineDashed()); + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + mpHost = nullptr; + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + QDir(path).removeRecursively(); + delete mudlet::self(); + } +}; + +void initializeQRCResourcesForUnderlineTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "SgrUnderlineStyleTest.moc" +QTEST_MAIN(SgrUnderlineStyleTest) diff --git a/test/functional_tests/StarterUiTriggerCostTest.cpp b/test/functional_tests/StarterUiTriggerCostTest.cpp new file mode 100644 index 000000000..017e3c2fe --- /dev/null +++ b/test/functional_tests/StarterUiTriggerCostTest.cpp @@ -0,0 +1,738 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +// The starter UI (mudlet-base-ui) is preinstalled into new profiles, so every +// always-active trigger it arms is matched against every line the game sends. +// This pins what that costs and that the capture layers still capture. + +#include <QtTest/QtTest> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TTrigger.h" +#include "TelnetServerStub.h" +#include "TriggerUnit.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "dlgTriggerEditor.h" +#include <QTreeWidget> +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +static void initializeQRCResources(); + +class StarterUiTriggerCostTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + const QString mHostname = qsl("Test-StarterUiTriggerCost"); + const QString mLocalhost = qsl("localhost"); + quint16 mPort = 0; + + // A full default-package profile measures 3: the starter UI's chat capture + // tree and its vitals prefilter, plus one folder from another package. + // Nesting hides growth from this count, so kMaxGatePatterns is what tracks + // the per-line cost. + static constexpr int kMaxRootTriggers = 5; + + // What every line of game text really pays for: the substrings the chat + // gates scan for before any regex runs. Measures 17. Raising this is a + // throughput change and wants measuring first. + static constexpr int kMaxGatePatterns = 20; + +private slots: + void initTestCase() { initializeQRCResources(); } + + void init() + { + mpServer = new TelnetServerStub(qApp); + // Ephemeral port so parallel worktree runs never collide. + mpServer->start(mLocalhost, 0); + mPort = mpServer->serverPort(); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + } + + void cleanup() + { + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + + void test_captureLayersArmAHandfulOfTriggersNotOnePerShape() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + const int rootTriggers = static_cast<int>(host->getTriggerUnit()->getTriggerRootNodeList().size()); + QVERIFY2(rootTriggers > 0, "no triggers are registered at all - the profile did not finish loading its packages"); + QVERIFY2(rootTriggers <= kMaxRootTriggers, + qPrintable(qsl("a new user's profile arms %1 always-active root triggers, and every line of game " + "text is matched against all of them") + .arg(rootTriggers))); + + QVERIFY(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 1"))); + QVERIFY2(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")), "the chat capture tree's gates did not come up armed"); + } + + // Miss a line here and that game's gauges silently never appear. + // + // The label list in the script is hand-written, not derived from the + // package's label tables, so this catches a new shape whose spelling is + // missing from the prefilter but not a new spelling bolted onto an existing + // shape - add spellings to promptLabels and friends, not inline. + void test_thePrefilterMatchesEveryLineTheShapesRead() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + QVERIFY2(runLua(host, prefilterDifferentialScript()), "the prefilter differential did not run - see the profile's error console"); + QVERIFY2(luaTrue(host, qsl("__starterUi.misses == 0")), "the vitals prefilter drops lines the shapes read - the first few are in the error console"); + // Without this the assertion above holds vacuously. + QVERIFY2(luaTrue(host, qsl("__starterUi.readable > 1500")), "the generated corpus stopped producing readings, so the prefilter check proved nothing"); + QVERIFY2(luaTrue(host, qsl("__starterUi.shapesFired == __starterUi.shapeCount")), + "the generated corpus no longer exercises every vitals shape - a new shape needs a layout or label " + "adding to the lists in prefilterDifferentialScript()"); + } + + // The fallback to a pattern string still works, so nothing else fails when + // a shape is recompiled per line. + void test_theShapesArePrecompiled() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + QVERIFY2(luaTrue(host, qsl("BaseUI.shapesArePrecompiled()")), "a chat or vitals shape is not a compiled regex object, so it is recompiled on every line"); + } + + // A label-after-the-numbers prompt is read only by recurrence-gated shapes, + // so this pins the gate as well: nothing until the third sighting. + void test_aPlainTextPromptStillDrivesTheGauges() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + feedLine(host, qsl("<523/600hp 210/250m 80/100mv>")); + QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp == nil")), "a gated prompt shape drove the gauges on first sight"); + feedLine(host, qsl("<522/600hp 209/250m 79/100mv>")); + QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.hp == nil"))); + + feedLine(host, qsl("<521/600hp 208/250m 78/100mv>")); + QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp ~= nil and BaseUI.vitalsData.hp.max == 600")), + "a recurring cur/max prompt no longer reaches the gauges - the prefilter is dropping lines the " + "vitals shapes read"); + QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.hp.current == 521"))); + QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.mv ~= nil and BaseUI.vitalsData.mv.max == 100"))); + } + + void test_aPercentagePromptStillDrivesTheGauges() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + for (int i = 0; i < 3; ++i) { + feedLine(host, qsl("<87%hp 80%m>")); + } + QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp ~= nil and BaseUI.vitalsData.hp.percent == 87")), "a recurring percentage prompt no longer reaches the gauges"); + QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.mp.percent == 80"))); + } + + // A current-only prompt cannot supply a maximum, so the layer sends "score" + // once - the one capture path with a visible side effect on the game. + void test_aCurrentOnlyPromptStillAsksTheGameForItsScoreScreen() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + QVERIFY(luaTrue(host, qsl("not BaseUI.scoreRequested"))); + + for (int i = 0; i < 3; ++i) { + feedLine(host, qsl("<523hp 210m 80mv>")); + } + QVERIFY2(luaTrue(host, qsl("BaseUI.scoreRequested")), + "a current-only prompt no longer reaches BaseUI.maybeRequestScore, so games whose prompt carries no " + "maximum never get gauges"); + } + + // Score-screen rows are trusted on first sight: a score may only be shown once. + void test_aScoreScreenIsStillReadOnFirstSight() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + feedLine(host, qsl("Health: 3600/3600 Mana: 3400/3400")); + QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp ~= nil and BaseUI.vitalsData.hp.max == 3600")), "a score-screen row was not read on first sight"); + QVERIFY(luaTrue(host, qsl("BaseUI.vitalsData.mp ~= nil and BaseUI.vitalsData.mp.max == 3400"))); + + // Chat is conversation, not a prompt: a tell quoting numbers must not + // move the gauges, which is the chat shapes being consulted from the + // vitals path. + feedLine(host, qsl("Bob tells you, 'I am somehow alive at 11/12 hp'")); + QVERIFY2(luaTrue(host, qsl("BaseUI.vitalsData.hp.max == 3600")), "a tell was harvested for vitals"); + } + + // Every chat shape needs a line here. The tree's gates are + // case-sensitive substrings, so a shape whose literal is spelled differently + // in its gate silently never routes, and only a line exercising that shape + // notices. + void test_chatCaptureStillSortsLinesIntoTheirTabs() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + // second is the tab besides All the line has to reach, empty for the + // shapes that only ever reach All + const QList<QPair<QString, QString>> corpus = { + {qsl("Bob tells you, 'hello there'"), qsl("tells")}, + {qsl("You tell Ann, 'on my way'"), qsl("tells")}, + {qsl("You tell the formation you are ready."), qsl("tells")}, + {qsl("Ann whispers to you, 'psst'"), qsl("tells")}, + {qsl("Bob tells the group, 'incoming'"), qsl("tells")}, + {qsl("Bob says, 'hello everyone'"), QString()}, + {qsl("Bob asks, 'where is the bank?'"), QString()}, + {qsl("Bob exclaims, 'at last!'"), QString()}, + {qsl("You say, 'hi there'"), QString()}, + {qsl("You ask, 'which way?'"), QString()}, + {qsl("You exclaim, 'finally!'"), QString()}, + {qsl("Bob yells, 'help!'"), QString()}, + {qsl("Bob shouts, 'to arms!'"), QString()}, + {qsl("You yell, 'wait for me!'"), QString()}, + {qsl("You shout, 'over here!'"), QString()}, + {qsl("[tell] Ann: are you there?"), qsl("tells")}, + {qsl("[newbie] Ann: how do I get out of here?"), qsl("channels")}, + {qsl("(gossip) Ann: anyone around?"), qsl("channels")}, + {qsl("< chat | Ann: anyone around?"), qsl("channels")}, + }; + + QHash<QString, int> expectedUnread; + for (const auto& [text, family] : corpus) { + feedLine(host, text); + // routeChatLine() records what it copied, and counts the active tab + // (All) as read, so this is what says the line reached the dock + QVERIFY2(luaTrue(host, qsl("BaseUI.recentCaptures[#BaseUI.recentCaptures] ~= nil and BaseUI.recentCaptures[#BaseUI.recentCaptures].text == %1").arg(luaLiteral(text))), + qPrintable(qsl("the chat tree did not route: %1").arg(text))); + if (family.isEmpty()) { + continue; + } + expectedUnread[family] += 1; + QVERIFY2(luaTrue(host, qsl("BaseUI.unread.%1 == %2").arg(family, QString::number(expectedUnread.value(family)))), qPrintable(qsl("did not reach the %1 tab: %2").arg(family, text))); + } + + // BaseUI.chatChannelNames has the last word on a captured tag, so this + // line reaches no tab at all rather than only missing Channels. + QVERIFY(runLua(host, qsl("__starterUiCaptures = #BaseUI.recentCaptures"))); + feedLine(host, qsl("[inventory] a rusty sword")); + QVERIFY2(luaTrue(host, qsl("#BaseUI.recentCaptures == __starterUiCaptures")), "an unknown tag was captured as chat"); + + QVERIFY(runLua(host, chatShapeCoverageScript(corpus))); + QVERIFY2(luaTrue(host, qsl("__starterUi.uncoveredShapes == 0")), + "a chat shape has no line in the corpus above, so nothing would notice if its gate stopped matching - " + "the shapes are named in the error console"); + QVERIFY2(luaTrue(host, qsl("__starterUi.unroutedLines == 0")), + "a corpus line routes through the trigger tree but no chatPatterns shape recognises it, so the vitals " + "layer would harvest it - the lines are in the error console"); + + assertCorpusCoversEveryGateLiteral(host, corpus); + } + + // The gate layer is what every line pays for, so it has to stay substring + // matching, and the shapes hanging off it have to stay the ones the script + // knows about - chatLikeLine() reads that list to keep chat out of the + // vitals layer, and a shape only the tree has would be harvested for gauges. + void test_theChatTreeGatesOnSubstringsAndKeepsTheScriptsShapes() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + + TTrigger* tree = findTrigger(host, qsl("Mudlet base UI chat capture")); + QVERIFY2(tree, "the chat capture tree is not in the profile under the name the script arms"); + QVERIFY2(tree->getPatternsList().isEmpty(), "the chat tree's root folder grew a pattern, so it is a chain now and no longer passes every line to its gates"); + + int gatePatterns = 0; + QStringList shapes; + for (auto gate : *tree->getChildrenList()) { + const QList<int> kinds = gate->getRegexCodePropertyList(); + QVERIFY2(!kinds.isEmpty(), qPrintable(qsl("gate \"%1\" has no patterns, so it passes every line straight to its regexes").arg(gate->getName()))); + for (const int kind : kinds) { + QVERIFY2(kind == REGEX_SUBSTRING || kind == REGEX_BEGIN_OF_LINE_SUBSTRING, + qPrintable(qsl("gate \"%1\" has a pattern of kind %2 - a gate must be substring matching only, or every line pays for a regex").arg(gate->getName(), QString::number(kind)))); + } + gatePatterns += static_cast<int>(kinds.size()); + for (auto shape : *gate->getChildrenList()) { + shapes << shape->getPatternsList(); + } + } + + QVERIFY2(gatePatterns <= kMaxGatePatterns, + qPrintable(qsl("the chat gates scan every line for %1 substrings, over the budget of %2").arg(QString::number(gatePatterns), QString::number(kMaxGatePatterns)))); + + QVERIFY(runLua(host, treeShapeComparisonScript(shapes))); + QVERIFY2(luaTrue(host, qsl("__starterUi.shapeMismatch == nil")), + "the tree's shapes and the script's chatPatterns have drifted apart - chatLikeLine() would stop recognising a " + "line the tree routes, and the vitals layer would read it as a prompt. The first difference is in the error console"); + } + + // A game that sends chat over GMCP does not need the gates, but the next + // connection might. + void test_theChatLayerRetiresOnceGmcpChatAppears() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + QVERIFY(luaTrue(host, qsl("BaseUI.chatTriggersArmed()"))); + + QVERIFY(runLua(host, + qsl("gmcp = gmcp or {}\n" + "gmcp.Comm = { Channel = { Text = { channel = 'chat', text = 'hello there' } } }\n" + "BaseUI.addChatMessage()"))); + QVERIFY2(luaTrue(host, qsl("BaseUI.gmcpChat")), "a GMCP chat message did not retire the trigger layer"); + QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "the chat gates stayed armed after GMCP chat arrived, so lines would be captured twice"); + + QVERIFY(runLua(host, qsl("BaseUI.handleDisconnect()"))); + QVERIFY2(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")), "the chat gates did not re-arm after a disconnect"); + + QVERIFY(runLua(host, qsl("BaseUI.hide()"))); + QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "\"baseui hide\" left the chat gates armed"); + QVERIFY(runLua(host, qsl("BaseUI.handleDisconnect()"))); + QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "a disconnect re-armed the chat gates of a hidden UI"); + + QVERIFY(runLua(host, qsl("BaseUI.show()"))); + QVERIFY2(luaTrue(host, qsl("BaseUI.chatTriggersArmed()")), "\"baseui show\" did not bring the chat gates back"); + } + + // Shipping the tree visible invites players to copy it, and the editor's + // paste keeps every name. enableTrigger()/disableTrigger() can only name a + // trigger, so a lifecycle built on a name a copy reproduces would reach + // into the player's triggers - and their active copy would answer for ours + // when we asked whether the layer was armed. + void test_copyingTheTreeLeavesThePlayersTriggersAlone() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + auto* tree = triggerTreeWidget(host); + QVERIFY2(tree, "could not reach the editor's trigger tree"); + + QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTreeShared()")), "a fresh profile already has more than one chat capture tree"); + QVERIFY(runLua(host, qsl("BaseUI.disarmChatTriggers()"))); + QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "the layer did not disarm on a profile with no copies"); + QVERIFY(runLua(host, qsl("BaseUI.armChatTriggers()"))); + QVERIFY(luaTrue(host, qsl("BaseUI.chatTriggersArmed()"))); + + // the likely copy: one gate, to adapt for themselves + pasteCopyOf(tree, qsl("BaseUI chat: tells")); + QList<TTrigger*> gates; + collectByName(host, qsl("BaseUI chat: tells"), gates); + QCOMPARE(gates.size(), 2); + QVERIFY(runLua(host, qsl("BaseUI.disarmChatTriggers()"))); + QVERIFY2(gates.at(1)->isActive(), "retiring the chat layer switched off the player's copy of a gate"); + QVERIFY2(luaTrue(host, qsl("not BaseUI.chatTriggersArmed()")), "the player's copy answered for ours when we asked whether the layer was armed"); + QVERIFY(runLua(host, qsl("BaseUI.armChatTriggers()"))); + + // and the whole tree, name and all - now we cannot tell ours apart + pasteCopyOf(tree, qsl("Mudlet base UI chat capture")); + QVERIFY2(luaTrue(host, qsl("BaseUI.chatTreeShared()")), "a second tree with our folder's name went unnoticed"); + QList<TTrigger*> folders; + collectByName(host, qsl("Mudlet base UI chat capture"), folders); + QCOMPARE(folders.size(), 2); + QVERIFY(runLua(host, qsl("BaseUI.disarmChatTriggers()"))); + QVERIFY2(folders.at(1)->isActive(), "retiring the chat layer switched off the player's copy of the whole tree"); + + // giving up the switch is only safe because routeChatLine() re-checks + QVERIFY(runLua(host, qsl("__starterUiCaptures = #BaseUI.recentCaptures\nBaseUI.gmcpChat = true"))); + feedLine(host, qsl("Ann whispers to you, 'psst'")); + QVERIFY2(luaTrue(host, qsl("#BaseUI.recentCaptures == __starterUiCaptures")), + "with the switch given up, a chat line was still captured after GMCP chat took over - the per-line guard is what " + "keeps this correct and it did not hold"); + } + + void test_theVitalsLayerRetiresOnceAProtocolOwnsTheGauges() + { + Host* host = startProfileWithStarterUi(); + QVERIFY(host); + QVERIFY(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 1"))); + + QVERIFY(runLua(host, + qsl("gmcp = gmcp or {}\n" + "gmcp.Char = { Vitals = { hp = 500, maxhp = 600 } }\n" + "BaseUI.updateVitals()"))); + QVERIFY2(luaTrue(host, qsl("BaseUI.structuredVitalsOwnGauges()")), "GMCP vitals did not take the source lock"); + QVERIFY2(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 0")), "the plain-text vitals triggers stayed armed after GMCP took the gauges over"); + + // The next connection may have no protocol at all. + QVERIFY(runLua(host, qsl("BaseUI.handleDisconnect()"))); + QVERIFY2(luaTrue(host, qsl("#BaseUI.vitalsTriggerIds == 1")), "the vitals layer did not re-arm after a disconnect"); + } + +private: + // Quotes and backslashes in a corpus line have to reach the Lua state + // exactly as they were fed to the trigger engine. + static QString luaLiteral(const QString& text) { return qsl("[==[%1]==]").arg(text); } + + // Drives the editor the way a player does: its copy/paste goes through the + // same XML export/import as a package, so this covers both. + QTreeWidget* triggerTreeWidget(Host* host) + { + dlgTriggerEditor* editor = host->mpEditorDialog; + if (!editor) { + return nullptr; + } + QMetaObject::invokeMethod(editor, "slot_showTriggers"); + QCoreApplication::processEvents(); + auto* tree = editor->findChild<QTreeWidget*>(qsl("treeWidget_triggers")); + return (tree && tree->topLevelItemCount() > 0) ? tree : nullptr; + } + + void pasteCopyOf(QTreeWidget* tree, const QString& name) + { + dlgTriggerEditor* editor = tree->window()->findChild<dlgTriggerEditor*>(); + editor = editor ? editor : qobject_cast<dlgTriggerEditor*>(tree->window()); + QVERIFY(editor); + // the editor rebuilds the tree after a paste, so nothing may be cached + QTreeWidgetItem* base = tree->topLevelItem(0); + QVERIFY(base); + QTreeWidgetItem* item = findItem(base, name); + QVERIFY2(item, qPrintable(qsl("no editor tree item called %1").arg(name))); + tree->setCurrentItem(item); + QCoreApplication::processEvents(); + QMetaObject::invokeMethod(editor, "slot_copyXml"); + QCoreApplication::processEvents(); + tree->setCurrentItem(tree->topLevelItem(0)); + QCoreApplication::processEvents(); + QMetaObject::invokeMethod(editor, "slot_pasteXml"); + QCoreApplication::processEvents(); + } + + static QTreeWidgetItem* findItem(QTreeWidgetItem* parent, const QString& name) + { + if (!parent) { + return nullptr; + } + if (parent->text(0) == name) { + return parent; + } + for (int i = 0; i < parent->childCount(); ++i) { + if (QTreeWidgetItem* found = findItem(parent->child(i), name)) { + return found; + } + } + return nullptr; + } + + static void collectByName(Host* host, const QString& name, QList<TTrigger*>& found) + { + for (auto root : host->getTriggerUnit()->getTriggerRootNodeList()) { + collectByNameIn(root, name, found); + } + } + + static void collectByNameIn(TTrigger* trigger, const QString& name, QList<TTrigger*>& found) + { + if (trigger->getName() == name) { + found << trigger; + } + for (auto child : *trigger->getChildrenList()) { + collectByNameIn(child, name, found); + } + } + + static TTrigger* findTrigger(Host* host, const QString& name) + { + for (auto root : host->getTriggerUnit()->getTriggerRootNodeList()) { + if (TTrigger* found = findTriggerIn(root, name)) { + return found; + } + } + return nullptr; + } + + static TTrigger* findTriggerIn(TTrigger* trigger, const QString& name) + { + if (trigger->getName() == name) { + return trigger; + } + for (auto child : *trigger->getChildrenList()) { + if (TTrigger* found = findTriggerIn(child, name)) { + return found; + } + } + return nullptr; + } + + void assertCorpusCoversEveryGateLiteral(Host* host, const QList<QPair<QString, QString>>& corpus) + { + TTrigger* tree = findTrigger(host, qsl("Mudlet base UI chat capture")); + QVERIFY(tree); + for (auto gate : *tree->getChildrenList()) { + const QStringList patterns = gate->getPatternsList(); + const QList<int> kinds = gate->getRegexCodePropertyList(); + for (int i = 0; i < patterns.size() && i < kinds.size(); ++i) { + const QString& literal = patterns.at(i); + bool exercised = false; + for (const auto& [text, family] : corpus) { + // matching the engine: substrings are indexOf, the rest startsWith + exercised = kinds.at(i) == REGEX_SUBSTRING ? text.contains(literal, Qt::CaseSensitive) : text.startsWith(literal, Qt::CaseSensitive); + if (exercised) { + break; + } + } + QVERIFY2(exercised, + qPrintable(qsl("no corpus line exercises \"%1\" in gate \"%2\", so that literal could be misspelled and " + "the chat it gates would silently stop appearing") + .arg(literal, gate->getName()))); + } + } + } + + static QString treeShapeComparisonScript(const QStringList& treeShapes) + { + QStringList entries; + for (const QString& shape : treeShapes) { + entries << luaLiteral(shape); + } + return qsl(R"LUA( +local tree = { %1 } +local script = BaseUI.chatShapeRegexes() +__starterUi = __starterUi or {} +__starterUi.shapeMismatch = nil +for i = 1, math.max(#tree, #script) do + if tree[i] ~= script[i] then + __starterUi.shapeMismatch = string.format("shape %d: tree has %s, chatPatterns has %s", + i, tostring(tree[i]), tostring(script[i])) + echo("\n[ chat shape drift ] " .. __starterUi.shapeMismatch .. "\n") + break + end +end +)LUA") + .arg(entries.join(qsl(", "))); + } + + // Holds the corpus fed above against the shapes chatLikeLine() walks: every + // shape needs a line, and every line needs a shape. + static QString chatShapeCoverageScript(const QList<QPair<QString, QString>>& corpus) + { + QStringList entries; + for (const auto& entry : corpus) { + entries << luaLiteral(entry.first); + } + return qsl(R"LUA( +local corpus = { %1 } +__starterUi = { uncoveredShapes = 0, unroutedLines = 0 } + +for _, regex in ipairs(BaseUI.chatShapeRegexes()) do + local covered = false + for _, text in ipairs(corpus) do + if rex.find(text, regex) then + covered = true + break + end + end + if not covered then + __starterUi.uncoveredShapes = __starterUi.uncoveredShapes + 1 + echo("\n[ chat shape with no corpus line ] " .. regex .. "\n") + end +end + +for _, text in ipairs(corpus) do + if not BaseUI.chatLikeLine(text) then + __starterUi.unroutedLines = __starterUi.unroutedLines + 1 + echo("\n[ corpus line no chat shape recognises ] " .. text .. "\n") + end +end +)LUA") + .arg(entries.join(qsl(", "))); + } + + // Runs inside the profile's Lua state, against the real + // BaseUI.parseVitalsLine and BaseUI.vitalsPrefilter. + static QString prefilterDifferentialScript() + { + return qsl(R"LUA( +local labels = { + "hp", "health", "hit", "hits", "hitpoint", "hitpoints", "hit point", "hit points", "h", + "mp", "mana", "sp", "magic", "energy", "blood", "spell point", "spell points", "spellpoints", "m", + "mv", "move", "moves", "movement", "movements", "move point", "move points", "movement points", + "stamina", "st", "endurance", "end", "vitality", + "xp", "exp", "experience", "experience point", "experience points", "exp points", "tnl", +} +local templates = { + "@: 100/120", "@ 100/120", "@100/120", "100/120 @", "100/120@", "100 / 120 @", + "@: 87%", "87% @", "@ 87%", "87%@", "@87%", + "@: 100", "100 @", "100@", + "| @: 100/120 |", "| @ : 100/120 |", "@ : 100 of 120", "@: 100(120)", "@ 100 ( 120 )", + "You have 100/120 @.", "You have 100(120) @.", "You have 100/120 @points.", + "You have 100/120 @ and 50/60 mana.", "You have 100/120 @ left.", + "Level: 5 @: 100/120 Pager ( )", "@ : [ 100/120 ]", "@: 12,345/23,456", + "PRACT: 005 @: 90 of 90", " @: 3600/3600 Mana: 3400/3400", + "#### @ 100/120 ####", "50 @(50).", + "| @: 100(120) |", "| @ : 100 of 120 |", "| Race: Undead | @: 4252/4252 |", +} + +__starterUi = { misses = 0, readable = 0, shapeSeen = {} } +local reported = 0 + +for _, label in ipairs(labels) do + for _, template in ipairs(templates) do + for _, spelling in ipairs({ label, label:sub(1, 1):upper() .. label:sub(2), label:upper() }) do + local line = template:gsub("@", spelling) + local readings = BaseUI.parseVitalsLine(line) + if #readings > 0 then + __starterUi.readable = __starterUi.readable + 1 + for _, reading in ipairs(readings) do + __starterUi.shapeSeen[reading.pattern] = true + end + -- rex.find: rex.match returns false for an unset capture group + if not rex.find(line, BaseUI.vitalsPrefilter) then + __starterUi.misses = __starterUi.misses + 1 + if reported < 5 then + reported = reported + 1 + echo("\n[ prefilter MISS ] " .. line .. "\n") + end + end + end + end + end +end + +__starterUi.shapesFired = 0 +for _ in pairs(__starterUi.shapeSeen) do + __starterUi.shapesFired = __starterUi.shapesFired + 1 +end +__starterUi.shapeCount = BaseUI.vitalsShapeCount() +)LUA"); + } + + Host* startProfileWithStarterUi() + { + startProfile(); + Host* host = mudlet::self()->getActiveHost(); + if (!host) { + return nullptr; + } + host->mEchoLuaErrors = true; + // Installed by hand only when the preinstall gate did not, so this test + // says nothing about who counts as a new user. + if (!host->mInstalledPackages.contains(qsl("mudlet-base-ui"))) { + auto [installed, message] = host->installPackage(qsl(":/packages/mudlet-base-ui/mudlet-base-ui.mpackage"), enums::PackageModuleType::Package, true); + if (!installed) { + qWarning("%s", qPrintable(qsl("could not install the starter UI: %1").arg(message))); + return nullptr; + } + } + // A hidden or stood-aside setting would suppress every capture trigger. + if (!luaTrue(host, qsl("type(BaseUI) == 'table' and not BaseUI.dormant()"))) { + qWarning("the starter UI did not load, or loaded dormant"); + return nullptr; + } + return host; + } + + // Mirrors the helper the other functional tests use. + void startProfile() + { + const QString port = QString::number(mPort); + QTimer::singleShot(0, qApp, [this, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy loaded(mudlet::self(), &mudlet::signal_profileLoaded); + if (!loaded.wait(5000)) { + QFAIL("Profile took too long to load."); + } + Host* host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + QSignalSpy connected(&(host->mTelnet), &cTelnet::signal_connected); + if (!connected.wait(3000)) { + QFAIL("Could not connect to the stub."); + } + } + + void feedLine(Host* host, const QString& text) + { + QByteArray data = text.toUtf8() + "\r\n"; + data.reserve(data.size() + 16); + host->mTelnet.loopbackTest(data); + } + + bool runLua(Host* host, const QString& script) { return host->getLuaInterpreter()->compileAndExecuteScript(script); } + + bool luaTrue(Host* host, const QString& expression) + { + if (!runLua(host, qsl("__starterUiProbe = not not (%1)").arg(expression))) { + qWarning("%s", qPrintable(qsl("probe did not compile: %1").arg(expression))); + return false; + } + const bool result = runLua(host, qsl("assert(__starterUiProbe)")); + if (!result) { + qWarning("%s", qPrintable(qsl("probe is false: %1").arg(expression))); + } + return result; + } + + void deleteProfileDirectory(const QString& profileName) + { + QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName)); + if (dir.exists()) { + dir.removeRecursively(); + } + } +}; + +static void initializeQRCResources() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "StarterUiTriggerCostTest.moc" +QTEST_MAIN(StarterUiTriggerCostTest) diff --git a/test/functional_tests/SubCommandLineLifetimeTest.cpp b/test/functional_tests/SubCommandLineLifetimeTest.cpp new file mode 100644 index 000000000..d3010cca9 --- /dev/null +++ b/test/functional_tests/SubCommandLineLifetimeTest.cpp @@ -0,0 +1,340 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression test for a dangling TCommandLine* left behind in + * TMainConsole::mSubCommandLineMap when the widget that owns the command line + * is deleted. + * + * A TCommandLine is always a child widget of some other widget (the miniconsole + * it is embedded in, or the user window / scroll box it was created into), so + * Qt's parent-child ownership frees it along with that parent. The map entry + * registered in TConsole::setCmdVisible() / TMainConsole::createCommandLine() + * survived, leaving a non-null pointer to freed memory that every later lookup + * of that name dereferenced. + * + * The last test here is the one that needs no Lua at all: TConsole::setFont() + * walks the whole map and calls console() on every entry, and that walk is + * reached from Host::setDisplayFont(), i.e. from changing the display font in + * Preferences. + * + * Bootstrap mirrors the other functional tests (e.g. TUserWindowTest). + */ + +#include <QSignalSpy> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TCommandLine.h" +#include "TConsole.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForSubCommandLineTest(); + +class SubCommandLineLifetimeTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "SubCommandLine-Test-Host"; + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = "localhost"; + + // The free happens through deleteLater(), so it only lands once control + // returns to the event loop - which is exactly what makes the stale entry + // point at freed memory rather than at a doomed but still live widget. + void runDeferredDeletes() + { + QTest::qWait(50ms); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(); + } + +private slots: + // Start mudlet and create a profile once for all tests. + void initTestCase() + { + initializeQRCResourcesForSubCommandLineTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + QDir(path).removeRecursively(); + + QTimer::singleShot(0ms, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(1000)) { + QFAIL("Profile took too long to load."); + } + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(500)) { + QFAIL("Could not connect with the host."); + } + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + mpHost = nullptr; + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + QDir(path).removeRecursively(); + delete mudlet::self(); + } + + void init() + { + QVERIFY(mpHost); + QVERIFY(mpHost->mpConsole); + } + + // Route 1: enableCommandLine() on a miniconsole, then deleteMiniConsole(). + // The command line is a child of the miniconsole, so the miniconsole's + // destruction frees it. + void test_miniConsoleCommandLineDeregistersWhenConsoleDeleted() + { + TMainConsole* console = mpHost->mpConsole; + const QString name = qsl("doomedMiniConsole"); + + TConsole* miniConsole = console->createMiniConsole(QString(), name, 0, 0, 300, 100); + QVERIFY2(miniConsole, "could not create the miniconsole"); + miniConsole->setCmdVisible(true); // what Lua enableCommandLine(name) does + QVERIFY2(console->mSubCommandLineMap.contains(name), "command line not registered after enabling it"); + + auto [deleted, deleteMsg] = console->deleteMiniConsole(name); + QVERIFY2(deleted, qPrintable(deleteMsg)); + runDeferredDeletes(); + + QVERIFY2(!console->mSubCommandLineMap.contains(name), "stale command line entry left behind after deleting the miniconsole that owned it"); + + // The observable non-crashing symptom of the stale entry: the name still + // looks taken, so a fresh command line of that name cannot be made. + auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(created, qPrintable(createMsg)); + console->deleteCommandLine(name); + runDeferredDeletes(); + } + + // Route 2: createCommandLine() into a scroll box, then deleteScrollBox(). + void test_scrollBoxCommandLineDeregistersWhenScrollBoxDeleted() + { + TMainConsole* console = mpHost->mpConsole; + const QString scrollBoxName = qsl("doomedScrollBox"); + const QString cmdLineName = qsl("scrollBoxCmdLine"); + + QVERIFY2(console->createScrollBox(QString(), scrollBoxName, 0, 0, 300, 200), "could not create the scroll box"); + auto [created, createMsg] = console->createCommandLine(scrollBoxName, cmdLineName, 0, 0, 100, 30); + QVERIFY2(created, qPrintable(createMsg)); + QVERIFY(console->mSubCommandLineMap.contains(cmdLineName)); + + auto [deleted, deleteMsg] = console->deleteScrollBox(scrollBoxName); + QVERIFY2(deleted, qPrintable(deleteMsg)); + runDeferredDeletes(); + + QVERIFY2(!console->mSubCommandLineMap.contains(cmdLineName), "stale command line entry left behind after deleting the scroll box that owned it"); + + // Observable consequence: the name is free again. + auto [recreated, recreateMsg] = console->createCommandLine(QString(), cmdLineName, 0, 0, 100, 30); + QVERIFY2(recreated, qPrintable(recreateMsg)); + console->deleteCommandLine(cmdLineName); + runDeferredDeletes(); + } + + // Route 3: createCommandLine() into a user window, then deleteMiniConsole() + // on that user window - the dock owns the command line's parent widget. + void test_userWindowCommandLineDeregistersWhenWindowDeleted() + { + TMainConsole* console = mpHost->mpConsole; + const QString windowName = qsl("doomedUserWindow"); + const QString cmdLineName = qsl("userWindowCmdLine"); + + auto [opened, openMsg] = mpHost->openWindow(windowName, /*loadLayout=*/false, /*autoDock=*/true, qsl("l")); + QVERIFY2(opened, qPrintable(openMsg)); + auto [created, createMsg] = console->createCommandLine(windowName, cmdLineName, 0, 0, 100, 30); + QVERIFY2(created, qPrintable(createMsg)); + QVERIFY(console->mSubCommandLineMap.contains(cmdLineName)); + + auto [deleted, deleteMsg] = console->deleteMiniConsole(windowName); + QVERIFY2(deleted, qPrintable(deleteMsg)); + runDeferredDeletes(); + + QVERIFY2(!console->mSubCommandLineMap.contains(cmdLineName), "stale command line entry left behind after deleting the user window that owned it"); + + auto [recreated, recreateMsg] = console->createCommandLine(QString(), cmdLineName, 0, 0, 100, 30); + QVERIFY2(recreated, qPrintable(recreateMsg)); + console->deleteCommandLine(cmdLineName); + runDeferredDeletes(); + } + + // deleteCommandLine() must not leave the entry behind either - it takes the + // entry itself, so the destructor has to cope with the name already gone. + void test_deleteCommandLineDeregisters() + { + TMainConsole* console = mpHost->mpConsole; + const QString name = qsl("explicitlyDeletedCmdLine"); + + auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(created, qPrintable(createMsg)); + auto [deleted, deleteMsg] = console->deleteCommandLine(name); + QVERIFY2(deleted, qPrintable(deleteMsg)); + runDeferredDeletes(); + + QVERIFY2(!console->mSubCommandLineMap.contains(name), "command line entry left behind after deleteCommandLine()"); + + // Recreating under the same name must work. + auto [recreated, recreateMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(recreated, qPrintable(recreateMsg)); + console->deleteCommandLine(name); + runDeferredDeletes(); + } + + // The no-Lua route: changing the display font in Preferences ends up in + // Host::setDisplayFont() -> TConsole::setFont(), which walks the whole + // mSubCommandLineMap and calls console() on every entry. With a stale entry + // present that is a read of freed memory (a clean heap-use-after-free under + // AddressSanitizer). Kept last so the cheaper assertions above report first. + void test_changingDisplayFontAfterDeletedWindowIsSafe() + { + TMainConsole* console = mpHost->mpConsole; + const QString name = qsl("fontWalkMiniConsole"); + + TConsole* miniConsole = console->createMiniConsole(QString(), name, 0, 0, 300, 100); + QVERIFY2(miniConsole, "could not create the miniconsole"); + miniConsole->setCmdVisible(true); + QVERIFY(console->mSubCommandLineMap.contains(name)); + + auto [deleted, deleteMsg] = console->deleteMiniConsole(name); + QVERIFY2(deleted, qPrintable(deleteMsg)); + runDeferredDeletes(); + + QFont changedFont = mpHost->getDisplayFont(); + changedFont.setPointSize(changedFont.pointSize() == 12 ? 14 : 12); + auto [fontSet, fontMsg] = mpHost->setDisplayFont(changedFont); + QVERIFY2(fontSet, qPrintable(fontMsg)); + + QVERIFY2(!console->mSubCommandLineMap.contains(name), "stale command line entry survived into the setFont() walk"); + + auto [recreated, recreateMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(recreated, qPrintable(recreateMsg)); + console->deleteCommandLine(name); + runDeferredDeletes(); + } + + // Erasure has to be by value, not by name: a replacement registered under the + // same name before the old widget's deferred delete has run must survive it. + void test_recreatingBeforeTheDeferredDeleteKeepsTheNewCommandLine() + { + TMainConsole* console = mpHost->mpConsole; + const QString name = qsl("reusedCmdLineName"); + + auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(created, qPrintable(createMsg)); + auto [deleted, deleteMsg] = console->deleteCommandLine(name); + QVERIFY2(deleted, qPrintable(deleteMsg)); + + // Deliberately no event loop turn here - the old widget is still alive. + auto [recreated, recreateMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(recreated, qPrintable(recreateMsg)); + TCommandLine* replacement = console->mSubCommandLineMap.value(name); + QVERIFY(replacement); + + runDeferredDeletes(); + + QVERIFY2(console->mSubCommandLineMap.value(name) == replacement, "the old command line's deregistration took the replacement with it"); + console->deleteCommandLine(name); + runDeferredDeletes(); + } + + // Kept last on purpose: it leaves a registered command line behind, so that + // cleanupTestCase()'s teardown destroys the console with one still in its + // widget tree. ~TMainConsole has to drop its destroyed() handler first - that + // handler runs from ~QWidget, which is after the console's own members, + // mSubCommandLineMap included, have already been destroyed. + void test_destroyingTheConsoleWithALiveCommandLineIsSafe() + { + TMainConsole* console = mpHost->mpConsole; + const QString name = qsl("outlivesTheConsole"); + + auto [created, createMsg] = console->createCommandLine(QString(), name, 0, 0, 100, 30); + QVERIFY2(created, qPrintable(createMsg)); + QVERIFY(console->mSubCommandLineMap.contains(name)); + } +}; + +void initializeQRCResourcesForSubCommandLineTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "SubCommandLineLifetimeTest.moc" +QTEST_MAIN(SubCommandLineLifetimeTest) diff --git a/test/functional_tests/TFeedTriggersRecursionTest.cpp b/test/functional_tests/TFeedTriggersRecursionTest.cpp index fd8d84c30..0b52a0fb4 100644 --- a/test/functional_tests/TFeedTriggersRecursionTest.cpp +++ b/test/functional_tests/TFeedTriggersRecursionTest.cpp @@ -76,6 +76,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("loopCount = 0\n" @@ -96,7 +97,6 @@ private slots: lua_pop(L, 1); QVERIFY2(bufferContains(qsl("trigger '%1'").arg(loopTriggerId)), "Expected the abort message to name the offending trigger by its id"); - // The trigger should have fired exactly up to the limit and no further. host->getLuaInterpreter()->compileAndExecuteScript(qsl("echo('LOOPCOUNT='..loopCount..'\\n')")); QVERIFY2(bufferContains(qsl("LOOPCOUNT=%1").arg(TriggerUnit::scmMaxProcessingDepth)), qPrintable(qsl("Expected the trigger to fire exactly %1 times").arg(TriggerUnit::scmMaxProcessingDepth))); } @@ -107,6 +107,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("normalCount = 0\n" @@ -126,6 +127,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; // feedTelnet() refuses to work unless the profile is offline @@ -164,6 +166,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->mTelnet.disconnectIt(); diff --git a/test/functional_tests/TMediaLoopTest.cpp b/test/functional_tests/TMediaLoopTest.cpp new file mode 100644 index 000000000..47942270c --- /dev/null +++ b/test/functional_tests/TMediaLoopTest.cpp @@ -0,0 +1,880 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QAudioOutput> +#include <QDeadlineTimer> +#include <QMediaPlayer> +#include <QTemporaryDir> +#include <QtTest/QtTest> + +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TMedia.h" +#include "TMediaData.h" +#include "TelnetServerStub.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" +#include "utils.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForMediaLoop(); + +using namespace std::chrono_literals; + +// A skip is how these tests stay honest on a backend that cannot stage what they need. It is +// also how the whole file could go green everywhere and mean nothing, if a CI image lost its +// codecs or swapped its default backend - macOS already skips most of them by design, so a +// second platform quietly joining it would look no different. Runners known to carry a backend +// that can demonstrate everything here set MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK, which turns +// every capability skip into a failure and makes that loss a red build instead of silence. +#define SKIP_OR_FAIL_WITHOUT(reason) \ + do { \ + const QString incapable = (reason); \ + if (!incapable.isEmpty()) { \ + if (qEnvironmentVariableIsSet("MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK")) { \ + QFAIL(qPrintable(qsl("MUDLET_MEDIA_TESTS_REQUIRE_PLAYBACK is set for this runner, so a backend that cannot do this is a failure and " \ + "not a skip: %1") \ + .arg(incapable))); \ + } \ + QSKIP(qPrintable(incapable)); \ + } \ + } while (false) + +/* + * Regression guard for "Client.Media loops=-1 plays once" (issue #9566): the deferred media + * source release in TMedia, and the generation counters documented on TMediaPlayer that decide + * whether it still applies by the time its turn comes. The obligations that follow: + * + * - a looping track must survive the stop/restart cycle (the #9566 bug itself); + * - a finite loops=N track must reach every pass, which goes through the playlist + * branch of the same handler; + * - a track that genuinely finishes, or is stopped outright, must still release + * its source, or the resource release #9237 added is lost; + * - a source the backend cannot decode must release itself off the error signal, since + * the player was already stopped and no playback state change follows; + * - a player re-sourced during the deferred turn, by a different track or by a + * continue=false restart of the same one, must keep the source it was given; + * - each of those endings must raise sysMediaFinished exactly once, since releasing the + * source alone leaves a script chaining its next track off that event waiting forever, + * and announcing twice re-enters any handler that stops the media it was told about. + * + * Which of those a backend can demonstrate varies, so probeBackend() measures one up front and + * each test skips with what it found. CMakeLists.txt pins QT_MEDIA_BACKEND on the platforms + * where main.cpp does, and leaves it to Qt elsewhere, exactly as the shipped application does - + * so a skip reflects what users actually get. + */ +class TMediaLoopTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "Test-Media-Loop"; + const QString mPort = "4012"; + const QString mLocalhost = "localhost"; + + // Length of the generated clip. Long enough that "still playing" cannot be an + // artefact of start-up latency, short enough to loop several times quickly. + static constexpr int clipMs = 400; + + QTemporaryDir mProbeDir; + // Set when the backend never reaches PlayingState, so nothing below can even be started. + QString mCannotStartReason; + // Set when the backend starts a clip but never decodes it through to EndOfMedia. + QString mCannotPlayReason; + // Set when the backend ends a track with EndOfMedia before StoppedState. + QString mWrongOrderReason; + // Set when the backend starts playing synchronously, so a player that has just been + // re-sourced can never be mistaken for a stopped one - which is the whole race the + // claim counter exists to settle. + QString mSynchronousStartReason; + // Set when the backend does not report an undecodable file as an error. + QString mNoLoadErrorReason; + +private slots: + void initTestCase() + { + initializeQRCResourcesForMediaLoop(); + probeBackend(); + } + + void init() + { + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, mPort.toUShort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + } + + // A looping track must still be playing well after its first pass would have + // ended. Before the fix the first StoppedState cleared the source, EndOfMedia + // was never delivered and the player dropped out of the playing set for good. + void test_loopingTrackKeepsPlayingPastFirstPass() + { + SKIP_OR_FAIL_WITHOUT(mCannotPlayReason); + SKIP_OR_FAIL_WITHOUT(mWrongOrderReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + const QString fileName = writeClip(qsl("loop.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(data); + + QVERIFY2(waitForPlaying(media, fileName), "The looping track never started playing."); + + // Span several passes so a single missed restart cannot pass by luck, and land off a + // clip boundary: StoppedState and the EndOfMedia that restarts the loop are separate + // signals, and in the window between them a healthy player reads as not playing. + QTest::qWait(clipMs * 4 + clipMs / 2); + + QVERIFY2(waitForPlaying(media, fileName, 2s), "A loops=-1 track stopped after its first pass - the StoppedState cleanup suppressed EndOfMedia and the loop never restarted."); + } + + // Every pass of a finite loops=N track after the first comes from the playlist branch of + // the same EndOfMedia handler, which the indefinite-loop test never reaches. Its final + // pass is also the one place a continuation ends and the deferred cleanup must take over. + // Both hold whichever way round the backend emits EndOfMedia and StoppedState, so unlike + // the loop test this one needs no mWrongOrderReason gate. + void test_finiteLoopsReachEveryPass() + { + SKIP_OR_FAIL_WITHOUT(mCannotPlayReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + const QString fileName = writeClip(qsl("finite.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(3); + media->playMedia(data); + + QVERIFY2(waitForPlaying(media, fileName), "The finite-loop track never started playing."); + + // Into the second pass, which only happens if the playlist advanced. + QTest::qWait(clipMs + clipMs / 2); + QVERIFY2(waitForPlaying(media, fileName, 2s), "A loops=3 track stopped after its first pass - the playlist never advanced to the next entry."); + + // ...and the last pass must still hand back to the cleanup rather than loop forever. + const bool cleanedUp = QTest::qWaitFor( + [&]() { + return !playing(media, fileName) && media->playersHoldingSource() == 0; + }, + QDeadlineTimer(10s)); + + QVERIFY2(cleanedUp, "A loops=3 track never finished and released its source - the deferred cleanup did not take over from the last pass."); + } + + // Staged rather than played: only a backend that delivers EndOfMedia before StoppedState + // announces a pass from inside continuePlaying()'s setSource(), and no runner this suite has + // does. sourceChanged stands in for that announcement, being emitted from inside the same + // setSource() call. + void test_continuingToTheNextPassClearsTheEarlierAnnouncement() + { + const QString path = qsl("%1/pass.wav").arg(mProbeDir.path()); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(wavBytes()); + file.close(); + + TMediaData data{}; + // Declared ahead of the player: should a player ever emit while being destroyed again, + // the lambda below has to have somewhere live to write to. + bool announcedFromInsideSetSource = false; + TMediaPlayer player(nullptr, data); + QVERIFY(player.isInitialized()); + + connect(player.mediaPlayer(), &QMediaPlayer::sourceChanged, player.mediaPlayer(), [&](const QUrl&) { + player.noteEndAnnounced(); + announcedFromInsideSetSource = true; + }); + + player.continuePlaying(QUrl::fromLocalFile(path)); + player.mediaPlayer()->stop(); + + QVERIFY2(announcedFromInsideSetSource, "setSource() emitted nothing synchronously, so the ordering this test is about was never staged."); + QVERIFY2(!player.endAnnounced(), "The new pass started already counted as announced, so its own ending would be swallowed as a duplicate."); + } + + // Destroying a player unloads whatever it still holds, and nothing may hear that: a handler + // that does would be reading a TMediaPlayer whose members are about to go. The handlers + // TMedia installs decline anyway, each by way of a weak_ptr already expired by then, so + // what this holds to is that no future one has to. Staged rather than played, since the + // unload needs a source and not a backend that can decode it (#9740). + void test_destroyingAPlayerAnnouncesNothing() + { + const QString path = qsl("%1/teardown.wav").arg(mProbeDir.path()); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(wavBytes()); + file.close(); + + int announcementsWhileBeingDestroyed = 0; + bool beingDestroyed = false; + const auto count = [&]() { + if (beingDestroyed) { + ++announcementsWhileBeingDestroyed; + } + }; + + TMediaData data{}; + + { + TMediaPlayer player(nullptr, data); + QVERIFY(player.isInitialized()); + + connect(player.mediaPlayer(), &QMediaPlayer::sourceChanged, player.mediaPlayer(), count); + connect(player.mediaPlayer(), &QMediaPlayer::playbackStateChanged, player.mediaPlayer(), count); + + player.continuePlaying(QUrl::fromLocalFile(path)); + QVERIFY2(!player.mediaPlayer()->source().isEmpty(), "The player holds no source, so its destructor has nothing to unload and this test proves nothing."); + + beingDestroyed = true; + } + + QVERIFY2(announcementsWhileBeingDestroyed == 0, "A player announced its own teardown, so every handler connected to it ran against a player being deleted."); + + // The same unload on a player that is not being destroyed, to keep the assertion above + // from passing on a Qt that has stopped announcing unloads at all. + int announcementsFromAnUnblockedUnload = 0; + const auto countUnblocked = [&]() { + ++announcementsFromAnUnblockedUnload; + }; + + QMediaPlayer unblocked; + connect(&unblocked, &QMediaPlayer::sourceChanged, &unblocked, countUnblocked); + connect(&unblocked, &QMediaPlayer::playbackStateChanged, &unblocked, countUnblocked); + unblocked.setSource(QUrl::fromLocalFile(path)); + announcementsFromAnUnblockedUnload = 0; + unblocked.stop(); + unblocked.setSource(QUrl()); + + QVERIFY2(announcementsFromAnUnblockedUnload > 0, "An unload announced nothing even unblocked, so the assertion above passes without the destructor having to block anything."); + } + + // The deferred cleanup must still fire for a genuinely finished track, otherwise the + // media source release added by #9237 is lost. Releasing the source is what this asserts + // on because playingMedia() has already dropped the player by the time the cleanup runs. + void test_oneShotTrackIsCleanedUpWhenItFinishes() + { + SKIP_OR_FAIL_WITHOUT(mCannotPlayReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + watchMediaFinished(); + + const QString fileName = writeClip(qsl("oneshot.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(TMediaData::MediaLoopsDefault); + media->playMedia(data); + + QVERIFY2(waitForPlaying(media, fileName), "The one-shot track never started playing."); + + const bool cleanedUp = QTest::qWaitFor( + [&]() { + return !playing(media, fileName) && media->playersHoldingSource() == 0; + }, + QDeadlineTimer(10s)); + + QVERIFY2(cleanedUp, "A finished one-shot track never released its media source - the deferred cleanup did not run."); + + QVERIFY2(waitForMediaFinishedCount(1), "A finished one-shot track raised no single sysMediaFinished - a script chaining its next track off that event would wait forever."); + QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedNames[1] == 'oneshot.wav'")), "sysMediaFinished named the wrong file for a finished one-shot track."); + } + + // The same obligation as above, reached by an explicit stop rather than by the clip + // ending. It asks the least of the backend of any test here - only that playback starts - + // so it is the one that still runs on a runner whose backend cannot decode a clip. + // + // Deliberately the weaker waitForPlaying(): on an asynchronous backend that lands the stop + // while the track is still loading, which is a player Qt already considers stopped and so + // one that reports no state change to end its playback. Holding a source for good is + // exactly what that used to cost, so this is the case worth keeping. + void test_stoppedTrackReleasesItsSource() + { + SKIP_OR_FAIL_WITHOUT(mCannotStartReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + watchMediaFinished(); + + const QString fileName = writeClip(qsl("stopped.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(data); + + QVERIFY2(waitForPlaying(media, fileName), "The track never started playing."); + + TMediaData stop = clipData(fileName); + media->stopMedia(stop); + + // Asserted separately from the release below: "still playing" and "still holding a + // source" are different faults with different causes, and a combined wait cannot say + // which of them a failure is. + const bool stopped = QTest::qWaitFor( + [&]() { + return !playing(media, fileName); + }, + QDeadlineTimer(10s)); + + QVERIFY2(stopped, "A stopped track was still reported as playing - stopMedia() did not take it out of the playing set."); + + const bool released = QTest::qWaitFor( + [&]() { + return media->playersHoldingSource() == 0; + }, + QDeadlineTimer(10s)); + + QVERIFY2(released, "A stopped track never released its media source - the deferred release did not run."); + + QVERIFY2(waitForMediaFinishedCount(1), "A stopped track raised no single sysMediaFinished - the silent stop this test exists for is only half fixed if the release happens without it."); + QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedNames[1] == 'stopped.wav'")), "sysMediaFinished named the wrong file for a stopped track."); + + // Nothing more may be said about it afterwards. The source outlives the event by a + // turn, so the stop, the error handler and a StoppedState report can each still find a + // playback that looks live and announce the same ending over again. + QTest::qWait(clipMs); + QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedCount == 1")), "A stopped track raised sysMediaFinished more than once for the same playback."); + } + + // Two ways a stop can say something it should not. A bare stopMusic() matches every player + // there is, including pooled ones between tracks that have nothing playing to end; and a + // stop issued from inside a sysMediaFinished handler - the natural place for a script to + // decide it has heard enough - lands on a player still holding the source of the track it + // was just told about, which used to look exactly like one more playback to end. That + // announced again, re-entered the same handler, and recursed until the stack gave out. + void test_stopDoesNotAnnounceWhatIsNotPlaying() + { + SKIP_OR_FAIL_WITHOUT(mCannotStartReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + watchMediaFinished(qsl("stopMusic()")); + + const QString fileName = writeClip(qsl("recursion.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(data); + + QVERIFY2(waitForPlaying(media, fileName), "The track never started playing."); + + TMediaData stop = clipData(fileName); + media->stopMedia(stop); + + QVERIFY2(waitForMediaFinishedCount(1), "A stopped track raised no single sysMediaFinished, so the handler that stops it again never ran and the recursion this test guards was never staged."); + + // Everything is idle by now, so a stop matching every player has nothing left to end. + TMediaData stopEverything; + stopEverything.setMediaProtocol(TMediaData::MediaProtocolAPI); + media->stopMedia(stopEverything); + + QTest::qWait(clipMs); + QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedCount == 1")), + "A stop announced a playback that was already over - either the handler's own stopMusic() recursed back through it, or pooled players holding nothing were ended too."); + } + + // A source that fails to load reports an error and no playback state change, because a + // player that was already stopped - as every claimSource() on a new or finished player + // leaves it, and as a loop restart or playlist advance finds it - has nothing to change + // from. Without the error being acted on, the track falls silent still holding a source + // nothing will ever release. + void test_unplayableTrackReleasesItsSource() + { + SKIP_OR_FAIL_WITHOUT(mCannotPlayReason); + SKIP_OR_FAIL_WITHOUT(mNoLoadErrorReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + watchMediaFinished(); + + const QString fileName = writeUnplayableClip(qsl("broken.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(data); + + // Without this the wait below is satisfied at once by a play() that bailed out early, + // and the error path this test exists for is never reached. claimSource() sets the + // source inside playMedia() and the release is deferred, so the count is settled here. + QCOMPARE(media->playersHoldingSource(), 1); + + const bool released = QTest::qWaitFor( + [&]() { + return media->playersHoldingSource() == 0; + }, + QDeadlineTimer(10s)); + + QVERIFY2(released, "A track that could not be decoded held on to its media source - the playback error was never acted on."); + + QVERIFY2(waitForMediaFinishedCount(1), "A track that could not be decoded raised no single sysMediaFinished - a script chaining its next track off that event would wait forever."); + QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedNames[1] == 'broken.wav'")), "sysMediaFinished named the wrong file for a track that could not be decoded."); + + // The error and the StoppedState that can follow it are two reports of one failure. + QTest::qWait(clipMs); + QVERIFY2(mediaFinishedHolds(qsl("mediaFinishedCount == 1")), "A track that could not be decoded raised sysMediaFinished more than once for the same failure."); + } + + // A player that is handed to a different track in the same event-loop turn, as + // stopMusic{} followed by playMusic{} in one script does, must keep the new source. The + // pending cleanup belongs to the track that stopped, and on an asynchronously starting + // backend (see mSynchronousStartReason) the player still reads as stopped while it loads. + void test_reusedPlayerKeepsTheTrackThatClaimedIt() + { + SKIP_OR_FAIL_WITHOUT(mCannotStartReason); + SKIP_OR_FAIL_WITHOUT(mSynchronousStartReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + const QString firstFile = writeClip(qsl("first.wav")); + const QString secondFile = writeClip(qsl("second.wav")); + QVERIFY(!firstFile.isEmpty() && !secondFile.isEmpty()); + + TMediaData first = clipData(firstFile); + first.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(first); + + QVERIFY2(waitForPlaybackStarted(media, firstFile), "The first track never started playing."); + + const int playerCount = media->mediaPlayerCount(); + + TMediaData stopFirst = clipData(firstFile); + media->stopMedia(stopFirst); + + TMediaData second = clipData(secondFile); + second.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(second); + + QVERIFY2(waitForPlaying(media, secondFile), "The replacement track never started playing."); + + // Without this the test passes vacuously on a second player, having never exercised + // the claim the deferred cleanup has to notice. + QCOMPARE(media->mediaPlayerCount(), playerCount); + + // Past the turn the stopped track's cleanup was scheduled for. + QTest::qWait(clipMs); + + QVERIFY2(waitForPlaying(media, secondFile, 2s), "The replacement track was cut off - the previous track's deferred cleanup cleared the source out from under it."); + } + + // continue=false restarts a track by stopping it and re-sourcing the same player inside + // one call. That player is matched rather than newly acquired, so the restart has to + // register the claim itself or the stop it just performed clears the source it just set. + void test_restartedTrackKeepsItsNewSource() + { + SKIP_OR_FAIL_WITHOUT(mCannotStartReason); + SKIP_OR_FAIL_WITHOUT(mSynchronousStartReason); + + auto* media = startProfileAndGetMedia(); + QVERIFY(media); + + const QString fileName = writeClip(qsl("restart.wav")); + QVERIFY(!fileName.isEmpty()); + + TMediaData data = clipData(fileName); + data.setMediaLoops(TMediaData::MediaLoopsRepeat); + media->playMedia(data); + + QVERIFY2(waitForPlaybackStarted(media, fileName), "The track never started playing."); + + TMediaData restart = clipData(fileName); + restart.setMediaLoops(TMediaData::MediaLoopsRepeat); + restart.setMediaContinue(TMediaData::MediaContinueRestart); + media->playMedia(restart); + + // Past the turn the stop inside that restart scheduled its cleanup for. + QTest::qWait(clipMs); + + QVERIFY2(waitForPlaying(media, fileName, 2s), "A restarted track was cut off - the cleanup deferred by its own stop cleared the source it had just been given."); + } + + void cleanup() + { + delete mpServer; + mpServer = nullptr; + mpHost = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + +private: + TMedia* startProfileAndGetMedia() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QTest::qFail("No active host available for the test.", __FILE__, __LINE__); + return nullptr; + } + auto* media = host->mpMedia.data(); + if (!media) { + QTest::qFail("Host has no TMedia instance.", __FILE__, __LINE__); + return nullptr; + } + mpHost = host; + return media; + } + + // sysMediaFinished is half of what the fixes here are for - a track that fails to load and + // one stopped while it is still loading each used to end in silence, with a script chaining + // its next track off that event waiting forever. Releasing the source, which is all the + // tests otherwise assert on, happens either way, so nothing here would notice the event + // going missing. Counted rather than merely seen: announcing the same ended playback more + // than once is its own bug, and one of them recursed until the stack gave out. + void watchMediaFinished(const QString& extraHandlerBody = QString()) + { + if (!mpHost) { + return; + } + + mpHost->getLuaInterpreter()->compileAndExecuteScript(qsl("mediaFinishedCount = 0\n" + "mediaFinishedNames = {}\n" + "registerAnonymousEventHandler('sysMediaFinished', function(_, fileName)\n" + " mediaFinishedCount = mediaFinishedCount + 1\n" + " mediaFinishedNames[#mediaFinishedNames + 1] = fileName\n" + " %1\n" + "end)\n") + .arg(extraHandlerBody)); + } + + // Runs a Lua assertion against what watchMediaFinished() recorded; compileAndExecuteScript() + // reports a raised error as false, so a failed assert() comes back here as one. + bool mediaFinishedHolds(const QString& luaCondition) const { return mpHost && mpHost->getLuaInterpreter()->compileAndExecuteScript(qsl("assert(%1)").arg(luaCondition)); } + + bool waitForMediaFinishedCount(int count, std::chrono::milliseconds timeout = 10s) const + { + return QTest::qWaitFor( + [&]() { + return mediaFinishedHolds(qsl("mediaFinishedCount == %1").arg(count)); + }, + QDeadlineTimer(timeout)); + } + + // Records what this backend is and is not able to demonstrate; each reason string set below + // spells out what that costs the tests reading it. Probed once, because it has to wait out a + // whole clip and the suite gives each test executable one wall-clock budget for all of its + // slots. test_stoppedTrackReleasesItsSource needs no capability and always runs. + void probeBackend() + { + if (!mProbeDir.isValid()) { + // Not a backend capability, so not a skip: the harness cannot do its own setup. + QFAIL("Could not create a temporary directory for the backend probe."); + } + + const QString path = qsl("%1/probe.wav").arg(mProbeDir.path()); + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + QFAIL("Could not write the backend probe clip."); + } + file.write(wavBytes()); + file.close(); + + QMediaPlayer probe; + auto* output = new QAudioOutput(&probe); + output->setMuted(true); + probe.setAudioOutput(output); + + bool sawEndOfMedia = false; + bool stoppedCameFirst = false; + bool sawPlaying = false; + connect(&probe, &QMediaPlayer::mediaStatusChanged, this, [&](QMediaPlayer::MediaStatus status) { + if (status == QMediaPlayer::EndOfMedia) { + sawEndOfMedia = true; + } + }); + connect(&probe, &QMediaPlayer::playbackStateChanged, this, [&](QMediaPlayer::PlaybackState state) { + if (state == QMediaPlayer::PlayingState) { + sawPlaying = true; + } + if (state == QMediaPlayer::StoppedState && !sawEndOfMedia) { + stoppedCameFirst = true; + } + }); + + probe.setSource(QUrl::fromLocalFile(path)); + probe.play(); + // Whether a player that has just been handed a source still reads as stopped is + // the whole reason the deferred cleanup needs to check who owns the player. + const bool startsSynchronously = probe.playbackState() == QMediaPlayer::PlayingState; + + const bool finished = QTest::qWaitFor( + [&]() { + return sawEndOfMedia; + }, + QDeadlineTimer(10s)); + probe.stop(); + + // Both recorded before the decode verdict below, because the tests that need them do + // not need the backend to finish a clip - an early return here would leave them + // believing this backend starts playback and loads asynchronously when it does neither. + if (startsSynchronously) { + mSynchronousStartReason = qsl("This Qt Multimedia backend reaches PlayingState synchronously, so a player that has just been claimed by another track never reads as stopped and cannot " + "have its source cleared out from under it. Needs a backend that loads asynchronously, such as Qt's FFmpeg one."); + } + + if (!sawPlaying && !startsSynchronously) { + // Without this every test that only stops a track - the ones that need nothing else + // of the backend - would fail its opening "never started playing" assertion rather + // than skip, which is a red build on any runner without a usable backend. + mCannotStartReason = qsl("This Qt Multimedia backend never reached PlayingState within 10s, so no playback can be started to act on. Backend: \"%1\", final media status: %2, error: " + "\"%3\".") + .arg(QString::fromLocal8Bit(qgetenv("QT_MEDIA_BACKEND")), QString::number(static_cast<int>(probe.mediaStatus())), probe.errorString()); + } + + if (!finished) { + // Report what was measured rather than a cause that was not diagnosed - no audio + // device, a missing codec and a stalled decoder all land here. + mCannotPlayReason = qsl("This Qt Multimedia backend did not reach EndOfMedia within 10s, so anything that waits for a clip to finish cannot be observed. Backend: \"%1\", reached " + "PlayingState: %2, final media status: %3, error: \"%4\".") + .arg(QString::fromLocal8Bit(qgetenv("QT_MEDIA_BACKEND")), + startsSynchronously ? qsl("yes") : qsl("no"), + QString::number(static_cast<int>(probe.mediaStatus())), + probe.errorString()); + return; + } + + if (!stoppedCameFirst) { + mWrongOrderReason = qsl("This Qt Multimedia backend emits EndOfMedia before StoppedState, so the loop restarts before any cleanup runs and issue #9566 cannot occur here. Needs a " + "StoppedState-first backend such as Qt's FFmpeg one."); + } + + // Only worth asking of a backend that got this far. Assumed, not measured: one that + // cannot finish a valid clip is taken not to reject an invalid one either. + probeLoadFailureReporting(); + } + + // Whether an undecodable file is reported as an error at all. A backend that stays silent + // gives TMedia nothing to act on, so the release it cannot schedule cannot be asserted. + void probeLoadFailureReporting() + { + const QString path = qsl("%1/unplayable.wav").arg(mProbeDir.path()); + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + QFAIL("Could not write the unplayable probe clip."); + } + file.write(QByteArray("not a WAV file, and not decodable as anything else")); + file.close(); + + QMediaPlayer probe; + auto* output = new QAudioOutput(&probe); + output->setMuted(true); + probe.setAudioOutput(output); + + bool sawError = false; + connect(&probe, &QMediaPlayer::errorOccurred, this, [&](QMediaPlayer::Error error, const QString&) { + if (error != QMediaPlayer::NoError) { + sawError = true; + } + }); + + probe.setSource(QUrl::fromLocalFile(path)); + probe.play(); + + const bool reported = QTest::qWaitFor( + [&]() { + return sawError; + }, + QDeadlineTimer(10s)); + probe.stop(); + + if (!reported) { + mNoLoadErrorReason = qsl("This Qt Multimedia backend does not report an error for an undecodable file within 10s, so there is no failure for TMedia to act on. Backend: \"%1\", final " + "media status: %2.") + .arg(QString::fromLocal8Bit(qgetenv("QT_MEDIA_BACKEND")), QString::number(static_cast<int>(probe.mediaStatus()))); + } + } + + TMediaData clipData(const QString& fileName) const + { + TMediaData data; + data.setMediaProtocol(TMediaData::MediaProtocolAPI); + data.setMediaType(TMediaData::MediaTypeMusic); + data.setMediaInput(TMediaData::MediaInputFile); + data.setMediaFileName(fileName); + data.setMediaVolume(1); // audible enough to play, quiet enough not to disturb a desktop run + return data; + } + + // TMedia reports a player only while it is actually playing, or still loading - the + // carve-out that lets a stalled backend look busy, and the observable this turns on. + bool playing(TMedia* media, const QString& fileName) const + { + TMediaData criteria = clipData(fileName); + return !media->playingMedia(criteria).isEmpty(); + } + + bool waitForPlaying(TMedia* media, const QString& fileName, std::chrono::milliseconds timeout = 10s) + { + return QTest::qWaitFor( + [&]() { + return playing(media, fileName); + }, + QDeadlineTimer(timeout)); + } + + // Stronger than waitForPlaying(): a player that is still loading counts as playing to + // TMedia, and it is not yet stoppable in the way a started one is - stopping it produces no + // playback state change, and getMediaPlayer() will not hand it to another track. + bool waitForPlaybackStarted(TMedia* media, const QString& fileName, std::chrono::milliseconds timeout = 10s) + { + return QTest::qWaitFor( + [&]() { + return playing(media, fileName) && media->playersInPlayingState() > 0; + }, + QDeadlineTimer(timeout)); + } + + // Passes the file-name checks in TMedia::play(), so it reaches the backend and fails + // there, so the error path is reached the way a real undecodable file would reach it. + QString writeUnplayableClip(const QString& fileName) const { return writeClip(fileName, QByteArray("not a WAV file, and not decodable as anything else")); } + + // Writes a clip into the profile media directory, returning {} if that fails. + QString writeClip(const QString& fileName, const QByteArray& contents = wavBytes()) const + { + const QString mediaPath = mudlet::getMudletPath(enums::profileMediaPath, mHostname); + if (!QDir().mkpath(mediaPath)) { + QTest::qFail("Could not create the profile media directory.", __FILE__, __LINE__); + return {}; + } + + QFile file(qsl("%1/%2").arg(mediaPath, fileName)); + if (!file.open(QIODevice::WriteOnly)) { + QTest::qFail("Could not write the test media file.", __FILE__, __LINE__); + return {}; + } + file.write(contents); + file.close(); + return fileName; + } + + // A silent 16-bit mono PCM WAV. Silence is fine: the tests assert on playback state + // transitions, not on what is heard. + static QByteArray wavBytes() + { + constexpr int sampleRate = 8000; + constexpr int bytesPerSample = 2; + const int dataBytes = sampleRate * bytesPerSample * clipMs / 1000; + + QByteArray wav; + QDataStream out(&wav, QIODevice::WriteOnly); + out.setByteOrder(QDataStream::LittleEndian); + + out.writeRawData("RIFF", 4); + out << static_cast<quint32>(36 + dataBytes); + out.writeRawData("WAVE", 4); + out.writeRawData("fmt ", 4); + out << static_cast<quint32>(16); // PCM header size + out << static_cast<quint16>(1); // PCM, uncompressed + out << static_cast<quint16>(1); // mono + out << static_cast<quint32>(sampleRate); + out << static_cast<quint32>(sampleRate * bytesPerSample); // byte rate + out << static_cast<quint16>(bytesPerSample); // block align + out << static_cast<quint16>(8 * bytesPerSample); // bits per sample + out.writeRawData("data", 4); + out << static_cast<quint32>(dataBytes); + wav.append(QByteArray(dataBytes, '\0')); + + return wav; + } + + // Utility function to manually start a profile like a user would do via the + // GUI + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5s)) { + QFAIL("Profile took too long to load."); + } + } + + // Utility function + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + + if (!dir.exists()) { + qInfo() << "Profile directory does not exist:" << path; + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResourcesForMediaLoop() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "TMediaLoopTest.moc" +QTEST_MAIN(TMediaLoopTest) diff --git a/test/functional_tests/TelnetSgrDefaultColorTest.cpp b/test/functional_tests/TelnetSgrDefaultColorTest.cpp index 293d66144..2df195ee0 100644 --- a/test/functional_tests/TelnetSgrDefaultColorTest.cpp +++ b/test/functional_tests/TelnetSgrDefaultColorTest.cpp @@ -182,7 +182,6 @@ private slots: QCOMPARE(brightChar->foreground(), mpHost->mLightRed); } - // Regression guard: bold of an explicit color still brightens it. void boldColorStillBrightens() { injectData(QByteArrayLiteral("\x1b[31;1mbright")); diff --git a/test/functional_tests/TelnetSubnegotiationTest.cpp b/test/functional_tests/TelnetSubnegotiationTest.cpp index 40abf5cb6..479952a23 100644 --- a/test/functional_tests/TelnetSubnegotiationTest.cpp +++ b/test/functional_tests/TelnetSubnegotiationTest.cpp @@ -88,10 +88,6 @@ private slots: data.append(TN_IAC); data.append(TN_SE); data.append("SUBNEG_RECOVERED\r\n"); - // processSocketData() writes a NUL at in_buffer[size + 1], so give the - // backing buffer a little slack before handing it its data pointer. - data.reserve(data.size() + 16); - host->mTelnet.loopbackTest(data); QVERIFY2(waitForBufferToContain("SUBNEG_RECOVERED"), "Ordinary text after an oversized subnegotiation was not displayed - recovery failed."); diff --git a/test/functional_tests/TelnetTlsPromptTest.cpp b/test/functional_tests/TelnetTlsPromptTest.cpp index 663947287..10640ebd3 100644 --- a/test/functional_tests/TelnetTlsPromptTest.cpp +++ b/test/functional_tests/TelnetTlsPromptTest.cpp @@ -29,7 +29,11 @@ #include "mudlet.h" #include "utils.h" +#include <QHostAddress> +#include <QNetworkReply> +#include <QPointer> #include <QProgressDialog> +#include <QTcpServer> #include <QRegularExpression> extern void qInitResources_mudlet(); @@ -108,10 +112,6 @@ private slots: data.append(tlsPort); data.append(TN_IAC); data.append(TN_SE); - // processSocketData() writes a NUL at in_buffer[size + 1], so give the - // backing buffer a little slack before handing it its data pointer. - data.reserve(data.size() + 16); - host->mTelnet.loopbackTest(data); // The signal is emitted synchronously inside loopbackTest(), so it has @@ -156,7 +156,6 @@ private slots: // Advertise a secure port so mMSSPTlsPort is populated (the handler is // detached, so nothing pops up). QByteArray advertise = msspTlsPayload("48000"); - advertise.reserve(advertise.size() + 16); host->mTelnet.loopbackTest(advertise); // The user answers No; the reconnect that follows must complete. @@ -172,7 +171,6 @@ private slots: // the user has declined (don't-ask-again is sticky for the session). QSignalSpy promptSpy(&host->mTelnet, &cTelnet::signal_promptTlsAvailable); QByteArray advertiseAgain = msspTlsPayload("48000"); - advertiseAgain.reserve(advertiseAgain.size() + 16); host->mTelnet.loopbackTest(advertiseAgain); QCOMPARE(promptSpy.count(), 0); #endif @@ -200,7 +198,6 @@ private slots: // Advertise the second stub's port as the secure port. QByteArray advertise = msspTlsPayload(QByteArray::number(securePort)); - advertise.reserve(advertise.size() + 16); host->mTelnet.loopbackTest(advertise); QCOMPARE(host->mMSSPTlsPort, static_cast<int>(securePort)); @@ -239,7 +236,6 @@ private slots: QVERIFY(spy.isValid()); QByteArray advertise = msspTlsPayload("48000"); - advertise.reserve(advertise.size() + 16); host->mTelnet.loopbackTest(advertise); if (spy.isEmpty()) { QVERIFY2(spy.wait(2s), "cTelnet did not emit signal_promptTlsAvailable for the first advertisement."); @@ -249,7 +245,6 @@ private slots: // Nobody has answered, so the prompt is still in flight: a repeated // advertisement (as a hostile server could spam) must be swallowed. QByteArray advertiseAgain = msspTlsPayload("48000"); - advertiseAgain.reserve(advertiseAgain.size() + 16); host->mTelnet.loopbackTest(advertiseAgain); QCOMPARE(spy.count(), 1); #endif @@ -273,7 +268,6 @@ private slots: // Advertise so the port is recorded and the latch is set, mirroring a // real pending prompt. QByteArray advertise = msspTlsPayload("48000"); - advertise.reserve(advertise.size() + 16); host->mTelnet.loopbackTest(advertise); const int originalPort = host->getPort(); @@ -310,12 +304,10 @@ private slots: QSignalSpy bellSpy(&host->mTelnet, &cTelnet::signal_bell); QByteArray oneBell("\a"); - oneBell.reserve(oneBell.size() + 16); host->mTelnet.loopbackTest(oneBell); QCOMPARE(bellSpy.count(), 1); QByteArray twoBells("\a\a"); - twoBells.reserve(twoBells.size() + 16); host->mTelnet.loopbackTest(twoBells); QCOMPARE(bellSpy.count(), 3); } @@ -343,6 +335,51 @@ private slots: QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1); } + // When a second server-initiated GUI download supersedes one still in + // flight (a reconnect re-sends Client.GUI), swapping the progress dialog + // must not cancel the freshly started download. The superseded dialog's + // close() emits canceled(), which used to abort the just-assigned new reply. + // The test above missed this because it swapped dialogs with no reply live. + void test_replacingDownloadDialogKeepsNewDownloadAlive() + { + startProfile(mHostname, mLocalhost, mPort); + auto host = mudlet::self()->getActiveHost(); + QVERIFY2(host, "No active host available for the test."); + QVERIFY2(host->mpConsole, "The active host has no main console."); + auto console = host->mpConsole; + + // A TCP server that accepts connections but never answers keeps the + // package-download reply in flight (Running, NoError) for the whole + // test, so an unwanted abort() is the only thing that can finish it. + QTcpServer hangingServer; + QVERIFY2(hangingServer.listen(QHostAddress::LocalHost, 0), "Could not start the stand-in download server."); + const QString url = qsl("http://localhost:%1/game-ui.mpackage").arg(hangingServer.serverPort()); + + // First server-initiated download: starts reply #1 and progress dialog #1. + host->mTelnet.downloadAndInstallGUIPackage(qsl("game-ui"), qsl("game-ui.mpackage"), url); + QVERIFY2(host->mTelnet.mpPackageDownloadReply, "The first GUI download did not start a network reply."); + QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1); + + // Second download supersedes the first; reply #2 must take over and stay + // live rather than being cancelled the instant its dialog replaces #1. + host->mTelnet.downloadAndInstallGUIPackage(qsl("game-ui"), qsl("game-ui.mpackage"), url); + + QPointer<QNetworkReply> newReply = host->mTelnet.mpPackageDownloadReply; + QVERIFY2(newReply, "The superseding GUI download left no active network reply."); + QVERIFY2(!newReply->isFinished(), "The superseding GUI download was cancelled at birth by the dialog swap."); + QCOMPARE(newReply->error(), QNetworkReply::NoError); + + // Let dialog #1's WA_DeleteOnClose deleteLater() run: exactly one dialog + // survives the swap, and the new download is still alive. + QTest::qWait(50ms); + QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1); + QVERIFY2(newReply && newReply->error() == QNetworkReply::NoError, "The superseding GUI download did not survive the dialog swap."); + + // The user's Cancel must still abort the live download. + host->mTelnet.slot_cancelPackageDownload(); + QTest::qWait(50ms); + } + // Builds an MSSP subnegotiation advertising a secure TLS port: // IAC SB MSSP MSSP_VAR "TLS" MSSP_VAL <port> IAC SE QByteArray msspTlsPayload(const QByteArray& port) diff --git a/test/functional_tests/TriggerSameLineMatchTest.cpp b/test/functional_tests/TriggerSameLineMatchTest.cpp index aecfcfd8d..b141a7f54 100644 --- a/test/functional_tests/TriggerSameLineMatchTest.cpp +++ b/test/functional_tests/TriggerSameLineMatchTest.cpp @@ -76,6 +76,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("captured = {}\n" @@ -98,6 +99,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("order = {}\n" @@ -119,6 +121,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("chain = {}\n" @@ -142,6 +145,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("expiryLine = ''\n" @@ -162,6 +166,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("lineGrabs = {}\n" @@ -184,6 +189,7 @@ private slots: { startProfile(mpHostname, mpLocalhost, mpPort); auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); host->mEchoLuaErrors = true; host->getLuaInterpreter()->compileAndExecuteScript(qsl("nested = {}\n" @@ -198,6 +204,491 @@ private slots: QVERIFY2(bufferContains(qsl("NESTED=inner,outer#")), "Expected the mid-pass trigger to match the nested line first, then the outer line it was created on"); } + // The naive "one-shot that re-arms itself at the end of its own handler" is + // the shape users write. Without the budget this does not fail, it hangs. + void test_selfRecreatingTriggerIsStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("loopFires = 0\n" + "function arm()\n" + " tempRegexTrigger('^hploop$', [[loopFires = loopFires + 1; arm()]], 1)\n" + "end\n" + "arm()\n" + "feedTriggers('hploop\\n')\n" + "echo('LOOPFIRES=' .. loopFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected the same-line re-creation abort error in the console buffer"); + // one fire from the trigger already there, then one per budgeted creation; + // the trailing # keeps the check from also passing on ten times the number + const int expectedFires = 1 + TriggerUnit::scmMaxSameLineGenerations; + QVERIFY2(bufferContains(qsl("LOOPFIRES=%1#").arg(expectedFires)), qPrintable(qsl("Expected the re-arming trigger to fire exactly %1 times").arg(expectedFires))); + } + + void test_selfRecreatingTriggerAbortNamesTheTrigger() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("function armNamed()\n" + " tempComplexRegexTrigger('hpWatcher', '^hpnamed$', [[armNamed()]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)\n" + "end\n" + "armNamed()\n" + "feedTriggers('hpnamed\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("trigger 'hpWatcher'")), "Expected the abort message to name the trigger that keeps re-creating itself"); + } + + void test_finiteCreationChainIsUnaffected() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("chainFires = 0\n" + "function chainStep()\n" + " chainFires = chainFires + 1\n" + " if chainFires < 10 then\n" + " tempRegexTrigger('^chain$', [[chainStep()]], 1)\n" + " end\n" + "end\n" + "tempRegexTrigger('^chain$', [[chainStep()]], 1)\n" + "feedTriggers('chain\\n')\n" + "echo('CHAINFIRES=' .. chainFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("CHAINFIRES=10#")), "Expected all ten generations of the finite chain to match the current line"); + QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A chain that ends on its own must not trip the same-line generation budget"); + } + + // Without disowning what the loop created, each line costs a multiple of the + // one before it, so the freeze is postponed rather than prevented. + void test_selfRecreatingTriggerDoesNotAccumulateAcrossLines() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("keptFires = 0\n" + "function armKept()\n" + " tempRegexTrigger('^kept$', [[keptFires = keptFires + 1; armKept()]])\n" + "end\n" + "armKept()\n" + "feedTriggers('kept\\n')\n" + "feedTriggers('kept\\n')\n" + "echo('KEPTFIRES=' .. keptFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + const int firesPerLine = 1 + TriggerUnit::scmMaxSameLineGenerations; + QVERIFY2(bufferContains(qsl("KEPTFIRES=%1#").arg(2 * firesPerLine)), + qPrintable(qsl("Expected the second line to cost the same %1 fires as the first, not a multiple of them").arg(firesPerLine))); + } + + // Permanent triggers are saved with the profile, so they are stopped without + // being deleted and with deactivate(), which leaves the user-active state + // XMLexport writes alone. + void test_selfRecreatingPermanentTriggerIsStoppedButNotDeleted() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("permFires = 0\n" + "function armPerm()\n" + " permRegexTrigger('Perm Loop', '', {'^permloop$'}, [[permFires = permFires + 1; armPerm()]])\n" + "end\n" + "armPerm()\n" + "feedTriggers('permloop\\n')\n" + "echo('PERMFIRES=' .. permFires .. '#\\n')\n" + "echo('PERMACTIVE=' .. isActive('Perm Loop', 'trigger') .. '#\\n')\n" + "echo('PERMEXISTS=' .. exists('Perm Loop', 'trigger') .. '#\\n')\n")); + + const int expectedFires = 1 + TriggerUnit::scmMaxSameLineGenerations; + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a permanent trigger re-creating itself to be stopped too"); + QVERIFY2(bufferContains(qsl("PERMFIRES=%1#").arg(expectedFires)), qPrintable(qsl("Expected the re-arming permanent trigger to fire exactly %1 times").arg(expectedFires))); + QVERIFY2(bufferContains(qsl("PERMACTIVE=1#")), "Expected only the trigger that predates the line to still be active"); + QVERIFY2(bufferContains(qsl("PERMEXISTS=%1#").arg(expectedFires + 1)), "Expected the stopped permanent triggers to still exist - stopping them is not deleting them"); + } + + // A script arming a batch of unrelated triggers is not a runaway, however + // big the batch: each of them starts a creation chain of its own, and none + // of those chains ever gets a second link. + void test_bulkUnrelatedCreationsAreNotStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("bulkFires = 0\n" + "tempRegexTrigger('^bulkgate$', [=[\n" + " for i = 1, %1 do\n" + " tempRegexTrigger('^bulkpay$', [[bulkFires = bulkFires + 1]])\n" + " end\n" + "]=], 1)\n" + "feedTriggers('bulkgate\\n')\n" + "feedTriggers('bulkpay\\n')\n" + "echo('BULKFIRES=' .. bulkFires .. '#\\n')\n") + .arg(bulkCount)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A batch of unrelated triggers must not be mistaken for a trigger re-creating itself"); + QVERIFY2(bufferContains(qsl("BULKFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed on the previous line to survive and fire").arg(bulkCount))); + } + + // Two scripts arming triggers on one line get a budget each, so neither can + // exhaust the other's - together they come to more than one budget's worth. + void test_twoScriptsArmingOnOneLineKeepBothSetsOfTriggers() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int eachCount = (TriggerUnit::scmMaxSameLineGenerations / 2) + 1; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("firesA, firesB = 0, 0\n" + "tempRegexTrigger('^sharedgate$', [=[\n" + " for i = 1, %1 do\n" + " tempRegexTrigger('^payA$', [[firesA = firesA + 1]])\n" + " end\n" + "]=], 1)\n" + "tempRegexTrigger('^sharedgate$', [=[\n" + " for i = 1, %1 do\n" + " tempRegexTrigger('^payB$', [[firesB = firesB + 1]])\n" + " end\n" + "]=], 1)\n" + "feedTriggers('sharedgate\\n')\n" + "feedTriggers('payA\\n')\n" + "feedTriggers('payB\\n')\n" + "echo('SHARED=' .. firesA .. ',' .. firesB .. '#\\n')\n") + .arg(eachCount)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("SHARED=%1,%1#").arg(eachCount)), qPrintable(qsl("Expected both scripts to keep all %1 of the triggers they armed").arg(eachCount))); + } + + // The batch is armed by a trigger that was itself created on this line, so + // creator and batch share a lineage. Counting a lineage's members rather than + // its generations condemns the whole batch here, which is the room-capture + // shape: the room-title trigger creates the capture trigger, and the capture + // trigger is what arms the batch. + void test_bulkCreationsFromAMidLineTriggerAreNotStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("deepFires = 0\n" + "tempRegexTrigger('^deepgate$', [===[\n" + " tempRegexTrigger('^deepgate$', [==[\n" + " for i = 1, %1 do\n" + " tempRegexTrigger('^deeppay$', [[deepFires = deepFires + 1]])\n" + " end\n" + " ]==], 1)\n" + "]===], 1)\n" + "feedTriggers('deepgate\\n')\n" + "feedTriggers('deeppay\\n')\n" + "echo('DEEPFIRES=' .. deepFires .. '#\\n')\n") + .arg(bulkCount)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A batch is one generation wherever it is armed from, and must not be mistaken for a runaway"); + QVERIFY2(bufferContains(qsl("DEEPFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed by a trigger created on the same line to survive and fire").arg(bulkCount))); + } + + // Permanent triggers take the same path, and are the more painful loss - a + // "rebuild my triggers when the game says X" routine arms them in bulk. + void test_bulkPermanentCreationsAreNotStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("permBulkFires = 0\n" + "function permBulkStep() permBulkFires = permBulkFires + 1 end\n" + "tempRegexTrigger('^permgate$', [=[\n" + " for i = 1, %1 do\n" + " permRegexTrigger('PermBulk' .. i, '', {'^permpay$'}, [[permBulkStep()]])\n" + " end\n" + "]=], 1)\n" + "feedTriggers('permgate\\n')\n" + "feedTriggers('permpay\\n')\n" + "echo('PERMBULK=' .. permBulkFires .. '#\\n')\n" + "echo('PERMBULKACTIVE=' .. isActive('PermBulk%1', 'trigger') .. '#\\n')\n") + .arg(bulkCount)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("PERMBULK=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 permanent triggers armed on the previous line to survive and fire").arg(bulkCount))); + QVERIFY2(bufferContains(qsl("PERMBULKACTIVE=1#")), "Expected the permanent triggers to be left switched on"); + } + + // The whole point of the budget being per chain: the runaway loses its + // triggers, the script that happened to arm a trigger on the same line does not. + void test_runawayChainSparesTriggersFromOtherScripts() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("innocentFires = 0\n" + "function armRunaway()\n" + " tempRegexTrigger('^runline$', [[armRunaway()]], 1)\n" + "end\n" + "armRunaway()\n" + "tempRegexTrigger('^runline$', [=[\n" + " tempRegexTrigger('^innocent$', [[innocentFires = innocentFires + 1]])\n" + "]=], 1)\n" + "feedTriggers('runline\\n')\n" + "feedTriggers('innocent\\n')\n" + "echo('INNOCENT=' .. innocentFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected the self-recreating chain to still be stopped"); + QVERIFY2(bufferContains(qsl("INNOCENT=1#")), "Expected the trigger armed by an unrelated script on the same line to survive the runaway's abort and fire"); + } + + // A lineage of exactly the budget's depth ends on its own; the trip is on the + // generation after it, which test_selfRecreatingTriggerIsStopped() pins from + // the other side. Both land on the same fire count, so the presence or + // absence of the abort message is what tells the two apart. + void test_chainExactlyAtTheLimitIsNotStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int limit = TriggerUnit::scmMaxSameLineGenerations; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("boundFires = 0\n" + "function boundStep()\n" + " boundFires = boundFires + 1\n" + " if boundFires <= %1 then\n" + " tempRegexTrigger('^boundline$', [[boundStep()]], 1)\n" + " end\n" + "end\n" + "tempRegexTrigger('^boundline$', [[boundStep()]], 1)\n" + "feedTriggers('boundline\\n')\n" + "echo('BOUNDFIRES=' .. boundFires .. '#\\n')\n") + .arg(limit)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A chain of exactly the budget's length ends on its own and must not be stopped"); + QVERIFY2(bufferContains(qsl("BOUNDFIRES=%1#").arg(limit + 1)), qPrintable(qsl("Expected the chain to run to its own end, %1 fires").arg(limit + 1))); + } + + // Once the line that created a trigger is done with, that trigger is as + // ordinary as any other and what it creates starts fresh chains - otherwise + // it would carry its creator's chain around for the rest of the session. + void test_aTriggerOutlivingItsLineStartsFreshChains() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("laterFires = 0\n" + "tempRegexTrigger('^egate$', [==[\n" + " tempRegexTrigger('^esecond$', [=[\n" + " for i = 1, %1 do\n" + " tempRegexTrigger('^epay$', [[laterFires = laterFires + 1]])\n" + " end\n" + " ]=], 1)\n" + "]==], 1)\n" + "feedTriggers('egate\\n')\n" + "feedTriggers('esecond\\n')\n" + "feedTriggers('epay\\n')\n" + "echo('LATERFIRES=' .. laterFires .. '#\\n')\n") + .arg(bulkCount)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A trigger created on an earlier line is not part of a chain any more and must arm freely"); + QVERIFY2(bufferContains(qsl("LATERFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed on the later line to survive and fire").arg(bulkCount))); + } + + // A lineage that starts in the outer pass and runs away inside a nested + // feedTriggers() has members either side of the nested pass's first-node + // index, which is why stopping one scans the whole list rather than the + // tail of the tripping pass. Scanning only the tail leaves the first link + // alive, and the outer pass then has to trip on the same lineage all over + // again - the fire count is what shows that, at twice this number. + void test_runawayCrossingIntoANestedPassIsStoppedWhole() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("nestFires, nestSafeFires = 0, 0\n" + "function armNested()\n" + " tempRegexTrigger('^nestin$', [[nestFires = nestFires + 1; armNested()]])\n" + "end\n" + "tempRegexTrigger('^nestout$', [=[\n" + " armNested()\n" + " tempRegexTrigger('^nestsafe$', [[nestSafeFires = nestSafeFires + 1]])\n" + " feedTriggers('nestin\\n')\n" + "]=], 1)\n" + "feedTriggers('nestout\\n')\n" + "echo('NESTFIRES=' .. nestFires .. '#\\n')\n" + "nestFires = 0\n" + "feedTriggers('nestin\\n')\n" + "feedTriggers('nestsafe\\n')\n" + "echo('NESTAFTER=' .. nestFires .. '#\\n')\n" + "echo('NESTSAFE=' .. nestSafeFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a runaway that crosses into a nested pass to be stopped"); + QVERIFY2(bufferContains(qsl("NESTFIRES=%1#").arg(TriggerUnit::scmMaxSameLineGenerations)), "Expected the runaway to cost one budget, not one per pass the lineage is spread across"); + QVERIFY2(bufferContains(qsl("NESTAFTER=0#")), "Expected no member of the stopped lineage to be left armed, wherever in the list it sat"); + QVERIFY2(bufferContains(qsl("NESTSAFE=1#")), "Expected a trigger armed by an unrelated script on the outer line to survive the nested pass's abort"); + } + + // Creations made inside a nested pass are appended to the same list the outer + // pass is walking, so a batch armed there has to be read as one generation + // just the same. + void test_bulkCreationsInsideANestedPassAreNotStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + const int bulkCount = TriggerUnit::scmMaxSameLineGenerations + 1; + host->getLuaInterpreter()->compileAndExecuteScript(qsl("crossFires = 0\n" + "tempRegexTrigger('^crossout$', [==[\n" + " tempRegexTrigger('^crossin$', [=[\n" + " for i = 1, %1 do\n" + " tempRegexTrigger('^crosspay$', [[crossFires = crossFires + 1]])\n" + " end\n" + " ]=], 1)\n" + " feedTriggers('crossin\\n')\n" + "]==], 1)\n" + "feedTriggers('crossout\\n')\n" + "feedTriggers('crosspay\\n')\n" + "echo('CROSSFIRES=' .. crossFires .. '#\\n')\n") + .arg(bulkCount)); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(!bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "A batch armed inside a nested pass is still one generation and must not be stopped"); + QVERIFY2(bufferContains(qsl("CROSSFIRES=%1#").arg(bulkCount)), qPrintable(qsl("Expected all %1 triggers armed inside the nested pass to survive and fire").arg(bulkCount))); + } + + // Only root triggers carry a lineage, so a trigger sitting in a folder creates + // on the folder's behalf. Read the child's own (always empty) lineage instead + // and every round would start a fresh one, which never deepens and so never + // trips - the run would only end at the per-line creation ceiling. + void test_folderChildCreatesOnItsRootsBehalf() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("folderFires, folderCount = 0, 0\n" + "function makeFolderGen()\n" + " folderCount = folderCount + 1\n" + " local name = 'FGen' .. folderCount\n" + " permGroup(name, 'trigger')\n" + " permRegexTrigger('FChild' .. folderCount, name, {'^folderloop$'}, [[folderFires = folderFires + 1; makeFolderGen()]])\n" + "end\n" + "makeFolderGen()\n" + "feedTriggers('folderloop\\n')\n" + "echo('FOLDERFIRES=' .. folderFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a runaway driven from inside a folder to be stopped"); + QVERIFY2(bufferContains(qsl("FOLDERFIRES=%1#").arg(1 + TriggerUnit::scmMaxSameLineGenerations)), + "Expected the folder's lineage to deepen by one per round, so the generation budget is what ends it"); + } + + // The same for a filter chain, where the child is reached through the parent's + // capture rather than by the root list passing data down. + void test_filterChainChildCreatesOnItsRootsBehalf() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("filterFires, filterCount = 0, 0\n" + "function makeFilterGen()\n" + " filterCount = filterCount + 1\n" + " local name = 'FiltP' .. filterCount\n" + " tempComplexRegexTrigger(name, '^(filterloop)$', '', 0, 0, 0, 1, 0, 0, 0, 0, 0, 0)\n" + " permRegexTrigger('FiltC' .. filterCount, name, {'filterloop'}, [[filterFires = filterFires + 1; makeFilterGen()]])\n" + "end\n" + "makeFilterGen()\n" + "feedTriggers('filterloop\\n')\n" + "echo('FILTERFIRES=' .. filterFires .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected a runaway driven from inside a filter chain to be stopped"); + QVERIFY2(bufferContains(qsl("FILTERFIRES=%1#").arg(1 + TriggerUnit::scmMaxSameLineGenerations)), + "Expected the filter parent's lineage to deepen by one per round, so the generation budget is what ends it"); + } + + // The outer line's own mid-pass triggers were registered before the nested + // pass began, so its abort must not take them. + void test_nestedPassAbortLeavesTheOuterLineAlone() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("seen = {}\n" + "function armInner()\n" + " tempRegexTrigger('^inner$', [[armInner()]], 1)\n" + "end\n" + "armInner()\n" + "tempRegexTrigger('^outer$', [=[\n" + " tempRegexTrigger('^(.*)$', [[table.insert(seen, matches[2])]], 10)\n" + " feedTriggers('inner\\n')\n" + "]=], 1)\n" + "feedTriggers('outer\\n')\n" + "echo('SEEN=' .. table.concat(seen, ',') .. '#\\n')\n")); + + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected the runaway in the nested pass to be stopped"); + QVERIFY2(bufferContains(qsl("SEEN=inner,outer#")), "Expected the capture trigger created by the outer line to survive the nested pass's abort and still match the outer line"); + } + + // Not a feedTriggers() curiosity - real socket text takes the same path - and + // driving it from the socket also proves the abort leaves the event loop running. + void test_selfRecreatingTriggerFromServerTextIsStopped() + { + startProfile(mpHostname, mpLocalhost, mpPort); + auto* host = mudlet::self()->getActiveHost(); + QVERIFY(host); + host->mEchoLuaErrors = true; + + host->getLuaInterpreter()->compileAndExecuteScript(qsl("function armFromServer()\n" + " tempRegexTrigger('^HP: 100/100$', [[armFromServer()]], 1)\n" + "end\n" + "armFromServer()\n")); + + mpServer->sendRaw(QByteArray("HP: 100/100\r\n")); + QTRY_VERIFY2_WITH_TIMEOUT(bufferContains(qsl("Trigger processing stopped to prevent a freeze")), "Expected server text to reach the same-line generation budget and be stopped", 10000); + QCOMPARE(host->getTriggerUnit()->processingDepth(), 0); + } + void cleanup() { delete mpServer; diff --git a/test/functional_tests/TtsInterruptingSpeakTest.cpp b/test/functional_tests/TtsInterruptingSpeakTest.cpp new file mode 100644 index 000000000..307c43d52 --- /dev/null +++ b/test/functional_tests/TtsInterruptingSpeakTest.cpp @@ -0,0 +1,243 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Regression guard for #9659: an interrupting ttsSpeak() dropping the utterance + * it was asked to speak. + * + * Every speech engine Qt wraps stops the running utterance inside say() and + * reports Ready for it - speech-dispatcher, SAPI, WinRT and AVFoundation all + * do. Mudlet raises its TTS events off those state changes and drains + * ttsQueue() on any Ready, so that one was read as "the engine is idle": the + * queued line was spoken straight over the utterance the script had just asked + * for, and that utterance was never heard at all. + * + * Qt's mock engine, which the Lua specs in Media_spec.lua use, never reports + * that Ready - it stays in Speaking with no state change at all, which is the + * other half of the same issue and is covered there. So the guard itself needs + * that Ready delivered the way a real engine delivers it, which is what this + * test does: it drives the Lua API for everything else and hands + * TLuaInterpreter::ttsStateChanged() the state change the engine's + * QTextToSpeech::stateChanged signal would have carried. + * + * Run with: ctest -R TtsInterruptingSpeakTest -V + */ + +#include <QtTest/QtTest> + +#include <QTemporaryDir> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TLuaInterpreter.h" +#include "TScript.h" +#include "ScriptUnit.h" +#include "mudlet.h" + +#ifdef QT_TEXTTOSPEECH_LIB +#include <QTextToSpeech> +#endif + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForTtsInterruptingSpeakTest(); + +class TtsInterruptingSpeakTest : public QObject +{ + Q_OBJECT + +private: + Host* mpHost = nullptr; + const QString mProfileName = qsl("TtsInterruptingSpeak-Test"); + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + +#ifdef QT_TEXTTOSPEECH_LIB + // Runs a snippet in the profile's Lua state, failing the test with the + // script's own error message when it does not run cleanly. Lua assert()s in + // the snippet are how the queue and the current line are read back. + bool runLua(const QString& script) { return mpHost->getLuaInterpreter()->compileAndExecuteScript(script); } + + // Counts ttsSpeechStarted from here on in the Lua global ttsStartedCount. A + // TScript rather than registerAnonymousEventHandler(): this console-less + // Host never loads LuaGlobals.lua, where that function is defined. + bool countStartedEvents() + { + auto pScript = new TScript(nullptr, mpHost); + mpHost->getScriptUnit()->registerScript(pScript); + pScript->setName(qsl("ttsStartedCounter")); + if (!pScript->setScript(qsl("ttsStartedCount = 0\nfunction ttsStartedCounter(event, text)\n ttsStartedCount = ttsStartedCount + 1\nend\n"))) { + return false; + } + pScript->setEventHandlerList(QStringList{qsl("ttsSpeechStarted")}); + pScript->setIsActive(true); + return true; + } +#endif + +private slots: + void initTestCase() + { + initializeQRCResourcesForTtsInterruptingSpeakTest(); + + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + // Picked up by TLuaInterpreter::ttsBuild(), which then asks for Qt's + // deterministic mock engine instead of whatever the host machine would + // otherwise speak out loud. + qputenv("MUDLET_TEST_MODE", "1"); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, qsl("23"), QString(), QString()), "failed to create the Host"); + mpHost = mudlet::self()->getHostManager().getHost(mProfileName); + QVERIFY(mpHost); + // A bare Host blocks script compilation until the full profile boot + // would clear this, and these tests need their snippets to compile: + mpHost->mBlockScriptCompile = false; + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mudlet::self(); + qunsetenv("MUDLET_TEST_MODE"); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // The TTS state - engine, queue, and the flags this fixes - is process + // global, so it is reset after every test method rather than at the end of + // each, where an assertion that fails would jump over it. + void cleanup() + { +#ifdef QT_TEXTTOSPEECH_LIB + if (mpHost) { + runLua(qsl("ttsClearQueue() ttsSkip()")); + } +#endif + } + + void test_theReadyFromAnInterruptedUtteranceDoesNotDrainTheQueue() + { +#ifndef QT_TEXTTOSPEECH_LIB + QSKIP("Mudlet was built without text-to-speech support"); +#else + QVERIFY2(runLua(qsl("ttsClearQueue() ttsSkip()")), "could not reset the TTS state"); + if (!runLua(qsl("assert(#ttsGetVoices() > 0)"))) { + QSKIP("Qt's mock speech engine is unavailable here, so nothing can be made to speak"); + } + + QVERIFY(runLua(qsl("ttsSpeak('the utterance already being spoken')"))); + QVERIFY(runLua(qsl("ttsQueue('the queued line')"))); + QVERIFY(runLua(qsl("ttsSpeak('the utterance the script asked for')"))); + + // What every real engine reports next: the utterance say() stopped has + // ended. It is not the engine falling idle, and the queue must survive + // it - the requested utterance is what should be being spoken. + TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Ready); + + QVERIFY2(runLua(qsl("assert(#ttsGetQueue() == 1, 'the queued line was spoken over the utterance ttsSpeak() asked for, queue holds '..#ttsGetQueue())")), + "the queue was drained by the Ready that reported the interrupted utterance ending"); + QVERIFY2(runLua(qsl("assert(ttsGetCurrentLine() == 'the utterance the script asked for', 'the current line became: '..tostring(ttsGetCurrentLine()))")), + "the drained line replaced the utterance the script asked for"); + + // The engine getting round to reporting that the requested utterance + // started must not announce it a second time: ttsSpeak() already did, + // there being no state edge at the time for it to have come from. + QVERIFY2(countStartedEvents(), "could not install the event handler that counts ttsSpeechStarted"); + TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Speaking); + QVERIFY2(runLua(qsl("assert(ttsStartedCount == 0, 'the utterance was announced '..ttsStartedCount..' more time(s)')")), + "the engine's late Speaking announced the same utterance a second time"); +#endif + } + + // The control for the test above: with nothing interrupted, that same Ready + // is the engine going idle and has to drain the queue. Without this, a + // change that stopped ttsStateChanged() draining at all would leave the + // test above passing while the queue feature was dead. + void test_theReadyFromAnUninterruptedUtteranceStillDrainsTheQueue() + { +#ifndef QT_TEXTTOSPEECH_LIB + QSKIP("Mudlet was built without text-to-speech support"); +#else + QVERIFY2(runLua(qsl("ttsClearQueue() ttsSkip()")), "could not reset the TTS state"); + if (!runLua(qsl("assert(#ttsGetVoices() > 0)"))) { + QSKIP("Qt's mock speech engine is unavailable here, so nothing can be made to speak"); + } + + QVERIFY(runLua(qsl("ttsSpeak('the only utterance')"))); + QVERIFY(runLua(qsl("ttsQueue('the queued line')"))); + + TLuaInterpreter::ttsStateChanged(QTextToSpeech::State::Ready); + + QVERIFY2(runLua(qsl("assert(#ttsGetQueue() == 0, 'the queue was not drained, it holds '..#ttsGetQueue())")), "an idle engine left the queue undrained"); + QVERIFY2(runLua(qsl("assert(ttsGetCurrentLine() == 'the queued line', 'the current line is: '..tostring(ttsGetCurrentLine()))")), "the drain did not start speaking the queued line"); +#endif + } + + // The other side of the same guard: an explicit stop really does leave the + // engine idle, so its Ready has to keep draining the queue as it always has. + void test_theReadyFromAnExplicitSkipStillDrainsTheQueue() + { +#ifndef QT_TEXTTOSPEECH_LIB + QSKIP("Mudlet was built without text-to-speech support"); +#else + QVERIFY2(runLua(qsl("ttsClearQueue() ttsSkip()")), "could not reset the TTS state"); + if (!runLua(qsl("assert(#ttsGetVoices() > 0)"))) { + QSKIP("Qt's mock speech engine is unavailable here, so nothing can be made to speak"); + } + + QVERIFY(runLua(qsl("ttsSpeak('the utterance being spoken over')"))); + QVERIFY(runLua(qsl("ttsSpeak('the utterance the script asked for')"))); + QVERIFY(runLua(qsl("ttsQueue('the queued line')"))); + QVERIFY(runLua(qsl("ttsSkip()"))); + + QVERIFY2(runLua(qsl("assert(#ttsGetQueue() == 0, 'the queue still holds '..#ttsGetQueue())")), "an explicit skip left the queue undrained"); + QVERIFY2(runLua(qsl("assert(ttsGetCurrentLine() == 'the queued line', 'the current line is: '..tostring(ttsGetCurrentLine()))")), "the skip did not start speaking the queued line"); +#endif + } +}; + +void initializeQRCResourcesForTtsInterruptingSpeakTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "TtsInterruptingSpeakTest.moc" +QTEST_MAIN(TtsInterruptingSpeakTest) diff --git a/test/functional_tests/UnitDeferredDeleteTest.cpp b/test/functional_tests/UnitDeferredDeleteTest.cpp new file mode 100644 index 000000000..d6e99cb1d --- /dev/null +++ b/test/functional_tests/UnitDeferredDeleteTest.cpp @@ -0,0 +1,665 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * TriggerUnit, AliasUnit, KeyUnit and TimerUnit each defer the deletion of an + * item until no script is on the call stack. Two properties of that machinery + * are checked here for all four units: + * + * - freeing a temporary item must unlink only that item from the by-name lookup + * table, not every item filed under the same name (#9649). The lookup table is + * a QMultiMap and names are not unique + * - killing by name must keep scanning past same-named items it cannot kill, + * rather than report failure over the first one (#9649) + * - the two deferred-delete containers, mCleanupSet and uninstallList, must never + * free the same object twice, whichever order it lands in them (#9650) + * + * The timer half of #9649 cannot be reached from the busted Lua suite - that runs + * inside a tempTimer, so TimerUnit's cleanup stays deferred for the whole run - + * which is why it lives here. The trigger, alias and key halves are covered from + * Lua as well, in Trigger_spec.lua, Alias_spec.lua and KeyBinds_spec.lua. + * + * Note on the ...ContainersStayDisjoint cases: a regression there is a double + * free, which has no post-condition to read back - the assertions below hold + * either way and the run aborts instead. That is a real signal because the + * functional tests always build with the address sanitizer on non-Windows + * (test/functional_tests/CMakeLists.txt includes EnableSanitizers.cmake, whose + * USE_SANITIZER defaults to "address"), but it does mean these four cases carry + * no weight in a build with sanitizers switched off. The trigger and timer + * variants are pure regression guards: those two units already had the guards on + * development, and only AliasUnit and KeyUnit gain them here. + * + * Run with: ctest -R UnitDeferredDeleteTest -V + */ + +#include <QtTest/QtTest> +#include <chrono> + +#include "AliasUnit.h" +#include "Host.h" +#include "KeyUnit.h" +#include "MudletInstanceCoordinator.h" +#include "TAlias.h" +#include "TKey.h" +#include "TLuaInterpreter.h" +#include "TTimer.h" +#include "TTrigger.h" +#include "TelnetServerStub.h" +#include "TimerUnit.h" +#include "TriggerUnit.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForUnitDeferredDeleteTest(); + +class UnitDeferredDeleteTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "UnitDeferredDelete-Test"; + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = "localhost"; + const QString mPackageName = "unit deferred delete package"; + + // QMultiMap::count() is a qsizetype; narrow it so QCOMPARE reports a plain + // number against the int literals below + static int lookupCount(qsizetype count) { return static_cast<int>(count); } + +private slots: + void initTestCase() + { + initializeQRCResourcesForUnitDeferredDeleteTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + + startProfile(mHostname, mLocalhost, mPort); + mpHost = mudlet::self()->getActiveHost(); + QVERIFY2(mpHost, "No active host after profile creation"); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + + // #9649: temporary items are named after their id, so a permanent item called + // after that number shares the name. tempComplexRegexTrigger() makes the + // trigger case even easier - it takes a user-supplied name - and that variant + // is covered from Lua in Trigger_spec.lua. + void test_triggerLookupKeepsSameNamedPermanent() + { + auto* unit = mpHost->getTriggerUnit(); + const int tempId = mpHost->mLuaInterpreter.startTempTrigger(qsl("lookup_evict_trigger_temp"), QString()); + QVERIFY(tempId > 0); + const QString sharedName = QString::number(tempId); + + const QStringList permPatterns{qsl("lookup_evict_trigger_perm")}; + auto [permId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(sharedName, QString(), permPatterns, QString()); + QVERIFY2(permId > 0, qPrintable(message)); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killTrigger(sharedName), "the temporary trigger should be the one killed"); + unit->doCleanup(); + + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1); + QCOMPARE(unit->mLookupTable.value(sharedName), unit->getTrigger(permId)); + QVERIFY2(unit->enableTrigger(sharedName), "the permanent trigger must still be reachable by name"); + } + + void test_aliasLookupKeepsSameNamedPermanent() + { + auto* unit = mpHost->getAliasUnit(); + const int tempId = mpHost->mLuaInterpreter.startTempAlias(qsl("^lookup_evict_alias$"), QString()); + QVERIFY(tempId > 0); + const QString sharedName = QString::number(tempId); + + auto [permId, message] = mpHost->mLuaInterpreter.startPermAlias(sharedName, QString(), qsl("^lookup_evict_alias_perm$"), QString()); + QVERIFY2(permId > 0, qPrintable(message)); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killAlias(sharedName), "the temporary alias should be the one killed"); + unit->doCleanup(); + + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1); + QCOMPARE(unit->mLookupTable.value(sharedName), unit->getAlias(permId)); + QVERIFY2(unit->enableAlias(sharedName), "the permanent alias must still be reachable by name"); + } + + void test_timerLookupKeepsSameNamedPermanent() + { + auto* unit = mpHost->getTimerUnit(); + auto [tempId, tempMessage] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false); + QVERIFY2(tempId > 0, qPrintable(tempMessage)); + const QString sharedName = QString::number(tempId); + + auto [permId, message] = mpHost->mLuaInterpreter.startPermTimer(sharedName, QString(), 60.0, QString()); + QVERIFY2(permId > 0, qPrintable(message)); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killTimer(sharedName), "the temporary timer should be the one killed"); + unit->doCleanup(); + + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1); + QCOMPARE(unit->mLookupTable.value(sharedName), unit->getTimer(permId)); + QVERIFY2(unit->enableTimer(sharedName), "the permanent timer must still be reachable by name"); + } + + void test_keyLookupKeepsSameNamedPermanent() + { + auto* unit = mpHost->getKeyUnit(); + QString emptyScript; + int tempModifier = Qt::NoModifier; + int tempKeyCode = Qt::Key_F5; + const int tempId = mpHost->mLuaInterpreter.startTempKey(tempModifier, tempKeyCode, emptyScript); + QVERIFY(tempId > 0); + QString sharedName = QString::number(tempId); + + QString parent; + int permModifier = Qt::NoModifier; + int permKeyCode = Qt::Key_F6; + auto [permId, message] = mpHost->mLuaInterpreter.startPermKey(sharedName, parent, permKeyCode, permModifier, emptyScript); + QVERIFY2(permId > 0, qPrintable(message)); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killKey(sharedName), "the temporary key should be the one killed"); + unit->doCleanup(); + + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1); + QCOMPARE(unit->mLookupTable.value(sharedName), unit->getKey(permId)); + QVERIFY2(unit->enableKey(sharedName), "the permanent key must still be reachable by name"); + } + + // #9649, the kill-by-name half: killX(name) walks the root node list, which + // holds items in creation order, so a permanent item restored from the profile + // at startup precedes this session's temporaries. Giving up on the first + // same-named item that cannot be killed strands the killable one and reports a + // bare false. Each case below renames a freshly created permanent item to the + // id the next temporary will take, which is exactly the collision a saved + // profile produces; the QCOMPARE on the temporary's id makes the test fail + // loudly rather than silently stop testing anything if ids stop being handed + // out in sequence. + void test_triggerKillByNameScansPastPermanent() + { + auto* unit = mpHost->getTriggerUnit(); + const QStringList permPatterns{qsl("kill_order_trigger_perm")}; + auto [permId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(qsl("kill order placeholder"), QString(), permPatterns, QString()); + QVERIFY2(permId > 0, qPrintable(message)); + const QString sharedName = QString::number(permId + 1); + unit->getTrigger(permId)->setName(sharedName); + + const int tempId = mpHost->mLuaInterpreter.startTempTrigger(qsl("kill_order_trigger_temp"), QString()); + QCOMPARE(tempId, permId + 1); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killTrigger(sharedName), "killTrigger must scan past the permanent trigger to the temporary one"); + unit->doCleanup(); + QVERIFY2(!unit->getTrigger(tempId), "the temporary trigger should have been freed"); + QVERIFY(unit->getTrigger(permId)); + } + + void test_aliasKillByNameScansPastPermanent() + { + auto* unit = mpHost->getAliasUnit(); + auto [permId, message] = mpHost->mLuaInterpreter.startPermAlias(qsl("kill order placeholder"), QString(), qsl("^kill_order_alias_perm$"), QString()); + QVERIFY2(permId > 0, qPrintable(message)); + const QString sharedName = QString::number(permId + 1); + unit->getAlias(permId)->setName(sharedName); + + const int tempId = mpHost->mLuaInterpreter.startTempAlias(qsl("^kill_order_alias_temp$"), QString()); + QCOMPARE(tempId, permId + 1); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killAlias(sharedName), "killAlias must scan past the permanent alias to the temporary one"); + unit->doCleanup(); + QVERIFY2(!unit->getAlias(tempId), "the temporary alias should have been freed"); + QVERIFY(unit->getAlias(permId)); + } + + void test_timerKillByNameScansPastPermanent() + { + auto* unit = mpHost->getTimerUnit(); + auto [permId, message] = mpHost->mLuaInterpreter.startPermTimer(qsl("kill order placeholder"), QString(), 60.0, QString()); + QVERIFY2(permId > 0, qPrintable(message)); + const QString sharedName = QString::number(permId + 1); + unit->getTimer(permId)->setName(sharedName); + + auto [tempId, tempMessage] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false); + QVERIFY2(tempId > 0, qPrintable(tempMessage)); + QCOMPARE(tempId, permId + 1); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killTimer(sharedName), "killTimer must scan past the permanent timer to the temporary one"); + unit->doCleanup(); + QVERIFY2(!unit->getTimer(tempId), "the temporary timer should have been freed"); + QVERIFY(unit->getTimer(permId)); + } + + void test_keyKillByNameScansPastPermanent() + { + auto* unit = mpHost->getKeyUnit(); + QString emptyScript; + QString parent; + QString placeholder = qsl("kill order placeholder"); + int permModifier = Qt::NoModifier; + int permKeyCode = Qt::Key_F7; + auto [permId, message] = mpHost->mLuaInterpreter.startPermKey(placeholder, parent, permKeyCode, permModifier, emptyScript); + QVERIFY2(permId > 0, qPrintable(message)); + QString sharedName = QString::number(permId + 1); + unit->getKey(permId)->setName(sharedName); + + int tempModifier = Qt::NoModifier; + int tempKeyCode = Qt::Key_F8; + const int tempId = mpHost->mLuaInterpreter.startTempKey(tempModifier, tempKeyCode, emptyScript); + QCOMPARE(tempId, permId + 1); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killKey(sharedName), "killKey must scan past the permanent key to the temporary one"); + unit->doCleanup(); + QVERIFY2(!unit->getKey(tempId), "the temporary key should have been freed"); + QVERIFY(unit->getKey(permId)); + } + + // #9649 again, on the non-root removal path: a temporary child goes through + // removeTrigger() rather than removeTriggerRootNode(), and both got the same + // exact-match fix. + void test_temporaryChildTriggerLeavesSameNamedSiblingAlone() + { + auto* unit = mpHost->getTriggerUnit(); + const QStringList parentPatterns{qsl("child_evict_parent")}; + auto [parentId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(qsl("Child Eviction Parent"), QString(), parentPatterns, QString()); + QVERIFY2(parentId > 0, qPrintable(message)); + auto* pParent = unit->getTrigger(parentId); + QVERIFY(pParent); + + const QString sharedName = qsl("Child Eviction Shared"); + const QStringList childPatterns{qsl("child_evict_perm")}; + auto [permChildId, childMessage] = mpHost->mLuaInterpreter.startPermSubstringTrigger(sharedName, qsl("Child Eviction Parent"), childPatterns, QString()); + QVERIFY2(permChildId > 0, qPrintable(childMessage)); + + auto* pTempChild = new TTrigger(pParent, mpHost); + pTempChild->setRegexCodeList(QStringList{qsl("child_evict_temp")}, QList<int>{REGEX_SUBSTRING}); + pTempChild->setIsFolder(false); + pTempChild->setIsActive(true); + pTempChild->setTemporary(true); + pTempChild->registerTrigger(); + pTempChild->setName(sharedName); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killTrigger(sharedName), "the temporary child should be the one killed"); + unit->doCleanup(); + + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 1); + QCOMPARE(unit->mLookupTable.value(sharedName), unit->getTrigger(permChildId)); + } + + // #9650: an item can be queued in mCleanupSet and in uninstallList at the same + // time - uninstall() at a non-zero processing depth leaves its items in + // uninstallList and drops them from mCleanupSet, and a script killing one of + // them afterwards puts it back. doCleanup() has to free such an item exactly + // once. Both containers are populated directly here because reaching the + // overlap from Lua needs a package-owned temporary item, which no current + // import path produces. + void test_triggerDeferredDeleteContainersStayDisjoint() + { + auto* unit = mpHost->getTriggerUnit(); + const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("double_free_trigger"), QString(), -1); + QVERIFY(id > 0); + auto* pTrigger = unit->getTrigger(id); + QVERIFY(pTrigger); + + unit->uninstallList.append(pTrigger); + unit->markCleanup(pTrigger); + unit->doCleanup(); + + QVERIFY(unit->mCleanupSet.isEmpty()); + QVERIFY(unit->uninstallList.isEmpty()); + QVERIFY2(!unit->getTrigger(id), "the trigger should have been freed exactly once"); + } + + // #9650: uninstall() at depth 0 deletes straight away, so it also has to drop + // the item from mCleanupSet - otherwise the next doCleanup() frees a dangling + // pointer. + void test_triggerUninstallAtDepthZeroClearsCleanupSet() + { + auto* unit = mpHost->getTriggerUnit(); + const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("uninstall_trigger"), QString(), -1); + QVERIFY(id > 0); + auto* pTrigger = unit->getTrigger(id); + QVERIFY(pTrigger); + pTrigger->mPackageName = mPackageName; + + unit->markCleanup(pTrigger); + unit->uninstall(mPackageName); + + // pTrigger is freed by now, so read the set's size rather than look the + // dangling pointer up in it + QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the trigger it freed out of the cleanup set"); + unit->doCleanup(); + QVERIFY2(!unit->getTrigger(id), "the trigger should have been freed exactly once"); + } + + void test_aliasDeferredDeleteContainersStayDisjoint() + { + auto* unit = mpHost->getAliasUnit(); + const int id = mpHost->mLuaInterpreter.startTempAlias(qsl("^double_free_alias$"), QString()); + QVERIFY(id > 0); + auto* pAlias = unit->getAlias(id); + QVERIFY(pAlias); + + unit->uninstallList.append(pAlias); + unit->markCleanup(pAlias); + unit->doCleanup(); + + QVERIFY(unit->mCleanupSet.isEmpty()); + QVERIFY(unit->uninstallList.isEmpty()); + QVERIFY2(!unit->getAlias(id), "the alias should have been freed exactly once"); + } + + void test_aliasUninstallAtDepthZeroClearsCleanupSet() + { + auto* unit = mpHost->getAliasUnit(); + const int id = mpHost->mLuaInterpreter.startTempAlias(qsl("^uninstall_alias$"), QString()); + QVERIFY(id > 0); + auto* pAlias = unit->getAlias(id); + QVERIFY(pAlias); + pAlias->mPackageName = mPackageName; + + unit->markCleanup(pAlias); + unit->uninstall(mPackageName); + + QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the alias it freed out of the cleanup set"); + unit->doCleanup(); + QVERIFY2(!unit->getAlias(id), "the alias should have been freed exactly once"); + } + + void test_timerDeferredDeleteContainersStayDisjoint() + { + auto* unit = mpHost->getTimerUnit(); + auto [id, message] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false); + QVERIFY2(id > 0, qPrintable(message)); + auto* pTimer = unit->getTimer(id); + QVERIFY(pTimer); + + unit->uninstallList.append(pTimer); + unit->markCleanup(pTimer); + unit->doCleanup(); + + QVERIFY(unit->mCleanupSet.isEmpty()); + QVERIFY(unit->uninstallList.isEmpty()); + QVERIFY2(!unit->getTimer(id), "the timer should have been freed exactly once"); + } + + void test_timerUninstallAtDepthZeroClearsCleanupSet() + { + auto* unit = mpHost->getTimerUnit(); + auto [id, message] = mpHost->mLuaInterpreter.startTempTimer(60.0, QString(), false); + QVERIFY2(id > 0, qPrintable(message)); + auto* pTimer = unit->getTimer(id); + QVERIFY(pTimer); + pTimer->mPackageName = mPackageName; + + unit->markCleanup(pTimer); + unit->uninstall(mPackageName); + + QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the timer it freed out of the cleanup set"); + unit->doCleanup(); + QVERIFY2(!unit->getTimer(id), "the timer should have been freed exactly once"); + } + + void test_keyDeferredDeleteContainersStayDisjoint() + { + auto* unit = mpHost->getKeyUnit(); + QString emptyScript; + int modifier = Qt::NoModifier; + int keyCode = Qt::Key_F10; + const int id = mpHost->mLuaInterpreter.startTempKey(modifier, keyCode, emptyScript); + QVERIFY(id > 0); + auto* pKey = unit->getKey(id); + QVERIFY(pKey); + + unit->uninstallList.append(pKey); + unit->markCleanup(pKey); + unit->doCleanup(); + + QVERIFY(unit->mCleanupSet.isEmpty()); + QVERIFY(unit->uninstallList.isEmpty()); + QVERIFY2(!unit->getKey(id), "the key should have been freed exactly once"); + } + + void test_keyUninstallAtDepthZeroClearsCleanupSet() + { + auto* unit = mpHost->getKeyUnit(); + QString emptyScript; + int modifier = Qt::NoModifier; + int keyCode = Qt::Key_F11; + const int id = mpHost->mLuaInterpreter.startTempKey(modifier, keyCode, emptyScript); + QVERIFY(id > 0); + auto* pKey = unit->getKey(id); + QVERIFY(pKey); + pKey->mPackageName = mPackageName; + + unit->markCleanup(pKey); + unit->uninstall(mPackageName); + + QVERIFY2(unit->mCleanupSet.isEmpty(), "uninstall() must take the key it freed out of the cleanup set"); + unit->doCleanup(); + QVERIFY2(!unit->getKey(id), "the key should have been freed exactly once"); + } + + // killTrigger() skips an item that is only waiting to be freed; enableTrigger() + // has to as well, or it resurrects the corpse. + void test_triggerEnableByNameCannotReviveKilled() + { + auto* unit = mpHost->getTriggerUnit(); + const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("resurrect_trigger"), QString(), -1); + QVERIFY(id > 0); + auto* pTrigger = unit->getTrigger(id); + QVERIFY(pTrigger); + const QString name = QString::number(id); + QVERIFY(pTrigger->isActive()); + + QVERIFY2(unit->killTrigger(name), "the temporary trigger should be killable by name"); + QVERIFY2(!pTrigger->isActive(), "killTrigger() must deactivate as well as queue the delete"); + QVERIFY2(unit->mCleanupSet.contains(pTrigger), "the killed trigger should be waiting to be freed"); + + QVERIFY2(!unit->enableTrigger(name), "enableTrigger() must not report success for a trigger that is only waiting to be freed"); + QVERIFY2(!pTrigger->isActive(), "a killed trigger must stay dead until it is freed"); + + unit->doCleanup(); + QVERIFY2(!unit->getTrigger(id), "the killed trigger should still have been freed"); + } + + // As a user meets it: a one-shot has spent its fire and a script later on the + // same line enables it by name. + void test_triggerEnableByNameCannotReviveExpiredOneShot() + { + mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("oneShotFires = 0\n" + "reviverRan = false\n" + "tempComplexRegexTrigger('watchOnce', '^ONESHOT$', [[oneShotFires = oneShotFires + 1]], 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)\n" + "tempRegexTrigger('^ONESHOT$', [[\n" + " if not reviverRan then\n" + " reviverRan = true\n" + " enableTrigger('watchOnce')\n" + " feedTriggers('ONESHOT\\n')\n" + " end\n" + "]], 1)\n" + "feedTriggers('ONESHOT\\n')\n")); + + QCOMPARE(mpHost->getTriggerUnit()->processingDepth(), 0); + QVERIFY2(readGlobalBool(qsl("reviverRan")), "the script that calls enableTrigger() has to have run for this to test anything"); + QCOMPARE(readGlobalInt(qsl("oneShotFires")), 1); + } + + // uninstall() at a non-zero processing depth leaves its package's triggers in + // uninstallList rather than mCleanupSet, still in the lookup table. Populated + // directly: reaching that state from Lua needs a package-owned temporary item, + // which no current import path produces. + void test_triggerEnableByNameCannotReviveAnUninstalledTrigger() + { + auto* unit = mpHost->getTriggerUnit(); + const int id = mpHost->mLuaInterpreter.startTempTrigger(qsl("uninstall_revive_trigger"), QString(), -1); + QVERIFY(id > 0); + auto* pTrigger = unit->getTrigger(id); + QVERIFY(pTrigger); + const QString name = QString::number(id); + + pTrigger->setIsActive(false); + unit->uninstallList.append(pTrigger); + QVERIFY2(!unit->mCleanupSet.contains(pTrigger), "uninstall() keeps the two deferred-delete containers disjoint"); + + QVERIFY2(!unit->enableTrigger(name), "enableTrigger() must not report success for a trigger an uninstall is waiting to free"); + QVERIFY2(!pTrigger->isActive(), "a trigger whose package has been uninstalled must stay inactive"); + + unit->doCleanup(); + QVERIFY2(!unit->getTrigger(id), "the uninstalled trigger should still have been freed"); + } + + // The skip must not stop the walk: a corpse and a live trigger can share a + // name, and enable-by-name still has to reach every live one. + void test_triggerEnableByNameStillReachesALiveSameNamedTrigger() + { + auto* unit = mpHost->getTriggerUnit(); + const QStringList permPatterns{qsl("mixed_corpse_perm")}; + auto [permId, message] = mpHost->mLuaInterpreter.startPermSubstringTrigger(qsl("mixed corpse placeholder"), QString(), permPatterns, QString()); + QVERIFY2(permId > 0, qPrintable(message)); + const QString sharedName = QString::number(permId + 1); + unit->getTrigger(permId)->setName(sharedName); + + const int tempId = mpHost->mLuaInterpreter.startTempTrigger(qsl("mixed_corpse_temp"), QString()); + QCOMPARE(tempId, permId + 1); + QCOMPARE(lookupCount(unit->mLookupTable.count(sharedName)), 2); + + QVERIFY2(unit->killTrigger(sharedName), "the temporary trigger should be the one killed"); + unit->getTrigger(permId)->setIsActive(false); + + QVERIFY2(unit->enableTrigger(sharedName), "enableTrigger must walk past the corpse to the live trigger filed under the same name"); + QVERIFY2(unit->getTrigger(permId)->isActive(), "the live same-named trigger should have been enabled"); + QVERIFY2(!unit->getTrigger(tempId)->isActive(), "the killed trigger must stay dead"); + + unit->doCleanup(); + QVERIFY(!unit->getTrigger(tempId)); + } + + // Helpers (reused from the ResetProfileTest pattern) + + int readGlobalInt(const QString& name) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, name.toUtf8().constData()); + const int value = static_cast<int>(lua_tointeger(L, -1)); + lua_pop(L, 1); + return value; + } + + bool readGlobalBool(const QString& name) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, name.toUtf8().constData()); + const bool value = lua_toboolean(L, -1); + lua_pop(L, 1); + return value; + } + + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0ms, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(1000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(500)) { + QFAIL("Could not connect with the host."); + } + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResourcesForUnitDeferredDeleteTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "UnitDeferredDeleteTest.moc" +QTEST_MAIN(UnitDeferredDeleteTest) diff --git a/test/functional_tests/UnitProcessingDepthTest.cpp b/test/functional_tests/UnitProcessingDepthTest.cpp new file mode 100644 index 000000000..faa8fbeb1 --- /dev/null +++ b/test/functional_tests/UnitProcessingDepthTest.cpp @@ -0,0 +1,253 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * AliasUnit and KeyUnit count how deeply their processDataStream() is nested so + * that an item deleted mid-pass (the #9337 self-uninstall pattern) is only freed + * once the outermost pass has finished - see the deferral added in #9383. + * + * The count is a member, so a pass that returns without taking its level back + * off leaves the unit permanently "busy": every later doCleanup() declines to + * run and the deferred deletes are never flushed. Nothing crashes and nothing + * warns, which is why these paths are asserted directly. KeyUnit is the one that + * matters most: it returns from inside its match loop as soon as a key fires, + * which is exactly the shape of exit a hand-written decrement gets forgotten on. + * + * Run with: ctest -R UnitProcessingDepthTest -V + */ + +#include <QtTest/QtTest> + +#include <QScopeGuard> +#include <QTemporaryDir> + +#include "AliasUnit.h" +#include "Host.h" +#include "HostManager.h" +#include "KeyUnit.h" +#include "MudletInstanceCoordinator.h" +#include "TKey.h" +#include "TLuaInterpreter.h" +#include "mudlet.h" + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForUnitProcessingDepthTest(); + +class UnitProcessingDepthTest : public QObject +{ + Q_OBJECT + +private: + const QString mProfileName = qsl("UnitProcessingDepth-Test"); + QTemporaryDir mConfigDir; + QByteArray mSavedXdg; + Host* mpHost = nullptr; + + // Reads back Lua state through the return value rather than getLuaString(), + // which reports an absolute stack slot and so only answers correctly for the + // first call in a process. + bool luaHolds(const QString& condition) { return mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("assert(%1)").arg(condition)); } + +private slots: + void initTestCase() + { + initializeQRCResourcesForUnitProcessingDepthTest(); + + // Keep the test hermetic: resolve the config dir to a temporary + // directory rather than the user's real profiles. + QVERIFY(mConfigDir.isValid()); + mSavedXdg = qgetenv("XDG_CONFIG_HOME"); + QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path()))); + qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8()); + + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host"); + mpHost = mudlet::self()->getHostManager().getHost(mProfileName); + QVERIFY(mpHost); + // A bare Host blocks script compilation until the full profile boot + // would normally clear this; the items below need to compile: + mpHost->mBlockScriptCompile = false; + } + + // Applies to every slot, including any added later: a level left on after a + // pass is what silently wedges the unit, so no slot gets to end holding one. + void cleanup() + { + QCOMPARE(mpHost->getKeyUnit()->processingDepth(), 0); + QCOMPARE(mpHost->getAliasUnit()->processingDepth(), 0); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mudlet::self(); + mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg); + } + + // A key that fires returns from the middle of the match loop; one that does + // not runs the loop out. Both exits owe the unit its level back, and the + // repeat is what makes a leak visible - one leaked level looks like nothing, + // an accumulating count is what actually wedges cleanup. + void keyProcessingDepthIsHandedBackOnEveryExit() + { + auto* keyUnit = mpHost->getKeyUnit(); + QCOMPARE(keyUnit->processingDepth(), 0); + // The exit under test is only taken when this is false, and a match + // reports true either way - so without pinning it, a changed default + // would quietly move this slot onto the fall-through path instead. + QCOMPARE(keyUnit->mRunAllKeyMatches, false); + + QString name = qsl("depthProbeKey"); + QString parent; + QString script = qsl("keyFireCount = (keyFireCount or 0) + 1"); + int keycode = Qt::Key_F7; + int modifier = Qt::NoModifier; + auto [id, message] = mpHost->mLuaInterpreter.startPermKey(name, parent, keycode, modifier, script); + QVERIFY2(id > 0, qPrintable(message)); + + constexpr int passes = 5; + for (int pass = 1; pass <= passes; ++pass) { + QVERIFY2(keyUnit->processDataStream(Qt::Key_F7, Qt::NoModifier), "the probe key did not match, so the return-from-the-loop exit went untested"); + QCOMPARE(keyUnit->processingDepth(), 0); + + QVERIFY2(!keyUnit->processDataStream(Qt::Key_F8, Qt::NoModifier), "an unbound key reported a match"); + QCOMPARE(keyUnit->processingDepth(), 0); + } + + // Proves the matching calls really did run the key's script, so the + // depth assertions above are not passing on a loop that never matched. + QVERIFY2(luaHolds(qsl("keyFireCount == %1").arg(passes)), "the probe key matched but its script did not run once per pass"); + } + + // With mRunAllKeyMatches set, a match no longer returns early and every key + // gets a turn - the other way through the same function. + void keyProcessingDepthIsHandedBackWhenRunningAllMatches() + { + auto* keyUnit = mpHost->getKeyUnit(); + QCOMPARE(keyUnit->processingDepth(), 0); + + QList<int> probeIds; + const bool savedRunAllKeyMatches = keyUnit->mRunAllKeyMatches; + keyUnit->mRunAllKeyMatches = true; + // Hands the unit back exactly as it was found - the flag is global to + // the profile and the F9 probes would otherwise fire in later slots. + const auto restoreGuard = qScopeGuard([keyUnit, savedRunAllKeyMatches, &probeIds] { + keyUnit->mRunAllKeyMatches = savedRunAllKeyMatches; + for (const int probeId : probeIds) { + if (auto* pKey = keyUnit->getKey(probeId)) { + pKey->setIsActive(false); + } + } + }); + + QString parent; + QString script = qsl("allMatchCount = (allMatchCount or 0) + 1"); + int keycode = Qt::Key_F9; + int modifier = Qt::NoModifier; + for (const auto& keyName : {qsl("allMatchProbeA"), qsl("allMatchProbeB")}) { + QString name = keyName; + auto [id, message] = mpHost->mLuaInterpreter.startPermKey(name, parent, keycode, modifier, script); + QVERIFY2(id > 0, qPrintable(message)); + probeIds.append(id); + } + + QVERIFY(keyUnit->processDataStream(Qt::Key_F9, Qt::NoModifier)); + QCOMPARE(keyUnit->processingDepth(), 0); + QVERIFY2(luaHolds(qsl("allMatchCount == 2")), "only one of the two keys bound to F9 ran, so the loop did not carry on past the first match"); + } + + // AliasUnit has the single exit, but the same permanence applies: the level + // has to be back off before the unit is asked to process anything else. + void aliasProcessingDepthIsHandedBackOnEveryExit() + { + auto* aliasUnit = mpHost->getAliasUnit(); + QCOMPARE(aliasUnit->processingDepth(), 0); + + auto [id, message] = mpHost->mLuaInterpreter.startPermAlias(qsl("depthProbeAlias"), QString(), qsl("^probe$"), qsl("aliasFireCount = (aliasFireCount or 0) + 1")); + QVERIFY2(id > 0, qPrintable(message)); + + constexpr int passes = 5; + for (int pass = 1; pass <= passes; ++pass) { + QVERIFY2(aliasUnit->processDataStream(qsl("probe")), "the probe alias did not match"); + QCOMPARE(aliasUnit->processingDepth(), 0); + + QVERIFY2(!aliasUnit->processDataStream(qsl("nothing matches this")), "an unmatched command reported a match"); + QCOMPARE(aliasUnit->processingDepth(), 0); + } + + QVERIFY2(luaHolds(qsl("aliasFireCount == %1").arg(passes)), "the probe alias matched but its script did not run once per pass"); + } + + // What the count is actually for. Both items delete themselves from their + // own script, which the unit has to defer while the pass is on the stack and + // then flush - and the flush is the drain step the guard runs at depth 0. + // Nothing else pumps cleanup here: Host::slot_purgeTemps() needs an event + // loop this test never spins, so if the drain does not run the item survives. + void anItemThatKillsItselfMidPassIsFreedByTheDrain() + { + auto* aliasUnit = mpHost->getAliasUnit(); + const int aliasId = mpHost->mLuaInterpreter.startTempAlias(qsl("^selfkill$"), qsl("killAlias(tostring(selfKillAliasId))")); + QVERIFY(aliasId > 0); + QVERIFY(mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("selfKillAliasId = %1").arg(aliasId))); + QVERIFY2(aliasUnit->getAlias(aliasId), "the temp alias was not registered"); + + QVERIFY2(aliasUnit->processDataStream(qsl("selfkill")), "the self-killing alias did not match"); + QCOMPARE(aliasUnit->processingDepth(), 0); + QVERIFY2(!aliasUnit->getAlias(aliasId), "the alias killed itself mid-pass but was never freed - the drain did not run"); + + auto* keyUnit = mpHost->getKeyUnit(); + int keycode = Qt::Key_F10; + int modifier = Qt::NoModifier; + const int keyId = mpHost->mLuaInterpreter.startTempKey(modifier, keycode, qsl("killKey(tostring(selfKillKeyId))")); + QVERIFY(keyId > 0); + QVERIFY(mpHost->mLuaInterpreter.compileAndExecuteScript(qsl("selfKillKeyId = %1").arg(keyId))); + QVERIFY2(keyUnit->getKey(keyId), "the temp key was not registered"); + + QVERIFY2(keyUnit->processDataStream(Qt::Key_F10, Qt::NoModifier), "the self-killing key did not match"); + QCOMPARE(keyUnit->processingDepth(), 0); + QVERIFY2(!keyUnit->getKey(keyId), "the key killed itself mid-pass but was never freed - the drain did not run"); + } +}; + +void initializeQRCResourcesForUnitProcessingDepthTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "UnitProcessingDepthTest.moc" +QTEST_MAIN(UnitProcessingDepthTest) diff --git a/test/functional_tests/WindowBackgroundTest.cpp b/test/functional_tests/WindowBackgroundTest.cpp new file mode 100644 index 000000000..253822177 --- /dev/null +++ b/test/functional_tests/WindowBackgroundTest.cpp @@ -0,0 +1,435 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +#include <QSignalSpy> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "MudletInstanceCoordinator.h" +#include "TLabel.h" +#include "TLuaInterpreter.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lua.h> +#else +#include <lua.h> +#endif +} + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForWindowBackgroundTest(); + +// Covers the full-window background feature added in #9394. +class WindowBackgroundTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = "WindowBackground-Test-Host"; + QString mPort; + const QString mLocalhost = "localhost"; + QTemporaryDir mImageDir; + + // a pattern rather than a flat fill, so that resampling differences show up + QString writeImage(const QString& fileName, const QSize& size, const QColor& seed) + { + QImage image(size, QImage::Format_ARGB32); + for (int y = 0; y < size.height(); ++y) { + for (int x = 0; x < size.width(); ++x) { + image.setPixel(x, y, qRgb((seed.red() + x * 7) % 256, (seed.green() + y * 13) % 256, (seed.blue() + (x + y) * 3) % 256)); + } + } + const QString path = mImageDir.filePath(fileName); + if (!image.save(path, "PNG")) { + return QString(); + } + return path; + } + + void runLua(const QString& script) { QVERIFY2(mpHost->getLuaInterpreter()->compileAndExecuteScript(script), qPrintable(script)); } + + int luaInt(const QString& global) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, global.toUtf8().constData()); + const int value = static_cast<int>(lua_tointeger(L, -1)); + lua_pop(L, 1); + return value; + } + + QString luaString(const QString& global) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, global.toUtf8().constData()); + const QString value = QString::fromUtf8(lua_tostring(L, -1)); + lua_pop(L, 1); + return value; + } + + bool luaNil(const QString& global) + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + lua_getglobal(L, global.toUtf8().constData()); + const bool value = lua_isnil(L, -1); + lua_pop(L, 1); + return value; + } + + int stackIndex(const QWidget* widget) const { return mpHost->mpConsole->mpMainFrame->children().indexOf(widget); } + + void verifyStackedBelow(const QWidget* lower, const QWidget* upper, const char* message) + { + QVERIFY(lower); + QVERIFY(upper); + const int lowerIndex = stackIndex(lower); + const int upperIndex = stackIndex(upper); + QVERIFY2(lowerIndex >= 0 && upperIndex >= 0, "a widget under test is not a child of mpMainFrame"); + QVERIFY2(lowerIndex < upperIndex, message); + } + + QPixmap installedBackgroundBrush() const { return mpHost->mpConsole->mpWindowBackground->palette().brush(QPalette::Window).texture(); } + +private slots: + void initTestCase() + { + initializeQRCResourcesForWindowBackgroundTest(); + + QVERIFY(mImageDir.isValid()); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + QDir(mudlet::getMudletPath(enums::profileHomePath, mHostname)).removeRecursively(); + + QTimer::singleShot(0ms, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(2000)) { + QFAIL("Could not connect with the host."); + } + + mudlet::self()->resize(1200, 800); + QTest::qWait(100ms); + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + mpHost = nullptr; + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + delete mudlet::self(); + QDir(path).removeRecursively(); + } + + void init() + { + QVERIFY(mpHost); + QVERIFY(mpHost->mpConsole); + QVERIFY(mpHost->mpConsole->mpWindowBackground); + runLua(qsl("resetBackgroundImage('main', true)")); + QCOMPARE(mpHost->mpConsole->mWindowBgImageMode, 0); + runLua(qsl("setBorderColor(0, 0, 0)")); + } + + // runs even when a QVERIFY aborts a test body, so nothing leaks into the next one + void cleanup() + { + mpHost->mpConsole->deleteLabel(qsl("lowerTarget")); + mpHost->mpConsole->deleteMiniConsole(qsl("lowerConsole")); + } + + // lowerWindow() drops mpMainDisplay to the bottom of mpMainFrame's stack so a + // lowered label still sits above the console - and the background is a sibling there. + void test_lowerWindowKeepsWindowBackgroundBottomMost() + { + const QString imagePath = writeImage(qsl("solid.png"), QSize(64, 64), Qt::red); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + QCOMPARE(mpHost->mpConsole->mWindowBgImageMode, 5); + + runLua(qsl("createLabel('lowerTarget', 10, 10, 100, 100, 1)")); + QVERIFY(mpHost->mpConsole->mLabelMap.contains(qsl("lowerTarget"))); + + runLua(qsl("lowerWindow('lowerTarget')")); + + verifyStackedBelow(mpHost->mpConsole->mpWindowBackground, mpHost->mpConsole->mpMainDisplay, "lowerWindow() left the full-window background painting on top of the main display"); + verifyStackedBelow(mpHost->mpConsole->mpMainDisplay, mpHost->mpConsole->mLabelMap.value(qsl("lowerTarget")), "lowerWindow() left the lowered label hidden behind the main display"); + } + + // The six branches of lowerWindow() are copy-pasted, so cover a second one. + void test_lowerWindowKeepsWindowBackgroundBottomMostForAMiniConsole() + { + const QString imagePath = writeImage(qsl("solidConsole.png"), QSize(64, 64), Qt::cyan); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + runLua(qsl("createMiniConsole('lowerConsole', 10, 10, 200, 100)")); + QVERIFY(mpHost->mpConsole->mSubConsoleMap.contains(qsl("lowerConsole"))); + + runLua(qsl("lowerWindow('lowerConsole')")); + + verifyStackedBelow(mpHost->mpConsole->mpWindowBackground, mpHost->mpConsole->mpMainDisplay, "lowerWindow() left the full-window background painting on top of the main display"); + } + + void test_lowerWindowOrderingHoldsWithoutABackgroundImage() + { + runLua(qsl("createLabel('lowerTarget', 10, 10, 100, 100, 1)")); + runLua(qsl("lowerWindow('lowerTarget')")); + + verifyStackedBelow(mpHost->mpConsole->mpWindowBackground, mpHost->mpConsole->mpMainDisplay, "lowerWindow() put the main display below the full-window background widget"); + verifyStackedBelow(mpHost->mpConsole->mpMainDisplay, mpHost->mpConsole->mLabelMap.value(qsl("lowerTarget")), "lowerWindow() left the lowered label hidden behind the main display"); + } + + // a game can reach changeColors() with no user action, through an OSC palette change + void test_borderColorSurvivesChangeColors() + { + runLua(qsl("setBorderColor(10, 20, 30)")); + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(10, 20, 30)); + + mpHost->mpConsole->changeColors(); + + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(10, 20, 30)); + QCOMPARE(mpHost->mpConsole->borderColor(), QColor(10, 20, 30)); + } + + void test_borderColorSurvivesSetBackgroundColor() + { + runLua(qsl("setBorderColor(40, 50, 60)")); + runLua(qsl("setBackgroundColor('main', 1, 2, 3, 255)")); + + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(40, 50, 60)); + } + + void test_borderColorReturnsAfterResettingTheBackground() + { + const QString imagePath = writeImage(qsl("reset.png"), QSize(64, 64), Qt::yellow); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBorderColor(255, 0, 0)")); + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window).alpha(), 0); + + runLua(qsl("resetBackgroundImage('main', true)")); + + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window), QColor(255, 0, 0)); + } + + void test_setBorderColorUnderAFullWindowBackgroundKeepsTheFrameTransparent() + { + const QString imagePath = writeImage(qsl("order.png"), QSize(64, 64), Qt::white); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + runLua(qsl("setBorderColor(11, 22, 33)")); + + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window).alpha(), 0); + QCOMPARE(mpHost->mpConsole->borderColor(), QColor(11, 22, 33)); + } + + // the frame is transparent under a full-window background, so the palette cannot be the source + void test_getBorderColorReportsSetValueUnderFullWindowBackground() + { + const QString imagePath = writeImage(qsl("solid2.png"), QSize(64, 64), Qt::blue); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBorderColor(70, 80, 90)")); + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + runLua(qsl("borderR, borderG, borderB = getBorderColor()")); + + QCOMPARE(luaInt(qsl("borderR")), 70); + QCOMPARE(luaInt(qsl("borderG")), 80); + QCOMPARE(luaInt(qsl("borderB")), 90); + + QCOMPARE(mpHost->mpConsole->mpMainFrame->palette().color(QPalette::Window).alpha(), 0); + } + + void test_coverSourceRectNeverExceedsTheSourceImage() + { + const QVector<QSize> sourceSizes{{3000, 100}, {100, 3000}, {1920, 1080}, {64, 64}, {1, 4000}, {4000, 1}}; + const QVector<QSize> targetSizes{{1920, 1080}, {800, 600}, {1, 1}, {3840, 40}}; + + for (const QSize& source : sourceSizes) { + for (const QSize& target : targetSizes) { + const QRect crop = TConsole::coverSourceRect(source, target); + const QString context = qsl("source %1x%2 target %3x%4").arg(source.width()).arg(source.height()).arg(target.width()).arg(target.height()); + QVERIFY2(!crop.isEmpty(), qPrintable(context)); + QVERIFY2(QRect(QPoint(0, 0), source).contains(crop), qPrintable(context)); + } + } + } + + void test_coverSourceRectMatchesAspectPreservingCentreCrop() + { + const QRect wideSource = TConsole::coverSourceRect(QSize(3000, 100), QSize(1920, 1080)); + QCOMPARE(wideSource.height(), 100); + QCOMPARE(wideSource.width(), 177); + QCOMPARE(wideSource.center().x(), QRect(0, 0, 3000, 100).center().x()); + + const QRect tallSource = TConsole::coverSourceRect(QSize(100, 3000), QSize(1920, 1080)); + QCOMPARE(tallSource.width(), 100); + QCOMPARE(tallSource.height(), 56); + QCOMPARE(tallSource.center().y(), QRect(0, 0, 100, 3000).center().y()); + } + + void test_coverBrushMatchesWidgetSizeForExtremeAspectImage() + { + const QString imagePath = writeImage(qsl("wide.png"), QSize(3000, 100), Qt::green); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + + const QSize widgetSize = mpHost->mpConsole->mpWindowBackground->size(); + QVERIFY(!widgetSize.isEmpty()); + QCOMPARE(installedBackgroundBrush().size(), widgetSize); + } + + // the two orders resample differently, so this fails if the crop stops coming first + void test_coverBrushIsScaledFromTheCroppedSourceRegion() + { + const QSize sourceSize(3000, 100); + const QString imagePath = writeImage(qsl("order-wide.png"), sourceSize, Qt::darkGreen); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + + const QSize widgetSize = mpHost->mpConsole->mpWindowBackground->size(); + const QPixmap source(imagePath); + QCOMPARE(source.size(), sourceSize); + const QPixmap expected = source.copy(TConsole::coverSourceRect(sourceSize, widgetSize)).scaled(widgetSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + QCOMPARE(installedBackgroundBrush().toImage(), expected.toImage()); + } + + void test_unloadableCoverImageIsReportedAndKeepsThePreviousBackground() + { + const QString goodPath = writeImage(qsl("good.png"), QSize(300, 200), Qt::gray); + QVERIFY(!goodPath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(goodPath)); + const QImage installed = installedBackgroundBrush().toImage(); + QVERIFY(!installed.isNull()); + + runLua(qsl("bgOk, bgError = setBackgroundImage('main', [[%1]], 'cover', true)").arg(mImageDir.filePath(qsl("no-such-file.png")))); + + QVERIFY(luaNil(qsl("bgOk"))); + QVERIFY2(luaString(qsl("bgError")).contains(qsl("full window background image")), qPrintable(luaString(qsl("bgError")))); + QCOMPARE(mpHost->mpConsole->mWindowBgImagePath, goodPath); + QCOMPARE(installedBackgroundBrush().toImage(), installed); + } + + void test_coverBrushFollowsAWindowResize() + { + const QString imagePath = writeImage(qsl("resize.png"), QSize(3000, 100), Qt::darkRed); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + const QSize sizeBefore = mpHost->mpConsole->mpWindowBackground->size(); + + mudlet::self()->resize(900, 640); + QTest::qWait(200ms); + + const QSize sizeAfter = mpHost->mpConsole->mpWindowBackground->size(); + QVERIFY2(sizeAfter != sizeBefore, "the window did not actually resize"); + QCOMPARE(installedBackgroundBrush().size(), sizeAfter); + + mudlet::self()->resize(1200, 800); + QTest::qWait(200ms); + } + + // clearing a stylesheet repolishes the widget, which can drop the palette brush + void test_switchingFromStylesheetModeToCoverInstallsTheBrush() + { + const QString imagePath = writeImage(qsl("switch.png"), QSize(256, 128), Qt::magenta); + QVERIFY(!imagePath.isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'border', true)").arg(imagePath)); + QVERIFY(!mpHost->mpConsole->mpWindowBackground->styleSheet().isEmpty()); + + runLua(qsl("setBackgroundImage('main', [[%1]], 'cover', true)").arg(imagePath)); + + QVERIFY(mpHost->mpConsole->mpWindowBackground->styleSheet().isEmpty()); + QCOMPARE(mpHost->mpConsole->mpWindowBackground->palette().brush(QPalette::Window).texture().size(), mpHost->mpConsole->mpWindowBackground->size()); + } +}; + +void initializeQRCResourcesForWindowBackgroundTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "WindowBackgroundTest.moc" +QTEST_MAIN(WindowBackgroundTest) diff --git a/test/functional_tests/WindowStateGettersTest.cpp b/test/functional_tests/WindowStateGettersTest.cpp new file mode 100644 index 000000000..293828a30 --- /dev/null +++ b/test/functional_tests/WindowStateGettersTest.cpp @@ -0,0 +1,256 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Covers getWindowGeometry() and windowVisible() for a profile that is not the + * front tab. Mudlet hides a backgrounded profile's whole console, so the busted + * suite structurally cannot reach this: it always runs as the only, front, + * profile. + */ + +#include <QSignalSpy> +#include <QtTest/QtTest> +#include <chrono> + +#include "Host.h" +#include "HostManager.h" +#include "MudletInstanceCoordinator.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForWindowStateGettersTest(); + +class WindowStateGettersTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpBackgroundHost = nullptr; + Host* mpFrontHost = nullptr; + const QString mBackgroundHostname = qsl("WindowStateGetters-Background"); + const QString mFrontHostname = qsl("WindowStateGetters-Front"); + QString mPort; + const QString mLocalhost = qsl("localhost"); + const QString mLabelName = qsl("wsgLabel"); + const QString mConsoleName = qsl("wsgConsole"); + const QString mScrollBoxName = qsl("wsgScrollBox"); + const QString mCmdLineName = qsl("wsgCmdLine"); + const QString mTextEditName = qsl("wsgTextEdit"); + const QString mUserWindowName = qsl("wsgUserWindow"); + const QString mChildLabelName = qsl("wsgChildLabel"); + +private slots: + void initTestCase() + { + initializeQRCResourcesForWindowStateGettersTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + deleteProfileDirectory(mBackgroundHostname); + deleteProfileDirectory(mFrontHostname); + + startProfile(mBackgroundHostname); + if (QTest::currentTestFailed()) { + return; + } + mpBackgroundHost = mudlet::self()->getHostManager().getHost(mBackgroundHostname); + QVERIFY(mpBackgroundHost); + QVERIFY(mpBackgroundHost->mpConsole); + + startProfile(mFrontHostname); + if (QTest::currentTestFailed()) { + return; + } + mpFrontHost = mudlet::self()->getHostManager().getHost(mFrontHostname); + QVERIFY(mpFrontHost); + QVERIFY(mpFrontHost->mpConsole); + + QVERIFY2(mpBackgroundHost->mpConsole->isHidden(), "opening a second profile did not background the first one, so there is nothing to test here"); + } + + void cleanupTestCase() + { + delete mpServer; + mpServer = nullptr; + mpBackgroundHost = nullptr; + mpFrontHost = nullptr; + deleteProfileDirectory(mBackgroundHostname); + deleteProfileDirectory(mFrontHostname); + delete mudlet::self(); + } + + void test_backgroundProfileReportsEveryElementTypeAsVisible() + { + buildElements(mpBackgroundHost); + + for (const QString& name : elementNames()) { + assertVisibility(mpBackgroundHost, name, true, qsl("%1 of a backgrounded profile").arg(name)); + } + + // a hidden user window still has to take its children with it + QVERIFY(mpBackgroundHost->hideWindow(mUserWindowName)); + assertVisibility(mpBackgroundHost, mUserWindowName, false, qsl("a hidden user window")); + assertVisibility(mpBackgroundHost, mChildLabelName, false, qsl("a child of a hidden user window")); + + QVERIFY(mpBackgroundHost->hideWindow(mLabelName)); + assertVisibility(mpBackgroundHost, mLabelName, false, qsl("a label hidden on a backgrounded profile")); + QVERIFY(mpBackgroundHost->showWindow(mLabelName)); + assertVisibility(mpBackgroundHost, mLabelName, true, qsl("a label shown again on a backgrounded profile")); + } + + void test_frontProfileReportsEveryElementTypeAsVisible() + { + buildElements(mpFrontHost); + + for (const QString& name : elementNames()) { + assertVisibility(mpFrontHost, name, true, qsl("%1 of the front profile").arg(name)); + } + + QVERIFY(mpFrontHost->hideWindow(mLabelName)); + assertVisibility(mpFrontHost, mLabelName, false, qsl("a label hidden on the front profile")); + } + + void test_mainWindowAnswersBothOfItsNames() + { + for (const QString& name : {qsl("main"), QString()}) { + const auto geometry = mpFrontHost->windowGeometry(name); + QVERIFY2(geometry.has_value(), qPrintable(qsl("getWindowGeometry(\"%1\") did not recognise the main window").arg(name))); + QCOMPARE(geometry->topLeft(), QPoint(0, 0)); + QCOMPARE(geometry->size(), mpFrontHost->mpConsole->getMainWindowSize()); + QVERIFY2(geometry->width() > 0 && geometry->height() > 0, qPrintable(qsl("the main window reported an empty geometry: %1x%2").arg(geometry->width()).arg(geometry->height()))); + + assertVisibility(mpFrontHost, name, true, qsl("the front profile's main window")); + } + } + + void test_backgroundProfileAnswersForItsOwnMainWindow() + { + assertVisibility(mpBackgroundHost, qsl("main"), true, qsl("a backgrounded profile's main window")); + + // getMainWindowSize() falls back to a cached size while the console is hidden + const auto geometry = mpBackgroundHost->windowGeometry(qsl("main")); + QVERIFY(geometry.has_value()); + QVERIFY2(geometry->width() > 0 && geometry->height() > 0, + qPrintable(qsl("a backgrounded profile's main window reported an empty geometry: %1x%2").arg(geometry->width()).arg(geometry->height()))); + } + +private: + QStringList elementNames() const { return {mLabelName, mConsoleName, mScrollBoxName, mCmdLineName, mTextEditName, mUserWindowName, mChildLabelName}; } + + // built through the Lua API so each profile's own interpreter creates them + void buildElements(Host* pHost) const + { + pHost->getLuaInterpreter()->compileAndExecuteScript(qsl("createLabel('%1', 0, 0, 50, 50, 1)\n" + "createMiniConsole('%2', 0, 60, 100, 50)\n" + "createScrollBox('%3', 0, 120, 100, 50)\n" + "createCommandLine('%4', 0, 180, 100, 30)\n" + "createTextEdit('%5', 0, 220, 100, 50)\n" + "openUserWindow('%6')\n" + "createLabel('%6', '%7', 5, 5, 40, 20, 1)") + .arg(mLabelName, mConsoleName, mScrollBoxName, mCmdLineName, mTextEditName, mUserWindowName, mChildLabelName)); + } + + void assertVisibility(Host* pHost, const QString& name, const bool expected, const QString& what) const + { + const auto visible = pHost->windowVisible(name); + QVERIFY2(visible.has_value(), qPrintable(qsl("windowVisible() reported %1 as not found").arg(what))); + QVERIFY2(*visible == expected, qPrintable(qsl("windowVisible() reported %1 as %2").arg(what, *visible ? qsl("visible") : qsl("hidden")))); + } + + void startProfile(const QString& hostname) + { + const QString address = mLocalhost; + const QString port = mPort; + QTimer::singleShot(0ms, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(5000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy connectionSpy(&(host->mTelnet), &cTelnet::signal_connected); + if (!connectionSpy.wait(2000)) { + QFAIL("Could not connect with the host."); + } + } + + void deleteProfileDirectory(const QString& profileName) + { + QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName)); + if (dir.exists()) { + dir.removeRecursively(); + } + } +}; + +void initializeQRCResourcesForWindowStateGettersTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "WindowStateGettersTest.moc" +QTEST_MAIN(WindowStateGettersTest) diff --git a/test/functional_tests/XMLexportVariablesTest.cpp b/test/functional_tests/XMLexportVariablesTest.cpp new file mode 100644 index 000000000..8a2cdb9c4 --- /dev/null +++ b/test/functional_tests/XMLexportVariablesTest.cpp @@ -0,0 +1,634 @@ +/*************************************************************************** + * Copyright (C) 2026 by Mudlet Developers * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Tests for XMLexport::writeVariablePackage(): variables created after the + * VarUnit tree was last built (e.g. by scripts at runtime) must still be + * written to the profile XML when they are marked as saved. The tree is + * only (re)built at profile load and when the Variables view is populated, + * so without a refresh at export time such variables silently vanish from + * profile saves. Also covers members a script adds to a saved table at + * runtime: they have no savedVars entry of their own but must be saved with + * the table (issue #9517), while hidden and unsaveable members must not be. + * + * Run with: ctest -R XMLexportVariablesTest -V + */ + +#include <QtTest/QtTest> + +#include "Host.h" +#include "LuaInterface.h" +#include "MudletInstanceCoordinator.h" +#include "TelnetServerStub.h" +#include "VarUnit.h" +#include "XMLexport.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "dlgTriggerEditor.h" +#include "mudlet.h" + +#include <QTreeWidget> + +extern "C" { +#if defined(INCLUDE_VERSIONED_LUA_HEADERS) +#include <lua5.1/lauxlib.h> +#include <lua5.1/lua.h> +#include <lua5.1/lualib.h> +#else +#include <lauxlib.h> +#include <lua.h> +#include <lualib.h> +#endif +} + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForXMLexportVariablesTest(); + +class XMLexportVariablesTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + dlgTriggerEditor* mpEditor = nullptr; + const QString mHostname = "XMLexportVars-Test"; + const QString mLocalhost = "localhost"; + +private slots: + void initTestCase() + { + initializeQRCResourcesForXMLexportVariablesTest(); + + mpServer = new TelnetServerStub(qApp); + // port 0 asks the OS for an ephemeral port, so parallel test runs + // (and other worktrees) cannot collide on a fixed one + mpServer->start(mLocalhost, 0); + QVERIFY2(mpServer->serverPort() != 0, "TelnetServerStub failed to bind a loopback port"); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + deleteProfileDirectory(mHostname); + + startProfile(mHostname, mLocalhost, QString::number(mpServer->serverPort())); + mpHost = mudlet::self()->getActiveHost(); + QVERIFY2(mpHost, "No active host after profile creation"); + } + + void cleanupTestCase() + { + mpEditor = nullptr; + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + deleteProfileDirectory(mHostname); + delete mudlet::self(); + } + + // A saved variable whose Lua value only comes into existence after the + // variable tree was last built (profile load, Variables view opening) + // must still be written out - the save path has to refresh the tree. + void test_lateCreatedSavedVariableIsExported() + { + // QTest runs slots in declaration order and these stand for a profile + // whose Variables view was never opened. Profile load builds the editor + // dialog itself, so what matters is that no slot has shown it yet. + QVERIFY2(!mpEditor, "a Variables-view test was declared before the ones that must run without it"); + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + // build the tree directly, standing in for the initial build that + // profile load performs (via Host::hideMudletsVariables()) + lI->getVars(false); + QVERIFY(vu->getBase()); + + // a script creates the variable after that; we mark its name as saved + // to emulate a variable persisted in a previous session (savedVars is + // name-keyed and persistent, so it survives a tree rebuild) + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "lateSavedTestVar = 'created after tree build'"), 0); + vu->savedVars.insert(qsl("lateSavedTestVar")); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("lateSavedTestVar")), + "saved variable created after the last variable-tree build should " + "still be exported to the profile XML"); + // the value is the payload of the save - make sure it is written, not + // just an empty node with the right name + QVERIFY2(xml.contains(qsl("created after tree build")), "the saved variable's value must be exported, not just its name"); + + // mpHost is shared across the tests, so undo the state this one added + vu->savedVars.remove(qsl("lateSavedTestVar")); + QCOMPARE(luaL_dostring(L, "lateSavedTestVar = nil"), 0); + } + + // The export-time refresh must not start saving variables that are not + // marked as saved. + void test_lateUnsavedVariableIsNotExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + lI->getVars(false); + + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "lateUnsavedTestVar = 'not marked saved'"), 0); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(!xml.contains(qsl("lateUnsavedTestVar")), "a variable not marked as saved must not be exported"); + } + + // A member a script adds to a saved table at runtime has no savedVars + // entry of its own, but must still be saved with the table (issue #9517). + void test_runtimeAddedMemberOfSavedTableIsExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "memberTestTable = {existing = 'existing member value'}"), 0); + // ticking a table in the Variables view registers the table and the + // members that exist at that moment + vu->savedVars.insert(qsl("memberTestTable")); + vu->savedVars.insert(qsl("memberTestTable.existing")); + lI->getVars(false); + + // a script adds another member after that + QCOMPARE(luaL_dostring(L, "memberTestTable.newcomer = 'runtime member value'"), 0); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("existing member value")), "member registered when the table was ticked must still be exported"); + QVERIFY2(xml.contains(qsl("runtime member value")), "member added to a saved table at runtime must be saved with the table"); + + vu->savedVars.remove(qsl("memberTestTable")); + vu->savedVars.remove(qsl("memberTestTable.existing")); + QCOMPARE(luaL_dostring(L, "memberTestTable = nil"), 0); + } + + // A nested table assigned into a saved table at runtime must be exported + // recursively, right down to its innermost members. + void test_nestedTableAddedToSavedTableIsExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "nestedTestTable = {}"), 0); + vu->savedVars.insert(qsl("nestedTestTable")); + lI->getVars(false); + + QCOMPARE(luaL_dostring(L, "nestedTestTable.inner = {deepest = 'nested member value'}"), 0); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("nested member value")), "members of a nested table added to a saved table at runtime must be exported"); + + vu->savedVars.remove(qsl("nestedTestTable")); + QCOMPARE(luaL_dostring(L, "nestedTestTable = nil"), 0); + } + + // The most common shape of issue #9517: a list-style table grown with + // table.insert at runtime. The numeric key must keep its key type so + // import restores t[1] and not t["1"]. + void test_numericKeyMemberAddedAtRuntimeIsExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "numericListTable = {}"), 0); + vu->savedVars.insert(qsl("numericListTable")); + lI->getVars(false); + + QCOMPARE(luaL_dostring(L, "table.insert(numericListTable, 'numeric member value')"), 0); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("numeric member value")), "a numeric-keyed member added at runtime must be saved with its table"); + // LUA_TNUMBER == 3: the key type decides whether import restores t[1] or t["1"] + QVERIFY2(xml.contains(qsl("<keyType>3</keyType>")), "the numeric member's key type must be numeric so import restores t[1], not t['1']"); + + vu->savedVars.remove(qsl("numericListTable")); + QCOMPARE(luaL_dostring(L, "numericListTable = nil"), 0); + } + + // Design pin: un-ticking a single member in the Variables view only + // removes its name from savedVars, which cannot be told apart from a + // member added after the table was ticked. A saved table therefore + // exports its members as they exist at save time; to keep a member out + // of the profile, hide it, remove it, or stop saving the table. + void test_untickedMemberOfSavedTableStillExports() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "untickedMemberTable = {kept = 'kept member value', unticked = 'unticked member value'}"), 0); + // ticking the table registers it and both members... + vu->savedVars.insert(qsl("untickedMemberTable")); + vu->savedVars.insert(qsl("untickedMemberTable.kept")); + vu->savedVars.insert(qsl("untickedMemberTable.unticked")); + // ...and un-ticking one member only removes its name again + vu->savedVars.remove(qsl("untickedMemberTable.unticked")); + lI->getVars(false); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("kept member value")), "a ticked member of a saved table must be exported"); + QVERIFY2(xml.contains(qsl("unticked member value")), "a saved table exports members as they exist at save time, so an un-ticked member rides along"); + + vu->savedVars.remove(qsl("untickedMemberTable")); + vu->savedVars.remove(qsl("untickedMemberTable.kept")); + QCOMPARE(luaL_dostring(L, "untickedMemberTable = nil"), 0); + } + + // A member table beyond the 10,000-item save limit must not ride along - + // it would bloat every profile save. + void test_oversizedMemberTableIsNotExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, + "oversizedHolderTable = {smallMember = 'small member value', bigMember = {}} " + "for i = 1, 10001 do oversizedHolderTable.bigMember[i] = 'oversized member value' end"), + 0); + vu->savedVars.insert(qsl("oversizedHolderTable")); + lI->getVars(false); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("small member value")), "a plain member of a saved table must be exported"); + QVERIFY2(!xml.contains(qsl("oversized member value")), "a member table over the 10,000-item limit must not ride along with its saved table"); + + vu->savedVars.remove(qsl("oversizedHolderTable")); + QCOMPARE(luaL_dostring(L, "oversizedHolderTable = nil"), 0); + } + + // A member whose key is a reference (e.g. a table used as a key) cannot + // be restored from XML and must not ride along. + void test_referenceKeyMemberIsNotExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "referenceKeyTable = {plainMember = 'plain member value'} referenceKeyTable[{}] = 'reference member value'"), 0); + vu->savedVars.insert(qsl("referenceKeyTable")); + lI->getVars(false); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("plain member value")), "a plain member of a saved table must be exported"); + QVERIFY2(!xml.contains(qsl("reference member value")), "a reference-keyed member must not ride along with its saved table"); + + vu->savedVars.remove(qsl("referenceKeyTable")); + QCOMPARE(luaL_dostring(L, "referenceKeyTable = nil"), 0); + } + + // Members only ride along with tables that are marked saved. + void test_memberOfUnsavedTableIsNotExported() + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "unsavedTestTable = {member = 'unsaved member value'}"), 0); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(!xml.contains(qsl("unsavedTestTable")), "a table not marked as saved must not be exported"); + QVERIFY2(!xml.contains(qsl("unsaved member value")), "members of a table not marked as saved must not be exported"); + + QCOMPARE(luaL_dostring(L, "unsavedTestTable = nil"), 0); + } + + // Hidden variables (Mudlet's internals, or ones the user hid) inside a + // saved table keep needing their own explicit save mark, so internals + // cannot leak into the profile XML through a saved parent. + void test_hiddenMemberOfSavedTableIsNotExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "hiddenMemberTable = {visibleMember = 'visible member value', secretMember = 'secret member value'}"), 0); + vu->savedVars.insert(qsl("hiddenMemberTable")); + vu->addHidden(qsl("hiddenMemberTable.secretMember")); + lI->getVars(false); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("visible member value")), "a plain member of a saved table must be exported"); + QVERIFY2(!xml.contains(qsl("secret member value")), "a hidden member must not ride along with its saved table"); + + vu->savedVars.remove(qsl("hiddenMemberTable")); + vu->removeHidden(qsl("hiddenMemberTable.secretMember")); + QCOMPARE(luaL_dostring(L, "hiddenMemberTable = nil"), 0); + } + + // A hidden member the user explicitly ticked stays exported - hiding only + // blocks the ride-along, not an explicit save mark. + void test_explicitlySavedHiddenMemberIsExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "explicitHiddenTable = {pinnedMember = 'pinned member value'}"), 0); + vu->savedVars.insert(qsl("explicitHiddenTable")); + vu->savedVars.insert(qsl("explicitHiddenTable.pinnedMember")); + vu->addHidden(qsl("explicitHiddenTable.pinnedMember")); + lI->getVars(false); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("pinned member value")), "a hidden member explicitly marked as saved must still be exported"); + + vu->savedVars.remove(qsl("explicitHiddenTable")); + vu->savedVars.remove(qsl("explicitHiddenTable.pinnedMember")); + vu->removeHidden(qsl("explicitHiddenTable.pinnedMember")); + QCOMPARE(luaL_dostring(L, "explicitHiddenTable = nil"), 0); + } + + // Function members cannot be saved, so they must not ride along either. + void test_functionMemberOfSavedTableIsNotExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "callableHolderTable = {dataMember = 'data member value', callableMember = function() end}"), 0); + vu->savedVars.insert(qsl("callableHolderTable")); + lI->getVars(false); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("data member value")), "a plain member of a saved table must be exported"); + QVERIFY2(!xml.contains(qsl("callableMember")), "a function member must not ride along with its saved table"); + + vu->savedVars.remove(qsl("callableHolderTable")); + QCOMPARE(luaL_dostring(L, "callableHolderTable = nil"), 0); + } + + // The export-time refresh must keep writing the user's hidden-variable + // preferences to the HiddenVariables node. + void test_hiddenPreferenceStillExported() + { + VarUnit* vu = mpHost->getLuaInterface()->getVarUnit(); + vu->addHidden(qsl("userHiddenPrefVar")); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("userHiddenPrefVar")), "hiddenByUser names must still be written to HiddenVariables"); + + vu->removeHidden(qsl("userHiddenPrefVar")); + } + + // VarUnit has two hidden sets: hiddenByUser, and hidden, which + // Host::hideMudletsVariables() fills with Mudlet's own Lua API. Both have to + // reach the export's tree or a saved table drags the internals into the XML. + void test_internallyHiddenMemberOfSavedTableIsNotExported() + { + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "internalHiddenTable = {plainMember = 'plain member value', internalMember = 'internal member value'}"), 0); + vu->savedVars.insert(qsl("internalHiddenTable")); + // what addHidden(TVar*, 0) records - the non-user half of the pair + vu->hidden.insert(qsl("internalHiddenTable.internalMember")); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("plain member value")), "a plain member of a saved table must be exported"); + QVERIFY2(!xml.contains(qsl("internal member value")), "a member hidden by Mudlet itself must not ride along with its saved table"); + + vu->savedVars.remove(qsl("internalHiddenTable")); + vu->hidden.remove(qsl("internalHiddenTable.internalMember")); + QCOMPARE(luaL_dostring(L, "internalHiddenTable = nil"), 0); + } + + // A variable tree takes a Lua registry reference per reference-keyed entry. + // The export throws its tree away, so if the references went with it the + // registry would grow by that many slots on every save. + void test_exportDoesNotLeakLuaRegistryReferences() + { + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + // several reference-keyed members, so a leak grows the registry visibly + QCOMPARE(luaL_dostring(L, "refKeyLeakTable = {} for i = 1, 20 do refKeyLeakTable[{}] = i end"), 0); + + // freed slots go on a free list and come straight back out, so the + // number stops climbing once the registry fits one pass's worth. + // Measuring after the first export leaves that one-off growth out. + QVERIFY(!exportProfileXml().isEmpty()); + lua_pushboolean(L, 1); + const int refAfterOne = luaL_ref(L, LUA_REGISTRYINDEX); + luaL_unref(L, LUA_REGISTRYINDEX, refAfterOne); + + for (int i = 0; i < 5; ++i) { + QVERIFY(!exportProfileXml().isEmpty()); + } + + lua_pushboolean(L, 1); + const int refAfterSix = luaL_ref(L, LUA_REGISTRYINDEX); + luaL_unref(L, LUA_REGISTRYINDEX, refAfterSix); + + // five more exports keeping 20 references each would put this 100 higher + QVERIFY2(refAfterSix < refAfterOne + 20, + qPrintable(qsl("the exports pinned Lua registry slots: a reference taken after one export was %1, one taken after six was %2").arg(refAfterOne).arg(refAfterSix))); + + QCOMPARE(luaL_dostring(L, "refKeyLeakTable = nil"), 0); + } + + // A script adds to a saved table while the editor sits on the Variables + // view. A session's last save is taken with whatever view was left on + // screen, so quitting from there is enough to reach this. + void test_savedTableMemberIsExportedWithVariablesViewOpen() + { + QVERIFY2(showEditorOnVariablesView(), "the script editor could not be opened on the Variables view"); + + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "varsViewTable = {seedMember = 'seed member value'}"), 0); + vu->savedVars.insert(qsl("varsViewTable")); + vu->savedVars.insert(qsl("varsViewTable.seedMember")); + mpEditor->repopulateVars(); + + // a script running afterwards, with the view still up + QCOMPARE(luaL_dostring(L, "varsViewTable.lateMember = 'late member value'"), 0); + QCOMPARE(luaL_dostring(L, "varsViewTable.seedMember = nil"), 0); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("late member value")), "a member added while the Variables view was open must still be saved"); + // secondary: writeVariable() re-reads values from Lua, so a stale tree + // writes this one out empty rather than with its old value + QVERIFY2(!xml.contains(qsl("seed member value")), "a member a script removed while the Variables view was open must not be saved back"); + + auto* pVariablesTree = mpEditor->findChild<QTreeWidget*>(qsl("treeWidget_variables")); + QVERIFY2(pVariablesTree, "the editor has no variables tree widget"); + QTreeWidgetItem* pBaseItem = pVariablesTree->topLevelItem(0); + QVERIFY2(pBaseItem && pBaseItem->childCount() > 0, "the Variables view did not populate"); + QVERIFY2(vu->getWVar(pBaseItem->child(0)), "a save taken with the Variables view on screen must leave its items resolving to their variables"); + + vu->savedVars.remove(qsl("varsViewTable")); + vu->savedVars.remove(qsl("varsViewTable.seedMember")); + QCOMPARE(luaL_dostring(L, "varsViewTable = nil"), 0); + } + + // ... and the same for a whole variable rather than a table member. + void test_lateSavedVariableIsExportedWithVariablesViewOpen() + { + QVERIFY2(showEditorOnVariablesView(), "the script editor could not be opened on the Variables view"); + + LuaInterface* lI = mpHost->getLuaInterface(); + VarUnit* vu = lI->getVarUnit(); + mpEditor->repopulateVars(); + + lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState(); + QCOMPARE(luaL_dostring(L, "varsViewLateVar = 'late variable value'"), 0); + vu->savedVars.insert(qsl("varsViewLateVar")); + + const QString xml = exportProfileXml(); + QVERIFY(!xml.isEmpty()); + QVERIFY2(xml.contains(qsl("late variable value")), "a saved variable created while the Variables view was open must still be saved"); + + vu->savedVars.remove(qsl("varsViewLateVar")); + QCOMPARE(luaL_dostring(L, "varsViewLateVar = nil"), 0); + } + + // The other side: a save must not pull the tree out from under the editor. + // Its tree widget and search results resolve items through VarUnit's + // item -> TVar map, which rebuilding the shared tree empties. + void test_variablesEditorItemMappingSurvivesExport() + { + QVERIFY2(showEditorOnVariablesView(), "the script editor could not be opened on the Variables view"); + mpEditor->repopulateVars(); + + VarUnit* vu = mpHost->getLuaInterface()->getVarUnit(); + auto* pVariablesTree = mpEditor->findChild<QTreeWidget*>(qsl("treeWidget_variables")); + QVERIFY2(pVariablesTree, "the editor has no variables tree widget"); + QTreeWidgetItem* pBaseItem = pVariablesTree->topLevelItem(0); + QVERIFY2(pBaseItem && pBaseItem->childCount() > 0, "the Variables view did not populate"); + QTreeWidgetItem* pVariableItem = pBaseItem->child(0); + TVar* pMappedBefore = vu->getWVar(pVariableItem); + QVERIFY2(pMappedBefore, "the Variables view's items should resolve to a variable"); + + // any save does it: the Save Profile button, the autosave, a package change + mpEditor->slot_showTriggers(); + QVERIFY(!exportProfileXml().isEmpty()); + + QVERIFY2(vu->getWVar(pVariableItem) == pMappedBefore, "a profile save must leave the Variables editor's items resolving to their variables"); + } + +private: + // Returns false rather than asserting: a QVERIFY here would only return from + // this helper, leaving the caller to dereference a null editor. + bool showEditorOnVariablesView() + { + if (!mpEditor) { + mudlet::self()->slot_showScriptDialog(); + QTest::qWait(100); + mpEditor = mpHost->mpEditorDialog; + if (!mpEditor) { + return false; + } + } + mpEditor->slot_showVariables(); + QTest::qWait(50); + return true; + } + + QString exportProfileXml() + { + const QString xmlPath = mudlet::getMudletPath(enums::profileHomePath, mHostname) + qsl("/xmlexport-test.xml"); + auto writer = std::make_shared<XMLexport>(mpHost); + if (!writer->exportPackage(xmlPath, true, false)) { + return {}; + } + QFile file(xmlPath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + const QString xml = QString::fromUtf8(file.readAll()); + file.close(); + QFile::remove(xmlPath); + return xml; + } + + void startProfile(const QString& hostname, const QString& address, const QString& port) + { + QTimer::singleShot(0, qApp, [hostname, address, port]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), hostname); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), address); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100); + QTest::keyClicks(QApplication::focusWidget(), port); + QTest::qWait(100); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(2000)) { + QFAIL("Profile took too long to load."); + } + auto host = mudlet::self()->getActiveHost(); + if (!host) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(1000)) { + QFAIL("Could not connect with the host."); + } + } + + void deleteProfileDirectory(const QString& profileName) + { + const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName); + QDir dir(path); + + if (!dir.exists()) { + return; + } + dir.removeRecursively(); + } +}; + +void initializeQRCResourcesForXMLexportVariablesTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "XMLexportVariablesTest.moc" +QTEST_MAIN(XMLexportVariablesTest) diff --git a/test/functional_tests/cTelnetBufferTest.cpp b/test/functional_tests/cTelnetBufferTest.cpp new file mode 100644 index 000000000..134a1e41d --- /dev/null +++ b/test/functional_tests/cTelnetBufferTest.cpp @@ -0,0 +1,333 @@ +/*************************************************************************** + * Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * + ***************************************************************************/ + +/* + * Tests for the off-by-one write in cTelnet::processSocketData() - + * https://github.com/Mudlet/Mudlet/issues/1065 + * + * processSocketData() used to terminate its input with + * "in_buffer[amount + 1] = '\0'", one byte further along than the data it was + * given. The socket path survived it because slot_socketReadyToBeRead() over- + * allocates its stack buffer, but the same function is also reached from Lua's + * feedTelnet() via cTelnet::loopbackTest(), which hands it a QByteArray sized + * exactly to its contents - so the stray NUL landed one byte past the end of a + * heap allocation. + * + * The discriminating tests are nulTerminatorLandsAtTheDataEnd(), its every-size + * sibling, and emptyAndErroredReadsLeaveTheBufferAlone(): a sentinel is planted + * at [amount + 1] and must still be there afterwards. Those fail on the unfixed + * code without needing a sanitizer, which matters because Windows CI builds + * without one. Note that the byte at [amount] is written by the later + * "buffer[datalen] = '\0'" too, so asserting on it only proves the call ran - + * the sentinel one byte further along is what catches the bug. + * + * Run with: ctest -R cTelnetBufferTest -V + */ + +#include <QtTest/QtTest> + +#include <QScopeGuard> + +#include <chrono> +#include <cstring> +#include <memory> + +#include "MudletInstanceCoordinator.h" +#include "TMainConsole.h" +#include "TelnetServerStub.h" +#include "ctelnet.h" +#include "dlgConnectionProfiles.h" +#include "mudlet.h" + +using namespace std::chrono_literals; + +extern void qInitResources_mudlet(); +extern void qInitResources_qm(); +extern void qInitResources_additional_splash_screens(); +extern void qInitResources_mudlet_fonts_common(); +extern void qInitResources_mudlet_fonts_posix(); +void initializeQRCResourcesForBufferTest(); + +class cTelnetBufferTest : public QObject +{ + Q_OBJECT + +private: + TelnetServerStub* mpServer = nullptr; + Host* mpHost = nullptr; + const QString mHostname = qsl("BufferTest-Host"); + QString mPort; // assigned the stub's actual ephemeral port in initTestCase() + const QString mLocalhost = qsl("localhost"); + + // The byte processSocketData() is entitled to overwrite with its NUL, and + // the one immediately after it that it must leave alone. + static constexpr char scmTerminatorSlot = '\x7b'; + static constexpr char scmPastTheEnd = '\x7c'; + + // True if any line in the main console buffer contains the given substring + bool bufferContains(const QString& text) const + { + TMainConsole* console = mpHost->mpConsole; + for (int i = 0; i <= console->buffer.getLastLineNumber(); ++i) { + if (console->buffer.line(i).contains(text)) { + return true; + } + } + return false; + } + +private slots: + void initTestCase() + { + initializeQRCResourcesForBufferTest(); + + mpServer = new TelnetServerStub(qApp); + mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs + mPort = QString::number(mpServer->serverPort()); + mudlet::start(); + mudlet::self()->setupConfig(); + mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator")); + mudlet::self()->init(); + mudlet::self()->setStorePasswordsSecurely(false); + + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + QDir(path).removeRecursively(); + + QTimer::singleShot(0ms, qApp, [this]() { + mudlet::self()->startAutoLogin({}); + QTest::qWait(100ms); + QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mHostname); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mLocalhost); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab); + QTest::qWait(100ms); + QTest::keyClicks(QApplication::focusWidget(), mPort); + QTest::qWait(100ms); + QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return); + }); + + QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded); + if (!spy.wait(1000)) { + QFAIL("Profile took too long to load."); + } + mpHost = mudlet::self()->getActiveHost(); + if (!mpHost) { + QFAIL("No active host available for the test."); + } + + QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected); + if (!spy2.wait(500)) { + QFAIL("Could not connect with the host."); + } + } + + void init() + { + QVERIFY(mpHost); + QVERIFY(mpHost->mpConsole); + mpHost->mpConsole->buffer.clear(); + // A leaked recursion level is permanent for the profile and eventually + // turns processSocketData() into a silent no-op, which would make the + // "nothing was written" assertions below pass for the wrong reason. + QCOMPARE(mpHost->mTelnet.mDecompressionRecursionDepth, 0); + } + + void cleanup() { QCOMPARE(mpHost->mTelnet.mDecompressionRecursionDepth, 0); } + + // The regression test for #1065. processSocketData() is handed `payloadSize` + // bytes inside a buffer that has two spare bytes after them. It may write + // its NUL over the first spare byte; the second must come back untouched. + void nulTerminatorLandsAtTheDataEnd() + { + constexpr int payloadSize = 8; + QByteArray backing(payloadSize + 2, '\0'); + std::memset(backing.data(), 'A', payloadSize); + backing[payloadSize] = scmTerminatorSlot; + backing[payloadSize + 1] = scmPastTheEnd; + + mpHost->mTelnet.processSocketData(backing.data(), payloadSize, true); + + QCOMPARE(backing.at(payloadSize), '\0'); + QVERIFY2(backing.at(payloadSize + 1) == scmPastTheEnd, + "processSocketData() wrote its NUL terminator one byte past the data it was given " + "- the off-by-one of issue #1065 is back."); + } + + // The same off-by-one across the sizes a read can plausibly return, so a + // future rewrite cannot reintroduce it for only some lengths. + void nulTerminatorLandsAtTheDataEndAtEverySize() + { + for (const int payloadSize : {1, 2, 4, 8, 15, 16, 31, 32, 33, 63, 64, 1024}) { + QByteArray backing(payloadSize + 2, '\0'); + std::memset(backing.data(), 'A', payloadSize); + backing[payloadSize] = scmTerminatorSlot; + backing[payloadSize + 1] = scmPastTheEnd; + + mpHost->mTelnet.processSocketData(backing.data(), payloadSize, true); + + // The terminator check proves the call actually ran, so the + // past-the-end check below cannot pass by the function bailing out. + QVERIFY2(backing.at(payloadSize) == '\0', qPrintable(qsl("processSocketData() did not terminate a %1 byte payload at all.").arg(payloadSize))); + QVERIFY2(backing.at(payloadSize + 1) == scmPastTheEnd, qPrintable(qsl("processSocketData() wrote past the end of a %1 byte payload.").arg(payloadSize))); + } + } + + // The production route from Lua: feedTelnet() -> loopbackTest() -> + // processSocketData(). loopbackTest() takes a non-const QByteArray and calls + // data(), which detaches, so the allocation shape is Qt's choice rather than + // ours - this is a "the pipeline still works" check, not a bounds check. + void feedTelnetPathDisplaysItsDataIntact() + { + QByteArray payload = QByteArrayLiteral("BUFFER_TEST_MARKER\r\n"); + payload.squeeze(); + + mpHost->mTelnet.loopbackTest(payload); + QVERIFY2(QTest::qWaitFor( + [this]() { + return bufferContains(qsl("BUFFER_TEST_MARKER")); + }, + QDeadlineTimer(5s)), + "Text fed through loopbackTest() did not reach the console."); + } + + // A closed or errored socket reports -1 and an empty read reports 0. Neither + // may touch the caller's buffer, which for amount == 0 can legitimately have + // no writable byte at all. -2 stands in for the qsizetype narrowing in + // loopbackTest(), which can produce a negative that is not -1. + void emptyAndErroredReadsLeaveTheBufferAlone() + { + for (const int amount : {0, -1, -2}) { + QByteArray backing(2, '\0'); + backing[0] = scmTerminatorSlot; + backing[1] = scmPastTheEnd; + + mpHost->mTelnet.processSocketData(backing.data(), amount, true); + + QVERIFY2(backing.at(0) == scmTerminatorSlot, qPrintable(qsl("processSocketData() wrote into the buffer for a read of %1.").arg(amount))); + QVERIFY2(backing.at(1) == scmPastTheEnd, qPrintable(qsl("processSocketData() wrote past the buffer for a read of %1.").arg(amount))); + } + } + + // Every exit from processSocketData() has to hand back the recursion level it + // took, including the one that refuses the read outright. That refusal is the + // only exit no other test here reaches, and a level leaked there would be + // permanent for the profile: once enough have piled up the connection stops + // accepting data altogether. Seeding the counter reaches the refusal without + // needing a real decompression bomb to recurse. + // + // Whether the refusal happened is read off the caller's buffer rather than + // the warning text: the refusal returns before the NUL terminator is written, + // so an untouched sentinel means the read was dropped. That also pins the + // threshold exactly, and unlike the posted message it does not depend on the + // interface language. + void recursionDepthIsHandedBackOnEveryExit() + { + constexpr int payloadSize = 12; + const int seededDepthLimit = cTelnet::scmMaxDecompressionRecursion + 3; + // A failed QVERIFY2 below aborts the slot mid-sweep, so put the counter + // back from here rather than at the end - otherwise the seeded value + // survives into cleanup() and the next slot's init(), and one real + // failure reports as three with two of them pointing at the wrong place. + const auto depthRestoreGuard = qScopeGuard([this] { + mpHost->mTelnet.mDecompressionRecursionDepth = 0; + }); + + for (int seededDepth = 0; seededDepth <= seededDepthLimit; ++seededDepth) { + // This read takes the level to seededDepth + 1, which is the value + // the cap is tested against. + const bool expectRefusal = (seededDepth + 1) > cTelnet::scmMaxDecompressionRecursion; + + // A full payload takes the ordinary fall-through exit, 0 and -1 the + // nothing-to-read one; past the cap all three take the refusal. + for (const int amount : {payloadSize, 0, -1}) { + QByteArray backing(payloadSize + 1, 'A'); + backing[payloadSize] = scmTerminatorSlot; + mpHost->mTelnet.mDecompressionRecursionDepth = seededDepth; + + mpHost->mTelnet.processSocketData(backing.data(), amount, true); + + QVERIFY2(mpHost->mTelnet.mDecompressionRecursionDepth == seededDepth, + qPrintable(qsl("processSocketData() came back from a %1 byte read at depth %2 with the depth at %3 - a recursion level was leaked.") + .arg(amount) + .arg(seededDepth) + .arg(mpHost->mTelnet.mDecompressionRecursionDepth))); + + if (amount != payloadSize) { + continue; // a non-positive read never terminates the buffer either way + } + const bool wasRefused = backing.at(payloadSize) == scmTerminatorSlot; + QVERIFY2(wasRefused == expectRefusal, + qPrintable(qsl("at depth %1 of %2 the read was %3 - the over-limit cap moved.") + .arg(seededDepth + 1) + .arg(cTelnet::scmMaxDecompressionRecursion) + .arg(wasRefused ? qsl("dropped") : qsl("processed")))); + } + } + } + + // Declared last on purpose: on the unfixed code this trips AddressSanitizer, + // which aborts the process, so anything after it would never report. The + // sentinels give it teeth on Windows too, where CI builds without ASan. + void exactlySizedHeapAllocationIsNotOverrun() + { + const QByteArray payload = QByteArrayLiteral("heap probe\r\n"); + const auto size = static_cast<int>(payload.size()); + // Exactly the shape QByteArray allocates: the data plus its terminator. + auto buffer = std::make_unique<char[]>(size + 1); + std::memcpy(buffer.get(), payload.constData(), size); + buffer[size] = scmTerminatorSlot; + + mpHost->mTelnet.processSocketData(buffer.get(), size, true); + + QCOMPARE(buffer[size], '\0'); + } + + void cleanupTestCase() + { + mpHost = nullptr; + delete mpServer; + mpServer = nullptr; + const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname); + QDir(path).removeRecursively(); + delete mudlet::self(); + } +}; + +void initializeQRCResourcesForBufferTest() +{ +#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN + qInitResources_additional_splash_screens(); +#endif +#ifdef INCLUDE_FONTS + qInitResources_mudlet_fonts_common(); +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + qInitResources_mudlet_fonts_posix(); +#endif +#endif + qInitResources_mudlet(); + qInitResources_qm(); +} + +#include "cTelnetBufferTest.moc" +QTEST_MAIN(cTelnetBufferTest) diff --git a/translations/mudlet.ts b/translations/mudlet.ts index 4d8549906..6457cbfc1 100644 --- a/translations/mudlet.ts +++ b/translations/mudlet.ts @@ -32,7 +32,7 @@ <context> <name>Discord</name> <message> - <location filename="../src/discord.cpp" line="162"/> + <location filename="../src/discord.cpp" line="165"/> <source>via Mudlet</source> <translation type="unfinished"></translation> </message> @@ -65,78 +65,78 @@ <context> <name>GMCPAuthenticator</name> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="287"/> + <location filename="../src/GMCPAuthenticator.cpp" line="294"/> <source>[ WARN ] - Could not save your sign-in for next time; you may need to sign in again.</source> <extracomment>Shown when the user opted to stay signed in but saving the sign-in token failed, so they will have to sign in again next time.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="322"/> + <location filename="../src/GMCPAuthenticator.cpp" line="329"/> <source>[ INFO ] - Resuming your %1 sign-in with the game.</source> <extracomment>Shown when Mudlet asks the game to restart the browser sign-in with the remembered provider; %1 is the provider name (e.g. Discord).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="413"/> + <location filename="../src/GMCPAuthenticator.cpp" line="420"/> <source>[ WARN ] - The game sent an invalid sign-in link; cannot continue.</source> <extracomment>Shown when the game sends a sign-in link with an unsupported or invalid address (not an http/https web link).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="426"/> + <location filename="../src/GMCPAuthenticator.cpp" line="433"/> <source>[ INFO ] - To sign in, open this link in your browser: %1</source> <extracomment>%1 is the sign-in web address the user should open in their browser to sign in.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="433"/> - <location filename="../src/GMCPAuthenticator.cpp" line="469"/> + <location filename="../src/GMCPAuthenticator.cpp" line="440"/> + <location filename="../src/GMCPAuthenticator.cpp" line="476"/> <source>[ WARN ] - Could not open your browser. Open this link manually to sign in: %1</source> <extracomment>%1 is the sign-in web address the user should open manually in their browser.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="443"/> + <location filename="../src/GMCPAuthenticator.cpp" line="450"/> <source>[ INFO ] - Opening your browser to sign in. Complete the login there, then return here.</source> <extracomment>Shown after the user's browser is launched to complete an OAuth/web sign-in. %1 is the provider name (e.g. Discord).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="444"/> + <location filename="../src/GMCPAuthenticator.cpp" line="451"/> <source>[ INFO ] - Opening your browser to sign in with %1. Complete the login there, then return here.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="475"/> + <location filename="../src/GMCPAuthenticator.cpp" line="482"/> <source>[ WARN ] - The browser sign-in could not be completed; reconnect to try again.</source> <extracomment>Shown when a browser-based sign-in with the game's own account could not be completed.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="504"/> + <location filename="../src/GMCPAuthenticator.cpp" line="511"/> <source>[ WARN ] - Cannot complete the sign-in because the connection is not encrypted.</source> <extracomment>Shown when a browser sign-in finished but the game connection is not encrypted, so completing it would be unsafe.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="608"/> + <location filename="../src/GMCPAuthenticator.cpp" line="615"/> <source>[ WARN ] - Could not log in to the game, is the login information correct?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="611"/> + <location filename="../src/GMCPAuthenticator.cpp" line="618"/> <source>[ WARN ] - Could not log in to the game: %1</source> <extracomment>%1 shows the reason for failure, could be authentication, etc.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="689"/> + <location filename="../src/GMCPAuthenticator.cpp" line="749"/> <source>[ INFO ] - Your saved sign-in has expired; reconnecting so you can sign in again.</source> <extracomment>Shown when a saved password-less sign-in is no longer accepted; Mudlet reconnects so the user can sign in again.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/GMCPAuthenticator.cpp" line="784"/> + <location filename="../src/GMCPAuthenticator.cpp" line="844"/> <source>[ INFO ] - You'll be signed in automatically next time. Manage this under Preferences, Connection.</source> <extracomment>Shown once after a browser/OAuth sign-in whose reconnect token was saved, so future connects need no sign-in.</extracomment> <translation type="unfinished"></translation> @@ -145,104 +145,104 @@ <context> <name>Host</name> <message> - <location filename="../src/Host.cpp" line="377"/> + <location filename="../src/Host.cpp" line="387"/> <source>Text to send to the game</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="473"/> + <location filename="../src/Host.cpp" line="485"/> <source>[ ALERT ] - This profile will now save and close.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="746"/> + <location filename="../src/Host.cpp" line="803"/> <source>Failed to open xml file "%1" inside module %2 to update it. Error message was: "%3".</source> <extracomment>This error message will appear when the xml file inside the module zip cannot be updated for some reason.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="759"/> + <location filename="../src/Host.cpp" line="816"/> <source>Failed to save "%1" to module "%2". Error message was: "%3".</source> <extracomment>This error message will appear when a module is saved as package but cannot be done for some reason.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="972"/> + <location filename="../src/Host.cpp" line="1051"/> <source>the profile is no longer available</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="1121"/> + <location filename="../src/Host.cpp" line="1238"/> <source>[ OK ] - %1 Thanks a lot for using the Public Test Build!</source> <comment>%1 will be a random happy emoji</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="1122"/> + <location filename="../src/Host.cpp" line="1239"/> <source>[ OK ] - %1 Help us make Mudlet better by reporting any problems.</source> <comment>%1 will be a random happy emoji</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="1926"/> + <location filename="../src/Host.cpp" line="2072"/> <source>[ ERROR ] - Package install failed for "%1": %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="1998"/> + <location filename="../src/Host.cpp" line="2141"/> <source>Module "%1" is already installed. Please uninstall it first or choose a different name.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="2044"/> + <location filename="../src/Host.cpp" line="2180"/> <source>Unpacking module: "%1" please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="2046"/> + <location filename="../src/Host.cpp" line="2180"/> <source>Unpacking package: "%1" please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="2050"/> + <location filename="../src/Host.cpp" line="2181"/> <source>Unpacking</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="2123"/> - <location filename="../src/Host.cpp" line="2150"/> + <location filename="../src/Host.cpp" line="2256"/> + <location filename="../src/Host.cpp" line="2308"/> <source>[ WARN ] - Failed to load module "%1": %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="2913"/> + <location filename="../src/Host.cpp" line="3068"/> <source>Playing %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="2918"/> - <location filename="../src/Host.cpp" line="2927"/> + <location filename="../src/Host.cpp" line="3073"/> + <location filename="../src/Host.cpp" line="3082"/> <source>%1 at %2:%3</source> <extracomment>%1 is the game name and %2:%3 is game server address like: mudlet.org:23</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="3421"/> - <location filename="../src/Host.cpp" line="4655"/> + <location filename="../src/Host.cpp" line="3596"/> + <location filename="../src/Host.cpp" line="4871"/> <source>Map - %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="4667"/> + <location filename="../src/Host.cpp" line="4882"/> <source>Pre-Map loading(3) report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/Host.cpp" line="4677"/> + <location filename="../src/Host.cpp" line="4892"/> <source>Loading map(3) at %1 report</source> <translation type="unfinished"></translation> </message> @@ -250,13 +250,13 @@ please wait...</source> <context> <name>KeyUnit</name> <message> - <location filename="../src/KeyUnit.cpp" line="409"/> + <location filename="../src/KeyUnit.cpp" line="435"/> <source>no key chosen</source> <extracomment>Displayed when no key binding has been set</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/KeyUnit.cpp" line="416"/> + <location filename="../src/KeyUnit.cpp" line="442"/> <source>%1undefined key (code: 0x%2)</source> <comment>%1 is a string describing the modifier keys (e.g. "shift" or "control") used with the key, whose 'code' number, in %2 is not one that we have a name for. This is probably one of those extra keys around the edge of the keyboard that some people have.</comment> <translation type="unfinished"></translation> @@ -292,165 +292,165 @@ please wait...</source> <context> <name>MMCPClient</name> <message> - <location filename="../src/MMCPClient.cpp" line="121"/> + <location filename="../src/MMCPClient.cpp" line="124"/> <source>[ CHAT ] - Waiting for response from %1:%2...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="140"/> + <location filename="../src/MMCPClient.cpp" line="143"/> <source>[ CHAT ] - You are now disconnected from <unknown> - %1:%2.</source> <extracomment>This message is used when a MMCP peer without a name disconnects, * %1 is the peer's IP address (numbers or URL), %2 is the port they are * listening on. Should be similiar to the one when we do have a name.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="146"/> + <location filename="../src/MMCPClient.cpp" line="149"/> <source>[ CHAT ] - You are now disconnected from %1 - %2:%3.</source> <extracomment>This message is used when a MMCP peer with a name disconnects, * %1 is the peer's name, %2 is the peer's IP address (numbers or URL), * %3 is the port they are listening on. Should be similiar to the one when * we do not have a name.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="168"/> + <location filename="../src/MMCPClient.cpp" line="171"/> <source>[ CHAT ] - Connection from %1 at %2:%3 timed out (not accepted or denied by you).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="216"/> + <location filename="../src/MMCPClient.cpp" line="219"/> <source>[ CHAT ] - Connection from %1 at %2:%3 denied (Peer name too long (64 chars max)).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="248"/> + <location filename="../src/MMCPClient.cpp" line="251"/> <source>[ CHAT ] - Connection from %1 at %2:%3 denied (DoNotDisturb).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="275"/> + <location filename="../src/MMCPClient.cpp" line="278"/> <source>[ CHAT ] - Connection from %1 at %2:%3 is pending, use mmcp.accept(%4) or mmcp.deny(%4) to accept or deny.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="308"/> + <location filename="../src/MMCPClient.cpp" line="311"/> <source>[ CHAT ] - Connection to %1:%2 refused.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="318"/> + <location filename="../src/MMCPClient.cpp" line="321"/> <source>[ CHAT ] - Connection to %1 at %2:%3 rejected.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="332"/> + <location filename="../src/MMCPClient.cpp" line="335"/> <source>[ CHAT ] - Connection to %1 at %2:%3 accepted.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="384"/> + <location filename="../src/MMCPClient.cpp" line="387"/> <source>[ CHAT ] - Connection from %1 at %2:%3 accepted.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="406"/> + <location filename="../src/MMCPClient.cpp" line="409"/> <source>[ CHAT ] - Connection from %1 at %2:%3 denied.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="423"/> + <location filename="../src/MMCPClient.cpp" line="426"/> <source>[ CHAT ] - The peer closed or refused the connection.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="426"/> + <location filename="../src/MMCPClient.cpp" line="429"/> <source>[ CHAT ] - The peer was not found. Please check the host name and port settings.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="429"/> + <location filename="../src/MMCPClient.cpp" line="432"/> <source>[ CHAT ] - The connection was refused by the peer.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="432"/> + <location filename="../src/MMCPClient.cpp" line="435"/> <source>[ CHAT ] - The following error occurred: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="468"/> + <location filename="../src/MMCPClient.cpp" line="471"/> <source>[ CHAT ] - Pinging %1...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="482"/> + <location filename="../src/MMCPClient.cpp" line="485"/> <source>[ CHAT ] - Attempting to peek at %1's public connections...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="496"/> + <location filename="../src/MMCPClient.cpp" line="499"/> <source>[ CHAT ] - Requested connections from %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="685"/> + <location filename="../src/MMCPClient.cpp" line="688"/> <source>[ CHAT ] - Badly formatted connection list from %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="695"/> + <location filename="../src/MMCPClient.cpp" line="698"/> <source>[ CHAT ] - Error parsing host value from connection: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="700"/> + <location filename="../src/MMCPClient.cpp" line="703"/> <source>[ CHAT ] - Attempting to connect to %1:%2 provided by %3</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="714"/> + <location filename="../src/MMCPClient.cpp" line="717"/> <source>[ CHAT ] - %1 is trying to request your connections!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="720"/> + <location filename="../src/MMCPClient.cpp" line="723"/> <source>[ CHAT ] - %1 has requested your public connections...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="725"/> + <location filename="../src/MMCPClient.cpp" line="728"/> <source>[ CHAT ] - %1 has requested your public connections, but you're ignoring connection requests...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="772"/> + <location filename="../src/MMCPClient.cpp" line="775"/> <source>%1%2%3%4(%5)%1%2%6%1</source> <extracomment>Incoming group message, %1, %2 and %4 are ANSI Escape codes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="788"/> + <location filename="../src/MMCPClient.cpp" line="791"/> <source>[ CHAT ] - %1 is now known as %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="800"/> + <location filename="../src/MMCPClient.cpp" line="803"/> <source>[ CHAT ] - %1 is trying to peek your connections!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="806"/> + <location filename="../src/MMCPClient.cpp" line="809"/> <source>[ CHAT ] - %1 is peeking at your connections...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="811"/> + <location filename="../src/MMCPClient.cpp" line="814"/> <source>[ CHAT ] - %1 is trying to peek your connections, but you're ignoring peek requests...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="829"/> + <location filename="../src/MMCPClient.cpp" line="832"/> <source>[ CHAT ] - Badly formatted peek list from %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="850"/> + <location filename="../src/MMCPClient.cpp" line="853"/> <source>Id Name Address Port ==== ==================== =============== ===== %1 @@ -459,27 +459,27 @@ please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="879"/> + <location filename="../src/MMCPClient.cpp" line="882"/> <source>[ CHAT ] - Ping returned from %1: %2 ms</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="882"/> + <location filename="../src/MMCPClient.cpp" line="885"/> <source>[ CHAT ] - Bad Ping response from %1: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="900"/> + <location filename="../src/MMCPClient.cpp" line="903"/> <source>[ CHAT ] - %1 tried to snoop you but doesn't have permission.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="909"/> + <location filename="../src/MMCPClient.cpp" line="912"/> <source>[ CHAT ] - %1 has stopped snooping you.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/MMCPClient.cpp" line="917"/> + <location filename="../src/MMCPClient.cpp" line="920"/> <source>[ CHAT ] - %1 has begun snooping you.</source> <translation type="unfinished"></translation> </message> @@ -772,19 +772,19 @@ This text is shown when room(s) are (not) selected in mapper. %1 is the room ID <context> <name>ModernGLWidget</name> <message> - <location filename="../src/modern_glwidget.cpp" line="254"/> + <location filename="../src/modern_glwidget.cpp" line="256"/> <source>No rooms in the map - load another one, or start mapping from scratch to begin.</source> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/modern_glwidget.cpp" line="256"/> + <location filename="../src/modern_glwidget.cpp" line="258"/> <source>You have a map loaded (%n room(s)), but Mudlet does not know where you are at the moment.</source> <translation type="unfinished"> <numerusform></numerusform> </translation> </message> <message> - <location filename="../src/modern_glwidget.cpp" line="259"/> + <location filename="../src/modern_glwidget.cpp" line="261"/> <source>You do not have a map yet - load one, or start mapping from scratch to begin.</source> <translation type="unfinished"></translation> </message> @@ -1179,7 +1179,7 @@ This text is shown when room(s) are (not) selected in mapper. %1 is the room ID <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="1794"/> + <location filename="../src/TBuffer.cpp" line="1808"/> <source>[ INFO ] - This game seems to wrap its own lines at %1 characters, which makes triggers awkward to write. Mudlet can undo that, so that triggers always see whole lines and wrapping follows your window size instead:</source> @@ -1187,46 +1187,46 @@ always see whole lines and wrapping follows your window size instead:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="1799"/> + <location filename="../src/TBuffer.cpp" line="1813"/> <source>Done - Mudlet now undoes the game's wrapping, and triggers see whole lines.</source> <extracomment>Confirmation shown after the player clicks the link that enables undoing the game's own line wrapping</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="1805"/> + <location filename="../src/TBuffer.cpp" line="1819"/> <source>Turn on "Undo the game's own wrapping" - also found in the settings under Main display</source> <extracomment>Tooltip on the link that enables the option to undo the game's own line wrapping</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="1807"/> + <location filename="../src/TBuffer.cpp" line="1821"/> <source> ➜ Click here to turn that on now</source> <extracomment>Clickable link shown in the main window when a game that wraps its own lines is detected</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="3366"/> + <location filename="../src/TBuffer.cpp" line="3409"/> <source>Send</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="3370"/> + <location filename="../src/TBuffer.cpp" line="3413"/> <source>Prompt</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="3377"/> + <location filename="../src/TBuffer.cpp" line="3420"/> <source>Open browser to</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="3436"/> + <location filename="../src/TBuffer.cpp" line="3479"/> <source>Right-click for menu</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TBuffer.cpp" line="3904"/> - <location filename="../src/TBuffer.cpp" line="7373"/> + <location filename="../src/TBuffer.cpp" line="3947"/> + <location filename="../src/TBuffer.cpp" line="7462"/> <source>Click to reveal</source> <translation type="unfinished"></translation> </message> @@ -1477,7 +1477,7 @@ always see whole lines and wrapping follows your window size instead:</source> </message> <message> <location filename="../src/EditorModifyPropertyCommand.cpp" line="284"/> - <source>modify button "%1"</source> + <source>modify button/menu/toolbar "%1"</source> <extracomment>Undo/redo menu text for modifying a button's properties</extracomment> <translation type="unfinished"></translation> </message> @@ -1620,25 +1620,25 @@ always see whole lines and wrapping follows your window size instead:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TKey.cpp" line="193"/> + <location filename="../src/TKey.cpp" line="225"/> <source>No key binding set. Click "Grab New Key" to assign one.</source> <extracomment>Error shown in the editor when a key item has no key binding assigned</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="933"/> + <location filename="../src/main.cpp" line="935"/> <source>Telnet Protocol Handler</source> <extracomment>Title for the dialog asking if Mudlet should handle telnet:// and telnets:// links</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="935"/> + <location filename="../src/main.cpp" line="937"/> <source>Another application is set to handle telnet:// and telnets:// links.</source> <extracomment>Text shown when another application is already handling telnet:// and telnets:// links</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="937"/> + <location filename="../src/main.cpp" line="939"/> <source>Would you like Mudlet to handle telnet:// and telnets:// links instead? This will allow you to click on telnet:// and telnets:// links in your browser to automatically open them in Mudlet. @@ -1648,7 +1648,7 @@ You can change this later in Settings > General.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="946"/> + <location filename="../src/main.cpp" line="948"/> <source>Don't ask again</source> <extracomment>Checkbox on the telnet handler prompt that suppresses future prompts</extracomment> <translation type="unfinished"></translation> @@ -1852,7 +1852,7 @@ You can change this later in Settings > General.</source> </message> <message> <location filename="../src/RoomContextMenuHandler.cpp" line="250"/> - <location filename="../src/T2DMap.cpp" line="5312"/> + <location filename="../src/T2DMap.cpp" line="5318"/> <source>Delete</source> <extracomment>2D Mapper context menu (room) item ---------- @@ -2033,31 +2033,31 @@ You can change this later in Settings > General.</source> </message> <message> <location filename="../src/T2DMap.cpp" line="4232"/> - <location filename="../src/T2DMap.cpp" line="5919"/> + <location filename="../src/T2DMap.cpp" line="5925"/> <source>Solid line</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/T2DMap.cpp" line="4233"/> - <location filename="../src/T2DMap.cpp" line="5920"/> + <location filename="../src/T2DMap.cpp" line="5926"/> <source>Dot line</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/T2DMap.cpp" line="4234"/> - <location filename="../src/T2DMap.cpp" line="5921"/> + <location filename="../src/T2DMap.cpp" line="5927"/> <source>Dash line</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/T2DMap.cpp" line="4235"/> - <location filename="../src/T2DMap.cpp" line="5922"/> + <location filename="../src/T2DMap.cpp" line="5928"/> <source>Dash-dot line</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/T2DMap.cpp" line="4236"/> - <location filename="../src/T2DMap.cpp" line="5923"/> + <location filename="../src/T2DMap.cpp" line="5929"/> <source>Dash-dot-dot line</source> <translation type="unfinished"></translation> </message> @@ -2068,120 +2068,120 @@ You can change this later in Settings > General.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4658"/> + <location filename="../src/T2DMap.cpp" line="4664"/> <source>Move the selection, centered on the highlighted room (%1) to:</source> <comment>%1 is a room number</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4664"/> + <location filename="../src/T2DMap.cpp" line="4670"/> <source>x coordinate (was %1):</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4665"/> + <location filename="../src/T2DMap.cpp" line="4671"/> <source>y coordinate (was %1):</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4666"/> + <location filename="../src/T2DMap.cpp" line="4672"/> <source>z coordinate (was %1):</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4682"/> + <location filename="../src/T2DMap.cpp" line="4688"/> <source>OK</source> <extracomment>dialog (room(s) move) button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4688"/> + <location filename="../src/T2DMap.cpp" line="4694"/> <source>Cancel</source> <extracomment>dialog (room(s) move) button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4737"/> + <location filename="../src/T2DMap.cpp" line="4743"/> <source>Click to finish moving the selected room(s).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5216"/> + <location filename="../src/T2DMap.cpp" line="5222"/> <source>[ ERROR ] - Unable to add "%1" as an area to the map. See the "[MAP ERROR:]" message for the reason.</source> <comment>The '[MAP ERROR:]' text here should be the same as that used for the translation of "[MAP ERROR:] %1" in the 'TMap::logError(...)' function.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5272"/> + <location filename="../src/T2DMap.cpp" line="5278"/> <source>Configure Areas</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5308"/> + <location filename="../src/T2DMap.cpp" line="5314"/> <source>Create</source> <extracomment>"Configure Areas" buttons: create new area</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5310"/> + <location filename="../src/T2DMap.cpp" line="5316"/> <source>Rename</source> <extracomment>"Configure Areas" buttons: rename existing area</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5314"/> + <location filename="../src/T2DMap.cpp" line="5320"/> <source>Close</source> <extracomment>"Configure Areas" buttons: close the dialog</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5356"/> + <location filename="../src/T2DMap.cpp" line="5362"/> <source>Rename area</source> <extracomment>Dialog title for renaming an area</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5356"/> + <location filename="../src/T2DMap.cpp" line="5362"/> <source>New name:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5364"/> + <location filename="../src/T2DMap.cpp" line="5370"/> <source>Rename failed</source> <extracomment>Warning message shown when renaming an area fails.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5364"/> + <location filename="../src/T2DMap.cpp" line="5370"/> <source>Unable to rename area. Name may be invalid or already in use.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5389"/> + <location filename="../src/T2DMap.cpp" line="5395"/> <source>Create area</source> <extracomment>Dialog title for creating a new area</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5389"/> + <location filename="../src/T2DMap.cpp" line="5395"/> <source>Name:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5401"/> + <location filename="../src/T2DMap.cpp" line="5407"/> <source>Create failed</source> <extracomment>Warning message shown when creating a new area fails.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5401"/> + <location filename="../src/T2DMap.cpp" line="5407"/> <source>Unable to create area. Name may be invalid or already in use.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5436"/> - <location filename="../src/T2DMap.cpp" line="5444"/> + <location filename="../src/T2DMap.cpp" line="5442"/> + <location filename="../src/T2DMap.cpp" line="5450"/> <source>Delete failed</source> <extracomment>Warning message shown when trying to delete the default area. ---------- @@ -2189,50 +2189,50 @@ Warning message shown when trying to delete an area fails.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5436"/> + <location filename="../src/T2DMap.cpp" line="5442"/> <source>The default area cannot be deleted.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5444"/> + <location filename="../src/T2DMap.cpp" line="5450"/> <source>Unable to delete area.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="6071"/> - <location filename="../src/T2DMap.cpp" line="6105"/> + <location filename="../src/T2DMap.cpp" line="6077"/> + <location filename="../src/T2DMap.cpp" line="6111"/> <source>Left-click to add point, right-click to undo/change/finish...</source> <extracomment>2D Mapper big, bottom of screen help message</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="6116"/> + <location filename="../src/T2DMap.cpp" line="6122"/> <source>Left-click and drag a square for the size and position of your label</source> <extracomment>2D Mapper big, bottom of screen help message</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="6989"/> + <location filename="../src/T2DMap.cpp" line="6995"/> <source>[MAP]: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="7017"/> + <location filename="../src/T2DMap.cpp" line="7023"/> <source>Unknown Area</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="7036"/> + <location filename="../src/T2DMap.cpp" line="7042"/> <source>Export Area %1 to Image</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="7036"/> + <location filename="../src/T2DMap.cpp" line="7042"/> <source>Image Files (*.png *.jpg *.jpeg *.bmp *.tiff);;All Files (*)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="7049"/> + <location filename="../src/T2DMap.cpp" line="7055"/> <source>[MAP]: Export failed - %1</source> <translation type="unfinished"></translation> </message> @@ -2275,12 +2275,12 @@ Warning message shown when trying to delete an area fails.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4964"/> + <location filename="../src/T2DMap.cpp" line="4970"/> <source>Spread out rooms</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="4965"/> + <location filename="../src/T2DMap.cpp" line="4971"/> <source>Increase the spacing of the selected rooms, centered on the @@ -2289,12 +2289,12 @@ factor of:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5034"/> + <location filename="../src/T2DMap.cpp" line="5040"/> <source>Shrink in rooms</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5035"/> + <location filename="../src/T2DMap.cpp" line="5041"/> <source>Decrease the spacing of the selected rooms, centered on the @@ -2303,23 +2303,23 @@ factor of:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5116"/> + <location filename="../src/T2DMap.cpp" line="5122"/> <source>Load Mudlet map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5118"/> + <location filename="../src/T2DMap.cpp" line="5124"/> <source>Mudlet map (*.dat);;Xml map data (*.xml);;Any file (*)</source> <comment>Do not change extensions (in braces) or the ;;s as they are used programmatically</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5200"/> + <location filename="../src/T2DMap.cpp" line="5206"/> <source>This will create new area: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/T2DMap.cpp" line="5223"/> + <location filename="../src/T2DMap.cpp" line="5229"/> <source>[ OK ] - Added "%1" (%2) area to map.</source> <translation type="unfinished"></translation> </message> @@ -2335,12 +2335,12 @@ factor of:</source> <context> <name>TArea</name> <message> - <location filename="../src/TArea.cpp" line="370"/> + <location filename="../src/TArea.cpp" line="372"/> <source>roomID=%1 does not exist, can not set properties of a non-existent room!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TArea.cpp" line="807"/> + <location filename="../src/TArea.cpp" line="809"/> <source>no text</source> <extracomment>Default text if a label is created in mapper with no text</extracomment> <translation type="unfinished"></translation> @@ -2349,61 +2349,61 @@ factor of:</source> <context> <name>TCommandLine</name> <message> - <location filename="../src/TCommandLine.cpp" line="68"/> - <location filename="../src/TCommandLine.cpp" line="1846"/> + <location filename="../src/TCommandLine.cpp" line="71"/> + <location filename="../src/TCommandLine.cpp" line="1872"/> <source>Show password</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="754"/> + <location filename="../src/TCommandLine.cpp" line="780"/> <source>Add to user dictionary</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="756"/> + <location filename="../src/TCommandLine.cpp" line="782"/> <source>Remove from user dictionary</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="769"/> + <location filename="../src/TCommandLine.cpp" line="795"/> <source>▼Mudlet▼ │ dictionary suggestions │ ▲User▲</source> <extracomment>This line is shown in the list of spelling suggestions on the profile's command line context menu to clearly divide up where the suggestions for correct spellings are coming from. The precise format might be modified as long as it is clear that the entries below this line in the menu come from the spelling dictionary that the user has chosen in the profile setting which we have bundled with Mudlet; the entries about this line are the ones that the user has personally added.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="780"/> + <location filename="../src/TCommandLine.cpp" line="806"/> <source>▼System▼ │ dictionary suggestions │ ▲User▲</source> <extracomment>This line is shown in the list of spelling suggestions on the profile's command line context menu to clearly divide up where the suggestions for correct spellings are coming from. The precise format might be modified as long as it is clear that the entries below this line in the menu come from the spelling dictionary that the user has chosen in the profile setting which is provided as part of the OS; the entries about this line are the ones that the user has personally added.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="849"/> + <location filename="../src/TCommandLine.cpp" line="875"/> <source>no suggestions (system)</source> <extracomment>Used when the command spelling checker using the selected system dictionary has no words to suggest.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="878"/> + <location filename="../src/TCommandLine.cpp" line="904"/> <source>no suggestions (shared)</source> <extracomment>Used when the command spelling checker using the dictionary shared between profile has no words to suggest.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="884"/> + <location filename="../src/TCommandLine.cpp" line="910"/> <source>no suggestions (profile)</source> <extracomment>Used when the command spelling checker using the profile's own dictionary has no words to suggest.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1460"/> + <location filename="../src/TCommandLine.cpp" line="1486"/> <source>Input line for "%1" profile.</source> <extracomment>Accessibility-friendly name to describe the main command line for a Mudlet profile when more than one profile is loaded, %1 is the profile name. Because this is likely to be used often it should be kept as short as possible.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1467"/> - <location filename="../src/TCommandLine.cpp" line="1500"/> - <location filename="../src/TCommandLine.cpp" line="1534"/> + <location filename="../src/TCommandLine.cpp" line="1493"/> + <location filename="../src/TCommandLine.cpp" line="1526"/> + <location filename="../src/TCommandLine.cpp" line="1560"/> <source>Type in text to send to the game server for the "%1" profile, or enter an alias to run commands locally.</source> <extracomment>Accessibility-friendly description for the main command line for a Mudlet profile when more than one profile is loaded, %1 is the profile name. Because this is likely to be used often it should be kept as short as possible. ---------- @@ -2413,15 +2413,15 @@ Accessibility-friendly description for the built-in command line of a console/wi <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1476"/> + <location filename="../src/TCommandLine.cpp" line="1502"/> <source>Input line.</source> <extracomment>Accessibility-friendly name to describe the main command line for a Mudlet profile when only one profile is loaded. Because this is likely to be used often it should be kept as short as possible.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1482"/> - <location filename="../src/TCommandLine.cpp" line="1515"/> - <location filename="../src/TCommandLine.cpp" line="1549"/> + <location filename="../src/TCommandLine.cpp" line="1508"/> + <location filename="../src/TCommandLine.cpp" line="1541"/> + <location filename="../src/TCommandLine.cpp" line="1575"/> <source>Type in text to send to the game server, or enter an alias to run commands locally.</source> <extracomment>Accessibility-friendly description for the main command line for a Mudlet profile when only one profile is loaded. Because this is likely to be used often it should be kept as short as possible. ---------- @@ -2431,31 +2431,31 @@ Accessibility-friendly description for the built-in command line of a console/wi <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1494"/> + <location filename="../src/TCommandLine.cpp" line="1520"/> <source>Additional input line "%1" on "%2" window of "%3"profile.</source> <extracomment>Accessibility-friendly name to describe an extra command line on top of console/window when more than one profile is loaded, %1 is the command line name, %2 is the name of the window/console that it is on and %3 is the name of the profile.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1510"/> + <location filename="../src/TCommandLine.cpp" line="1536"/> <source>Additional input line "%1" on "%2" window.</source> <extracomment>Accessibility-friendly name to describe an extra command line on top of console/window when only one profile is loaded, %1 is the command line name and %2 is the name of the window/console that it is on.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1528"/> + <location filename="../src/TCommandLine.cpp" line="1554"/> <source>Input line of "%1" window of "%2" profile.</source> <extracomment>Accessibility-friendly name to describe the built-in command line of a console/window other than the main one, when more than one profile is loaded, %1 is the name of the window/console and %2 is the name of the profile.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1543"/> + <location filename="../src/TCommandLine.cpp" line="1569"/> <source>Input line of "%1" window.</source> <extracomment>Accessibility-friendly name to describe the built-in command line of a console/window other than the main one, when only one profile is loaded, %1 is the name of the window/console.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TCommandLine.cpp" line="1842"/> + <location filename="../src/TCommandLine.cpp" line="1868"/> <source>Hide password</source> <translation type="unfinished"></translation> </message> @@ -2463,372 +2463,372 @@ Accessibility-friendly description for the built-in command line of a console/wi <context> <name>TConsole</name> <message> - <location filename="../src/TConsole.cpp" line="110"/> + <location filename="../src/TConsole.cpp" line="113"/> <source>Debug Console</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="426"/> + <location filename="../src/TConsole.cpp" line="442"/> <source>N:%1 S:%2</source> <extracomment>The first argument 'N' represents the 'N'etwork latency; the second 'S' the 'S'ystem (processing) time</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="432"/> + <location filename="../src/TConsole.cpp" line="448"/> <source><no GA> S:%1</source> <extracomment>The argument 'S' represents the 'S'ystem (processing) time, in this situation the Game Server is not sending "GoAhead" signals so we cannot deduce the network latency...</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="1958"/> + <location filename="../src/TConsole.cpp" line="2082"/> <source>System Message: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="1176"/> + <location filename="../src/TConsole.cpp" line="1222"/> <source>[ INFO ] - Split-screen scrollback activated. Press <⌘>+<ENTER> to cancel.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="1178"/> + <location filename="../src/TConsole.cpp" line="1224"/> <source>[ INFO ] - Split-screen scrollback activated. Press <CTRL>+<ENTER> to cancel.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2387"/> + <location filename="../src/TConsole.cpp" line="2511"/> <source>Debug messages from all profiles are shown here.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2390"/> + <location filename="../src/TConsole.cpp" line="2514"/> <source>Central debug console past content.</source> <extracomment>accessibility-friendly name to describe the upper half of the Mudlet central debug window when you've scrolled up</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2392"/> + <location filename="../src/TConsole.cpp" line="2516"/> <source>Central debug console live content.</source> <extracomment>accessibility-friendly name to describe the lower half of the Mudlet central debug when you've scrolled up</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2395"/> + <location filename="../src/TConsole.cpp" line="2519"/> <source>Central debug console.</source> <extracomment>accessibility-friendly name to describe the upper half of the Mudlet central debug window when it is not scrolled up</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2404"/> + <location filename="../src/TConsole.cpp" line="2528"/> <source>Editor's error window for profile "%1", past content.</source> <extracomment>accessibility-friendly name to describe the upper half of the Mudlet profile's editor error window when you've scrolled up, %1 is the name of the profile when more than one is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2406"/> + <location filename="../src/TConsole.cpp" line="2530"/> <source>Editor's error window for profile "%1", live content.</source> <extracomment>accessibility-friendly name to describe the lower half of the Mudlet profile's editor error window when you've scrolled up, %1 is the name of the profile when more than one is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2409"/> + <location filename="../src/TConsole.cpp" line="2533"/> <source>Editor's error window past content.</source> <extracomment>accessibility-friendly name to describe the upper half of the Mudlet profile's editor error window when you've scrolled up and only one profile is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2411"/> + <location filename="../src/TConsole.cpp" line="2535"/> <source>Editor's error window live content.</source> <extracomment>accessibility-friendly name to describe the lower half of the Mudlet profile's editor error window when you've scrolled up and only one profile is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2417"/> + <location filename="../src/TConsole.cpp" line="2541"/> <source>Editor's error window for profile "%1".</source> <extracomment>accessibility-friendly name to describe the upper half of the Mudlet profile's editor error window when it is not scrolled up, %1 is the name of the profile when more than one is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2420"/> + <location filename="../src/TConsole.cpp" line="2544"/> <source>Editor's error window</source> <extracomment>accessibility-friendly name to describe the upper half of the Mudlet profile's editor error window when it is not scrolled up and only one profile is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2427"/> + <location filename="../src/TConsole.cpp" line="2551"/> <source>Game content is shown here. It may contain subconsoles and a mapper window.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="286"/> + <location filename="../src/TConsole.cpp" line="302"/> <source>main window</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="387"/> - <location filename="../src/TConsole.cpp" line="999"/> + <location filename="../src/TConsole.cpp" line="403"/> + <location filename="../src/TConsole.cpp" line="1026"/> <source>Start recording of replay</source> <extracomment>Button tooltip for the replay recording toggle button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="397"/> + <location filename="../src/TConsole.cpp" line="413"/> <source>Start logging game output to log file.</source> <extracomment>Button tooltip for the logging button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="411"/> + <location filename="../src/TConsole.cpp" line="427"/> <source><i>N:</i> network latency in seconds (ping),<br><i>S:</i> system processing time (triggers).</source> <extracomment>Tooltip for N and S network latency indicators</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="472"/> + <location filename="../src/TConsole.cpp" line="488"/> <source>Search</source> <extracomment>search bar placeholder text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="479"/> + <location filename="../src/TConsole.cpp" line="495"/> <source>Search buffer.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="482"/> - <location filename="../src/TConsole.cpp" line="485"/> + <location filename="../src/TConsole.cpp" line="498"/> + <location filename="../src/TConsole.cpp" line="501"/> <source>Search Options</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="489"/> + <location filename="../src/TConsole.cpp" line="505"/> <source>Case sensitive</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="491"/> + <location filename="../src/TConsole.cpp" line="507"/> <source>Match case precisely</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="504"/> + <location filename="../src/TConsole.cpp" line="520"/> <source>Earlier search result.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="514"/> + <location filename="../src/TConsole.cpp" line="530"/> <source>Later search result.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="978"/> + <location filename="../src/TConsole.cpp" line="1005"/> <source>Failed to open replay recording file for writing.</source> <extracomment>Informational message displayed when replay recording file could not be opened</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="986"/> + <location filename="../src/TConsole.cpp" line="1013"/> <source>Replay recording has started. File: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="988"/> + <location filename="../src/TConsole.cpp" line="1015"/> <source>Stop recording of replay</source> <extracomment>Button tooltip for the replay recording toggle button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="993"/> + <location filename="../src/TConsole.cpp" line="1020"/> <source>Replay recording has been stopped, but couldn't be saved.</source> <extracomment>Informational message displayed when replay recording is stopped but could not be saved</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="996"/> + <location filename="../src/TConsole.cpp" line="1023"/> <source>Replay recording has been stopped. File: %1</source> <extracomment>Informational message displayed when replay recording is stopped</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2098"/> - <location filename="../src/TConsole.cpp" line="2141"/> + <location filename="../src/TConsole.cpp" line="2222"/> + <location filename="../src/TConsole.cpp" line="2265"/> <source>No search results, sorry!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2386"/> + <location filename="../src/TConsole.cpp" line="2510"/> <source>Debug Console.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2436"/> + <location filename="../src/TConsole.cpp" line="2560"/> <source>Profile "%1" main window past content.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's main window when you've scrolled up, %1 is the name of the profile when more than one is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2438"/> + <location filename="../src/TConsole.cpp" line="2562"/> <source>Profile "%1" main window live content.</source> <extracomment>accessibility-friendly name to describe the lower half of a Mudlet profile's main window when you've scrolled up, %1 is the name of the profile when more than one is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2441"/> + <location filename="../src/TConsole.cpp" line="2565"/> <source>Profile main window past content.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's main window when you've scrolled up and only one profile is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2443"/> + <location filename="../src/TConsole.cpp" line="2567"/> <source>Profile main window live content.</source> <extracomment>accessibility-friendly name to describe the lower half of a Mudlet profile's main window when you've scrolled up and only one profile is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2448"/> + <location filename="../src/TConsole.cpp" line="2572"/> <source>Profile "%1" main window.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's main window when it is not scrolled up, %1 is the name of the profile when more than one is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2451"/> + <location filename="../src/TConsole.cpp" line="2575"/> <source>Profile main window.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's main window when it is not scrolled up and only one profile is loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2466"/> + <location filename="../src/TConsole.cpp" line="2590"/> <source>Profile "%1" embedded window "%2" past content.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's sub-console window when you've scrolled up, %1 is the name of the profile when more than one is loaded and %2 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2468"/> + <location filename="../src/TConsole.cpp" line="2592"/> <source>Profile "%1" embedded window "%2" live content.</source> <extracomment>accessibility-friendly name to describe the lower half of a Mudlet profile's sub-console window when you've scrolled up, %1 is the name of the profile when more than one is loaded and %2 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2471"/> + <location filename="../src/TConsole.cpp" line="2595"/> <source>Profile embedded window "%1" past content.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's sub-console window when you've scrolled up, %1 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2473"/> + <location filename="../src/TConsole.cpp" line="2597"/> <source>Profile embedded window "%1" live content.</source> <extracomment>accessibility-friendly name to describe the lower half of a Mudlet profile's sub-console window when you've scrolled up, %1 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2478"/> + <location filename="../src/TConsole.cpp" line="2602"/> <source>Profile "%1" embedded window "%2".</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's sub-console window when it is not scrolled up, %1 is the name of the profile when more than one is loaded and %2 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2481"/> + <location filename="../src/TConsole.cpp" line="2605"/> <source>Profile embedded window "%1".</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's sub-console window when it is not scrolled up, %1 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2497"/> + <location filename="../src/TConsole.cpp" line="2621"/> <source>Profile "%1" user window "%2" past content.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's floating/dockable user window when you've scrolled up, %1 is the name of the profile when more than one is loaded and %2 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2499"/> + <location filename="../src/TConsole.cpp" line="2623"/> <source>Profile "%1" user window "%2" live content.</source> <extracomment>accessibility-friendly name to describe the lower half of a Mudlet profile's floating/dockable user window window when you've scrolled up, %1 is the name of the profile when more than one is loaded and %2 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2502"/> + <location filename="../src/TConsole.cpp" line="2626"/> <source>Profile user window "%1" past content.</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's sub-console window when you've scrolled up, %1 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2504"/> + <location filename="../src/TConsole.cpp" line="2628"/> <source>Profile user window "%1" live content.</source> <extracomment>accessibility-friendly name to describe the lower half of a Mudlet profile's sub-console window when you've scrolled up, %1 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2509"/> + <location filename="../src/TConsole.cpp" line="2633"/> <source>Profile "%1" user window "%2".</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's floating/dockable user window window when it is not scrolled up, %1 is the name of the profile when more than one is loaded and %2 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2512"/> + <location filename="../src/TConsole.cpp" line="2636"/> <source>Profile user window "%1".</source> <extracomment>accessibility-friendly name to describe the upper half of a Mudlet profile's floating/dockable user window window when it is not scrolled up, %1 is the name of the window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2400"/> + <location filename="../src/TConsole.cpp" line="2524"/> <source>Error Console in editor.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="368"/> + <location filename="../src/TConsole.cpp" line="384"/> <source>Toggle time stamps</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="454"/> + <location filename="../src/TConsole.cpp" line="470"/> <source>Emergency stop! Stop all scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2413"/> + <location filename="../src/TConsole.cpp" line="2537"/> <source>Error messages for the "%1" profile are shown here in the editor.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2423"/> + <location filename="../src/TConsole.cpp" line="2547"/> <source>Error messages are shown here in the editor.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2429"/> + <location filename="../src/TConsole.cpp" line="2553"/> <source>Main Window for "%1" profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2431"/> + <location filename="../src/TConsole.cpp" line="2555"/> <source>Main Window.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2458"/> + <location filename="../src/TConsole.cpp" line="2582"/> <source>Embedded window "%1" for "%2" profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2460"/> + <location filename="../src/TConsole.cpp" line="2584"/> <source>Embedded window "%1".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2462"/> + <location filename="../src/TConsole.cpp" line="2586"/> <source>Game content or locally generated text may be sent here.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2488"/> + <location filename="../src/TConsole.cpp" line="2612"/> <source>User window "%1" for "%2" profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2490"/> + <location filename="../src/TConsole.cpp" line="2614"/> <source>User window "%1".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TConsole.cpp" line="2493"/> + <location filename="../src/TConsole.cpp" line="2617"/> <source>Game content or locally generated text may be sent to this window that may be floated away from the Mudlet application or docked within the main application window.</source> <translation type="unfinished"></translation> </message> @@ -2836,39 +2836,39 @@ Accessibility-friendly description for the built-in command line of a console/wi <context> <name>TDetachedWindow</name> <message> - <location filename="../src/TDetachedWindow.cpp" line="82"/> - <location filename="../src/TDetachedWindow.cpp" line="1335"/> + <location filename="../src/TDetachedWindow.cpp" line="85"/> + <location filename="../src/TDetachedWindow.cpp" line="1338"/> <source>Mudlet - %1 (Detached)</source> <extracomment>This is the title of a Mudlet window which was detached from the main Mudlet window, and %1 is the name of the profile.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="284"/> + <location filename="../src/TDetachedWindow.cpp" line="287"/> <source>&Close Profile</source> <extracomment>This is an item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="286"/> + <location filename="../src/TDetachedWindow.cpp" line="289"/> <source>Close the current profile</source> <extracomment>This explains the "Close Profile" item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="432"/> + <location filename="../src/TDetachedWindow.cpp" line="435"/> <source>&Window</source> <extracomment>This is the name of a menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="451"/> + <location filename="../src/TDetachedWindow.cpp" line="454"/> <source>&Reattach to Main Window</source> <extracomment>This is an item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="453"/> - <location filename="../src/TDetachedWindow.cpp" line="860"/> + <location filename="../src/TDetachedWindow.cpp" line="456"/> + <location filename="../src/TDetachedWindow.cpp" line="863"/> <source>Reattach this profile window to the main Mudlet window</source> <extracomment>This explains the "Reattach to Main Window" item in the "Window" menu in the menubar of a detached Mudlet window. ---------- @@ -2876,45 +2876,45 @@ This explains the "Reattach" item in the toolbar of a detached Mudlet <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="462"/> + <location filename="../src/TDetachedWindow.cpp" line="465"/> <source>Always on &Top</source> <extracomment>This is an item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="465"/> + <location filename="../src/TDetachedWindow.cpp" line="468"/> <source>Keep this window always on top of other windows</source> <extracomment>This explains the "Always on Top" item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="470"/> + <location filename="../src/TDetachedWindow.cpp" line="473"/> <source>&Minimize</source> <extracomment>This is an item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="737"/> + <location filename="../src/TDetachedWindow.cpp" line="740"/> <source>Reattach '%1' to Main Window</source> <extracomment>This is an item in the context menu when clicked on a detached tab, and %1 is the name of the profile.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="748"/> + <location filename="../src/TDetachedWindow.cpp" line="751"/> <source>Close Profile '%1'</source> <extracomment>This is an item in the context menu when clicked on a detached tab, and %1 is the name of the profile.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="758"/> + <location filename="../src/TDetachedWindow.cpp" line="761"/> <source>Close Window (All Profiles)</source> <extracomment>This is an item in the context menu when clicked on a detached tab.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="870"/> - <location filename="../src/TDetachedWindow.cpp" line="879"/> - <location filename="../src/TDetachedWindow.cpp" line="881"/> + <location filename="../src/TDetachedWindow.cpp" line="873"/> + <location filename="../src/TDetachedWindow.cpp" line="882"/> + <location filename="../src/TDetachedWindow.cpp" line="884"/> <source>Connect</source> <extracomment>This is an item in the toolbar of a detached Mudlet window. ---------- @@ -2922,522 +2922,522 @@ This is a sub-item of the "Connect" item in the toolbar of a detached <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="885"/> + <location filename="../src/TDetachedWindow.cpp" line="888"/> <source>Disconnect</source> <extracomment>This is a sub-item of the "Connect" item in the toolbar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1053"/> + <location filename="../src/TDetachedWindow.cpp" line="1056"/> <source>Reconnect</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="889"/> - <location filename="../src/TDetachedWindow.cpp" line="891"/> + <location filename="../src/TDetachedWindow.cpp" line="892"/> + <location filename="../src/TDetachedWindow.cpp" line="894"/> <source>Close profile</source> <extracomment>This is a sub-item of the "Connect" item in the toolbar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="558"/> + <location filename="../src/TDetachedWindow.cpp" line="561"/> <source>Show &Toolbar</source> <extracomment>This is an item for the toolbar visibility toggle in a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="562"/> + <location filename="../src/TDetachedWindow.cpp" line="565"/> <source>Show or hide the toolbar</source> <extracomment>This explains the "Show Toolbar" action for toolbar visibility in a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="776"/> + <location filename="../src/TDetachedWindow.cpp" line="779"/> <source>Show Connection Indicators on Tabs</source> <extracomment>This is an item in the context menu when clicked on a detached tab.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="858"/> + <location filename="../src/TDetachedWindow.cpp" line="861"/> <source>Reattach</source> <extracomment>This is an item in the toolbar of a detached Mudlet window. It will reattach the profile to the main Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="895"/> - <location filename="../src/TDetachedWindow.cpp" line="897"/> + <location filename="../src/TDetachedWindow.cpp" line="898"/> + <location filename="../src/TDetachedWindow.cpp" line="900"/> <source>Close Mudlet</source> <extracomment>This is a sub-item of the "Connect" item in the toolbar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="907"/> + <location filename="../src/TDetachedWindow.cpp" line="910"/> <source>Triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="908"/> + <location filename="../src/TDetachedWindow.cpp" line="911"/> <source>Show and edit triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="912"/> + <location filename="../src/TDetachedWindow.cpp" line="915"/> <source>Aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="913"/> + <location filename="../src/TDetachedWindow.cpp" line="916"/> <source>Show and edit aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="917"/> + <location filename="../src/TDetachedWindow.cpp" line="920"/> <source>Timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="918"/> + <location filename="../src/TDetachedWindow.cpp" line="921"/> <source>Show and edit timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="922"/> + <location filename="../src/TDetachedWindow.cpp" line="925"/> <source>Buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="923"/> + <location filename="../src/TDetachedWindow.cpp" line="926"/> <source>Show and edit easy buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="927"/> + <location filename="../src/TDetachedWindow.cpp" line="930"/> <source>Scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="928"/> + <location filename="../src/TDetachedWindow.cpp" line="931"/> <source>Show and edit scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="932"/> + <location filename="../src/TDetachedWindow.cpp" line="935"/> <source>Keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="933"/> + <location filename="../src/TDetachedWindow.cpp" line="936"/> <source>Show and edit keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="937"/> + <location filename="../src/TDetachedWindow.cpp" line="940"/> <source>Variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="938"/> + <location filename="../src/TDetachedWindow.cpp" line="941"/> <source>Show and edit Lua variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="944"/> + <location filename="../src/TDetachedWindow.cpp" line="947"/> <source>Mute</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="407"/> - <location filename="../src/TDetachedWindow.cpp" line="952"/> - <location filename="../src/TDetachedWindow.cpp" line="954"/> + <location filename="../src/TDetachedWindow.cpp" line="410"/> + <location filename="../src/TDetachedWindow.cpp" line="955"/> + <location filename="../src/TDetachedWindow.cpp" line="957"/> <source>Mute all media</source> <extracomment>This is an item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="256"/> + <location filename="../src/TDetachedWindow.cpp" line="259"/> <source>&Games</source> <extracomment>This is the name of a menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="259"/> + <location filename="../src/TDetachedWindow.cpp" line="262"/> <source>&Play</source> <extracomment>This is an item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="261"/> + <location filename="../src/TDetachedWindow.cpp" line="264"/> <source>Configure connection details of, and make a connection to, game servers.</source> <extracomment>This explains the "Play" item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="268"/> + <location filename="../src/TDetachedWindow.cpp" line="271"/> <source>&Disconnect</source> <extracomment>This is an item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="270"/> + <location filename="../src/TDetachedWindow.cpp" line="273"/> <source>Disconnect from the current game server.</source> <extracomment>This explains the "Disconnect" item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="275"/> + <location filename="../src/TDetachedWindow.cpp" line="278"/> <source>&Reconnect</source> <extracomment>This is an item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="277"/> + <location filename="../src/TDetachedWindow.cpp" line="280"/> <source>Disconnect and then reconnect to the current game server.</source> <extracomment>This explains the "Reconnect" item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="291"/> + <location filename="../src/TDetachedWindow.cpp" line="294"/> <source>Close &Mudlet</source> <extracomment>This is an item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="293"/> + <location filename="../src/TDetachedWindow.cpp" line="296"/> <source>Close the entire Mudlet application</source> <extracomment>This explains the "Close Mudlet" item in the "Games" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="299"/> + <location filename="../src/TDetachedWindow.cpp" line="302"/> <source>&Toolbox</source> <extracomment>This is the name of a menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="302"/> + <location filename="../src/TDetachedWindow.cpp" line="305"/> <source>&Script editor</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="304"/> + <location filename="../src/TDetachedWindow.cpp" line="307"/> <source>Opens the Editor for the different types of things that can be scripted by the user.</source> <extracomment>This explains the "Script editor" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="309"/> + <location filename="../src/TDetachedWindow.cpp" line="312"/> <source>Show &errors</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="311"/> + <location filename="../src/TDetachedWindow.cpp" line="314"/> <source>Show errors from scripts that you have running</source> <extracomment>This explains the "Show errors" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="316"/> + <location filename="../src/TDetachedWindow.cpp" line="319"/> <source>Show &map</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="318"/> + <location filename="../src/TDetachedWindow.cpp" line="321"/> <source>Show or hide the game map.</source> <extracomment>This explains the "Show map" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="323"/> + <location filename="../src/TDetachedWindow.cpp" line="326"/> <source>Compact &input line</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="325"/> + <location filename="../src/TDetachedWindow.cpp" line="328"/> <source>Hide / show the search area and buttons at the bottom of the screen.</source> <extracomment>This explains the "Compact input line" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="331"/> + <location filename="../src/TDetachedWindow.cpp" line="334"/> <source>&Notepad</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="333"/> + <location filename="../src/TDetachedWindow.cpp" line="336"/> <source>Opens a free form text editor window for this profile that is saved between sessions.</source> <extracomment>This explains the "Notepad" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="338"/> + <location filename="../src/TDetachedWindow.cpp" line="341"/> <source>&Package manager</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="340"/> + <location filename="../src/TDetachedWindow.cpp" line="343"/> <source>Install and remove collections of Mudlet lua items (packages).</source> <extracomment>This explains the "Package manager" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="345"/> + <location filename="../src/TDetachedWindow.cpp" line="348"/> <source>Load &replay</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="347"/> + <location filename="../src/TDetachedWindow.cpp" line="350"/> <source>Load a previous saved game session that can be used to test Mudlet lua systems (off-line!).</source> <extracomment>This explains the "Load replay" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="352"/> + <location filename="../src/TDetachedWindow.cpp" line="355"/> <source>&Module manager</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="354"/> + <location filename="../src/TDetachedWindow.cpp" line="357"/> <source>Install and remove (share- & sync-able) collections of Mudlet lua items (modules).</source> <extracomment>This explains the "Module manager" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="359"/> + <location filename="../src/TDetachedWindow.cpp" line="362"/> <source>Package &exporter</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="361"/> + <location filename="../src/TDetachedWindow.cpp" line="364"/> <source>Gather and bundle up collections of Mudlet Lua items and other reasources into a module.</source> <extracomment>This explains the "Package exporter" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="366"/> + <location filename="../src/TDetachedWindow.cpp" line="369"/> <source>Record replay</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="368"/> + <location filename="../src/TDetachedWindow.cpp" line="371"/> <source>Toggle recording of replays.</source> <extracomment>This explains the "Record replay" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="373"/> + <location filename="../src/TDetachedWindow.cpp" line="376"/> <source>Record log</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="375"/> + <location filename="../src/TDetachedWindow.cpp" line="378"/> <source>Toggle logging facilities.</source> <extracomment>This explains the "Record log" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="380"/> + <location filename="../src/TDetachedWindow.cpp" line="383"/> <source>Emergency stop</source> <extracomment>This is an item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="382"/> + <location filename="../src/TDetachedWindow.cpp" line="385"/> <source>Toggle all triggers, aliases, timers, etc. on or off</source> <extracomment>This explains the "Emergency stop" item in the "Toolbox" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="388"/> + <location filename="../src/TDetachedWindow.cpp" line="391"/> <source>&Options</source> <extracomment>This is the name of a menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="391"/> + <location filename="../src/TDetachedWindow.cpp" line="394"/> <source>&Preferences</source> <extracomment>This is an item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="393"/> + <location filename="../src/TDetachedWindow.cpp" line="396"/> <source>Configure setting for the Mudlet application globally and for the current profile.</source> <extracomment>This explains the "Preferences" item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="398"/> + <location filename="../src/TDetachedWindow.cpp" line="401"/> <source>&Timestamps</source> <extracomment>This is an item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="400"/> + <location filename="../src/TDetachedWindow.cpp" line="403"/> <source>Toggle time stamps on the main console.</source> <extracomment>This explains the "Timestamps" item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="409"/> + <location filename="../src/TDetachedWindow.cpp" line="412"/> <source>Mutes all media played.</source> <extracomment>This explains the "Mute all media" item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="415"/> - <location filename="../src/TDetachedWindow.cpp" line="958"/> - <location filename="../src/TDetachedWindow.cpp" line="960"/> + <location filename="../src/TDetachedWindow.cpp" line="418"/> + <location filename="../src/TDetachedWindow.cpp" line="961"/> + <location filename="../src/TDetachedWindow.cpp" line="963"/> <source>Mute sounds from Mudlet (triggers, scripts, etc.)</source> <extracomment>This is an item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="417"/> + <location filename="../src/TDetachedWindow.cpp" line="420"/> <source>Mutes media played by the Lua API and scripts.</source> <extracomment>This explains the "Mute sounds from Mudlet (triggers, scripts, etc.)" item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="423"/> - <location filename="../src/TDetachedWindow.cpp" line="964"/> - <location filename="../src/TDetachedWindow.cpp" line="966"/> + <location filename="../src/TDetachedWindow.cpp" line="426"/> + <location filename="../src/TDetachedWindow.cpp" line="967"/> + <location filename="../src/TDetachedWindow.cpp" line="969"/> <source>Mute sounds from the game (MCMP, MSP)</source> <extracomment>This is an item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="425"/> + <location filename="../src/TDetachedWindow.cpp" line="428"/> <source>Mutes media played by the game (MCMP, MSP).</source> <extracomment>This explains the "Mute sounds from the game (MCMP, MSP)" item in the "Options" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="435"/> + <location filename="../src/TDetachedWindow.cpp" line="438"/> <source>&Fullscreen</source> <extracomment>This is an item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="443"/> + <location filename="../src/TDetachedWindow.cpp" line="446"/> <source>&Multiview</source> <extracomment>This is an item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="445"/> + <location filename="../src/TDetachedWindow.cpp" line="448"/> <source>Splits the Mudlet screen to show multiple profiles at once; disabled when less than two are loaded.</source> <extracomment>This explains the "Multiview" item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="472"/> + <location filename="../src/TDetachedWindow.cpp" line="475"/> <source>Minimize this window</source> <extracomment>This explains the "Minimize" item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="478"/> + <location filename="../src/TDetachedWindow.cpp" line="481"/> <source>&Help</source> <extracomment>This is the name of a menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="481"/> + <location filename="../src/TDetachedWindow.cpp" line="484"/> <source>&API Reference</source> <extracomment>This is an item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="483"/> + <location filename="../src/TDetachedWindow.cpp" line="486"/> <source>Opens the Mudlet manual in your web browser.</source> <extracomment>This explains the "API Reference" item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="488"/> + <location filename="../src/TDetachedWindow.cpp" line="491"/> <source>&Video tutorials</source> <extracomment>This is an item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="490"/> + <location filename="../src/TDetachedWindow.cpp" line="493"/> <source>Opens an (on-line) collection of "Educational Mudlet screencasts" in your system web-browser.</source> <extracomment>This explains the "Video tutorials" item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="495"/> + <location filename="../src/TDetachedWindow.cpp" line="498"/> <source>&Discord</source> <extracomment>This is an item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="497"/> + <location filename="../src/TDetachedWindow.cpp" line="500"/> <source>Open a link to Discord.</source> <extracomment>This explains the "Discord" item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="502"/> + <location filename="../src/TDetachedWindow.cpp" line="505"/> <source>Discord &help channel</source> <extracomment>This is an item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="504"/> + <location filename="../src/TDetachedWindow.cpp" line="507"/> <source>Open a link to the Mudlet server on Discord.</source> <extracomment>This explains the "Discord help channel" item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="509"/> + <location filename="../src/TDetachedWindow.cpp" line="512"/> <source>&Live help chat</source> <extracomment>This is an item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="511"/> + <location filename="../src/TDetachedWindow.cpp" line="514"/> <source>Opens a connect to an IRC server (LiberaChat) in your system web-browser.</source> <extracomment>This explains the "Live help chat" item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="516"/> + <location filename="../src/TDetachedWindow.cpp" line="519"/> <source>Online &forum</source> <extracomment>This is an item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="518"/> + <location filename="../src/TDetachedWindow.cpp" line="521"/> <source>Opens the (on-line) Mudlet Forum in your system web-browser.</source> <extracomment>This explains the "Online forum" item in the "Help" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="524"/> + <location filename="../src/TDetachedWindow.cpp" line="527"/> <source>&About</source> <extracomment>This is the name of a menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="527"/> + <location filename="../src/TDetachedWindow.cpp" line="530"/> <source>About &Mudlet</source> <extracomment>This is an item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="529"/> - <location filename="../src/TDetachedWindow.cpp" line="1061"/> + <location filename="../src/TDetachedWindow.cpp" line="532"/> + <location filename="../src/TDetachedWindow.cpp" line="1064"/> <source>About Mudlet version, creators, and license.</source> <extracomment>Tooltip for About Mudlet sub-menu item (Used in multiple places - please ensure all have the same translation). ---------- @@ -3445,45 +3445,45 @@ Tooltip for About Mudlet toolbar button (Used in multiple places - please ensure <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="535"/> + <location filename="../src/TDetachedWindow.cpp" line="538"/> <source>&Check for updates...</source> <extracomment>This is an item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="537"/> + <location filename="../src/TDetachedWindow.cpp" line="540"/> <source>Check for newer versions of Mudlet</source> <extracomment>This explains the "Check for updates..." item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="542"/> + <location filename="../src/TDetachedWindow.cpp" line="545"/> <source>Show &changelog</source> <extracomment>This is an item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="544"/> + <location filename="../src/TDetachedWindow.cpp" line="547"/> <source>Show the changelog for this version</source> <extracomment>This explains the "Show changelog" item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="550"/> + <location filename="../src/TDetachedWindow.cpp" line="553"/> <source>&Report an issue</source> <extracomment>This is an item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="552"/> + <location filename="../src/TDetachedWindow.cpp" line="555"/> <source>The public test build gets newer features to you quicker, and you help us find issues in them quicker. Spotted something odd? Let us know asap!</source> <extracomment>This explains the "Report an issue" item in the "About" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="765"/> - <location filename="../src/TDetachedWindow.cpp" line="835"/> - <location filename="../src/TDetachedWindow.cpp" line="1656"/> + <location filename="../src/TDetachedWindow.cpp" line="768"/> + <location filename="../src/TDetachedWindow.cpp" line="838"/> + <location filename="../src/TDetachedWindow.cpp" line="1659"/> <source>Main Toolbar</source> <extracomment>This is a checkable toggle item in the context menu shown when right-clicking a tab in a detached window, to show or hide the toolbar. It appears with a checkmark when the toolbar is visible. ---------- @@ -3493,158 +3493,158 @@ This is a checkable toggle item in the context menu shown when right-clicking th <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="984"/> + <location filename="../src/TDetachedWindow.cpp" line="987"/> <source>Open Discord</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="989"/> + <location filename="../src/TDetachedWindow.cpp" line="992"/> <source>Mudlet chat</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="990"/> + <location filename="../src/TDetachedWindow.cpp" line="993"/> <source>Open a link to the Mudlet server on Discord</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1000"/> + <location filename="../src/TDetachedWindow.cpp" line="1003"/> <source>Map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1001"/> + <location filename="../src/TDetachedWindow.cpp" line="1004"/> <source>Show/hide the map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1005"/> + <location filename="../src/TDetachedWindow.cpp" line="1008"/> <source>Manual</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1006"/> + <location filename="../src/TDetachedWindow.cpp" line="1009"/> <source>Browse reference material and documentation</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1010"/> + <location filename="../src/TDetachedWindow.cpp" line="1013"/> <source>Settings</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1011"/> + <location filename="../src/TDetachedWindow.cpp" line="1014"/> <source>See and edit profile preferences</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1015"/> + <location filename="../src/TDetachedWindow.cpp" line="1018"/> <source>Notepad</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1016"/> + <location filename="../src/TDetachedWindow.cpp" line="1019"/> <source>Open a notepad that you can store your notes in</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1022"/> - <location filename="../src/TDetachedWindow.cpp" line="1032"/> + <location filename="../src/TDetachedWindow.cpp" line="1025"/> + <location filename="../src/TDetachedWindow.cpp" line="1035"/> <source>Packages</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1030"/> + <location filename="../src/TDetachedWindow.cpp" line="1033"/> <source>Package Manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1035"/> + <location filename="../src/TDetachedWindow.cpp" line="1038"/> <source>Module Manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1039"/> + <location filename="../src/TDetachedWindow.cpp" line="1042"/> <source>Package Exporter</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1048"/> + <location filename="../src/TDetachedWindow.cpp" line="1051"/> <source>Replay</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1054"/> + <location filename="../src/TDetachedWindow.cpp" line="1057"/> <source>Disconnects you from the game and connects once again</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1059"/> + <location filename="../src/TDetachedWindow.cpp" line="1062"/> <source>About</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1069"/> + <location filename="../src/TDetachedWindow.cpp" line="1072"/> <source>Full Screen</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="438"/> - <location filename="../src/TDetachedWindow.cpp" line="1070"/> + <location filename="../src/TDetachedWindow.cpp" line="441"/> + <location filename="../src/TDetachedWindow.cpp" line="1073"/> <source>Toggle Full Screen View</source> <extracomment>This explains the "Fullscreen" item in the "Window" menu in the menubar of a detached Mudlet window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1349"/> + <location filename="../src/TDetachedWindow.cpp" line="1352"/> <source>Connected to %1</source> <extracomment>This text will be added to the title of a detached Mudlet window, if it is currently connected. The whole title will be like "Mudlet PROFILENAME (Detached) - Connected to GAMENAME"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1352"/> + <location filename="../src/TDetachedWindow.cpp" line="1355"/> <source>Connected</source> <extracomment>This text will be part of to the title of a detached Mudlet window, if it is currently connected but we don't know to where. The whole title will be like "Mudlet PROFILENAME (Detached) - Connected"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1356"/> + <location filename="../src/TDetachedWindow.cpp" line="1359"/> <source>Connecting...</source> <extracomment>This text will be part of the title of a detached Mudlet window, if it is about to be connected. The whole title will be like "Mudlet PROFILENAME (Detached) - Connecting..."</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1359"/> + <location filename="../src/TDetachedWindow.cpp" line="1362"/> <source>Disconnected</source> <extracomment>This text will be part of the title of a detached Mudlet window, if it is not connected. The whole title will be like "Mudlet PROFILENAME (Detached) - Disconnected"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1780"/> + <location filename="../src/TDetachedWindow.cpp" line="1783"/> <source>%1 (Main Window)</source> <extracomment>This is an item in list of profiles in the "Window" menu of a detached Mudlet window. %1 is the name of the profile, and it is located not in the detached window, but in Mudlet's main window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1808"/> + <location filename="../src/TDetachedWindow.cpp" line="1811"/> <source>%1 (Detached)</source> <extracomment>This is an item in list of profiles in the "Window" menu of a detached Mudlet window. %1 is the name of the profile, and it is located not in Mudlet's main window, but in the detached window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="2797"/> + <location filename="../src/TDetachedWindow.cpp" line="2800"/> <source>Map - %1</source> <extracomment>This is to create a new docked mapper widget for a profile in a detached Mudlet window. %1 is the name of the profile.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1338"/> + <location filename="../src/TDetachedWindow.cpp" line="1341"/> <source>Mudlet (Detached)</source> <extracomment>This is the title of a Mudlet window which was detached from the main Mudlet window, but has no profile loaded.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TDetachedWindow.cpp" line="1365"/> + <location filename="../src/TDetachedWindow.cpp" line="1368"/> <source>Mudlet (%1 profiles) - %2 (Detached)</source> <extracomment>This is the title of a Mudlet window which was detached from the main Mudlet window, and has multiple profiles opened in this window. %1 is the number of profiles, %2 is the name of the profile currently shown.</extracomment> <translation type="unfinished"></translation> @@ -3653,7 +3653,7 @@ This is a checkable toggle item in the context menu shown when right-clicking th <context> <name>TEasyButtonBar</name> <message> - <location filename="../src/TEasyButtonBar.cpp" line="64"/> + <location filename="../src/TEasyButtonBar.cpp" line="63"/> <source>Easybutton Bar - %1 - %2</source> <translation type="unfinished"></translation> </message> @@ -3670,13 +3670,13 @@ This is a checkable toggle item in the context menu shown when right-clicking th <context> <name>THyperlinkVisibilityManager</name> <message> - <location filename="../src/THyperlinkVisibilityManager.cpp" line="755"/> + <location filename="../src/THyperlinkVisibilityManager.cpp" line="758"/> <source>Link hidden</source> <extracomment>Screen-reader announcement when an OSC 8 hyperlink is hidden by the visibility manager</extracomment> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/THyperlinkVisibilityManager.cpp" line="758"/> + <location filename="../src/THyperlinkVisibilityManager.cpp" line="761"/> <source>%n link(s) hidden</source> <extracomment>Screen-reader announcement when multiple OSC 8 hyperlinks are hidden at once; %n is the count</extracomment> <translation type="unfinished"> @@ -3684,7 +3684,7 @@ This is a checkable toggle item in the context menu shown when right-clicking th </translation> </message> <message> - <location filename="../src/THyperlinkVisibilityManager.cpp" line="791"/> + <location filename="../src/THyperlinkVisibilityManager.cpp" line="794"/> <source>Link revealed: %1</source> <extracomment>Screen-reader announcement when a previously hidden OSC 8 link is revealed; %1 is the original link text</extracomment> <translation type="unfinished"></translation> @@ -3702,115 +3702,115 @@ This is a checkable toggle item in the context menu shown when right-clicking th <context> <name>TLuaInterpreter</name> <message> - <location filename="../src/TLuaInterpreterDiscord.cpp" line="343"/> + <location filename="../src/TLuaInterpreterDiscord.cpp" line="348"/> <source>Playing %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="4177"/> - <location filename="../src/TLuaInterpreter.cpp" line="4218"/> + <location filename="../src/TLuaInterpreter.cpp" line="4409"/> + <location filename="../src/TLuaInterpreter.cpp" line="4450"/> <source>ERROR</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="4996"/> + <location filename="../src/TLuaInterpreter.cpp" line="5282"/> <source>No error message available from Lua</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="4181"/> - <location filename="../src/TLuaInterpreter.cpp" line="4204"/> + <location filename="../src/TLuaInterpreter.cpp" line="4413"/> + <location filename="../src/TLuaInterpreter.cpp" line="4436"/> <source>object</source> <extracomment>object is the Mudlet alias/trigger/script, used in this sample message: object:<Alias1> function:<cure_me></extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="4184"/> - <location filename="../src/TLuaInterpreter.cpp" line="4207"/> + <location filename="../src/TLuaInterpreter.cpp" line="4416"/> + <location filename="../src/TLuaInterpreter.cpp" line="4439"/> <source>function</source> <extracomment>function is the Lua function, used in this sample message: object:<Alias1> function:<cure_me></extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="4998"/> + <location filename="../src/TLuaInterpreter.cpp" line="5284"/> <source>Lua error: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5007"/> + <location filename="../src/TLuaInterpreter.cpp" line="5293"/> <source>[ ERROR ] - Cannot find Lua module %1.%2%3%4</source> <extracomment>%1 is the name of the module; %2 will be a line-feed inserted to put the next argument on a new line; %3 is the error message from the lua sub-system; %4 can be an additional message about the expected effect (but may be blank).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5774"/> + <location filename="../src/TLuaInterpreter.cpp" line="6071"/> <source>Probably will not be able to access Mudlet Lua code.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5792"/> + <location filename="../src/TLuaInterpreter.cpp" line="6089"/> <source>Some regular expression functions may not be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5799"/> + <location filename="../src/TLuaInterpreter.cpp" line="6096"/> <source>Database support will not be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5806"/> + <location filename="../src/TLuaInterpreter.cpp" line="6103"/> <source>utf8.* Lua functions won't be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5812"/> + <location filename="../src/TLuaInterpreter.cpp" line="6109"/> <source>yajl.* Lua functions won't be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5817"/> + <location filename="../src/TLuaInterpreter.cpp" line="6114"/> <source>lpeg.* Lua functions won't be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6003"/> + <location filename="../src/TLuaInterpreter.cpp" line="6300"/> <source>No error message available from Lua.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6005"/> + <location filename="../src/TLuaInterpreter.cpp" line="6302"/> <source>Lua error: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6007"/> + <location filename="../src/TLuaInterpreter.cpp" line="6304"/> <source>[ ERROR ] - Cannot load code formatter, indenting functionality won't be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6099"/> + <location filename="../src/TLuaInterpreter.cpp" line="6396"/> <source>%1 (doesn't exist)</source> <comment>This file doesn't exist</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6104"/> + <location filename="../src/TLuaInterpreter.cpp" line="6401"/> <source>%1 (isn't a file or symlink to a file)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6117"/> + <location filename="../src/TLuaInterpreter.cpp" line="6414"/> <source>%1 (isn't a readable file or symlink to a readable file)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6139"/> + <location filename="../src/TLuaInterpreter.cpp" line="6436"/> <source>%1 (couldn't read file)</source> <comment>This file could not be read for some reason (for example, no permission)</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="6151"/> + <location filename="../src/TLuaInterpreter.cpp" line="6448"/> <source>[ ERROR ] - Couldn't find, load and successfully run LuaGlobal.lua - your Mudlet is broken! Tried these locations: %1</source> @@ -3820,151 +3820,151 @@ Tried these locations: <context> <name>TMainConsole</name> <message> - <location filename="../src/TMainConsole.cpp" line="269"/> + <location filename="../src/TMainConsole.cpp" line="339"/> <source>Mudlet MUD Client version: %1%2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="271"/> + <location filename="../src/TMainConsole.cpp" line="341"/> <source>Mudlet, log from %1 profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="342"/> + <location filename="../src/TMainConsole.cpp" line="412"/> <source>Stop logging game output to log file.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="229"/> + <location filename="../src/TMainConsole.cpp" line="299"/> <source>Logging has started. Log file is %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="187"/> + <location filename="../src/TMainConsole.cpp" line="257"/> <source>logfile</source> <extracomment>Must be a valid default filename for a log-file and is used if the user does not enter any other value (Ensure all instances have the same translation {one of two copies}).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="239"/> + <location filename="../src/TMainConsole.cpp" line="309"/> <source>Logging has been stopped. Log file is %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="317"/> - <location filename="../src/TMainConsole.cpp" line="340"/> + <location filename="../src/TMainConsole.cpp" line="387"/> + <location filename="../src/TMainConsole.cpp" line="410"/> <source>'Log session starting at 'hh:mm:ss' on 'dddd', 'd' 'MMMM' 'yyyy'.</source> <extracomment>This is the format argument to QDateTime::toString(...) and needs to follow the rules for that function {literal text must be single quoted} as well as being suitable for the translation locale</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="347"/> + <location filename="../src/TMainConsole.cpp" line="417"/> <source>'Log session ending at 'hh:mm:ss' on 'dddd', 'd' 'MMMM' 'yyyy'.</source> <extracomment>This is the format argument to QDateTime::toString(...) and needs to follow the rules for that function {literal text must be single quoted} as well as being suitable for the translation locale</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="358"/> + <location filename="../src/TMainConsole.cpp" line="428"/> <source>Start logging game output to log file.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="850"/> + <location filename="../src/TMainConsole.cpp" line="923"/> <source>Pre-Map loading(2) report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="861"/> + <location filename="../src/TMainConsole.cpp" line="934"/> <source>Loading map(2) at %1 report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1346"/> + <location filename="../src/TMainConsole.cpp" line="1449"/> <source>User window - %1 - %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1413"/> + <location filename="../src/TMainConsole.cpp" line="1542"/> <source>N:%1 S:%2</source> <extracomment>The first argument 'N' represents the 'N'etwork latency; the second 'S' the 'S'ystem (processing) time</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1420"/> + <location filename="../src/TMainConsole.cpp" line="1549"/> <source><no GA> S:%1</source> <extracomment>The argument 'S' represents the 'S'ystem (processing) time, in this situation the Game Server is not sending "GoAhead" signals so we cannot deduce the network latency...</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1523"/> + <location filename="../src/TMainConsole.cpp" line="1652"/> <source>Pre-Map loading(1) report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1541"/> + <location filename="../src/TMainConsole.cpp" line="1670"/> <source>Loading map(1) at %1 report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1543"/> + <location filename="../src/TMainConsole.cpp" line="1672"/> <source>Loading map(1) "%1" at %2 report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1587"/> + <location filename="../src/TMainConsole.cpp" line="1716"/> <source>Pre-Map importing(1) report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1610"/> + <location filename="../src/TMainConsole.cpp" line="1739"/> <source>[ ERROR ] - Map file not found, path and name used was: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1616"/> + <location filename="../src/TMainConsole.cpp" line="1745"/> <source>loadMap: bad argument #1 value (filename used: "%1" was not found).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1625"/> + <location filename="../src/TMainConsole.cpp" line="1754"/> <source>[ INFO ] - Map file located and opened, now parsing it...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1632"/> + <location filename="../src/TMainConsole.cpp" line="1761"/> <source>Importing map(1) "%1" at %2 report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1635"/> + <location filename="../src/TMainConsole.cpp" line="1764"/> <source>[ INFO ] - Map file located but it could not opened, please check permissions on:"%1".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1638"/> + <location filename="../src/TMainConsole.cpp" line="1767"/> <source>loadMap: bad argument #1 value (filename used: "%1" could not be opened for reading).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1662"/> + <location filename="../src/TMainConsole.cpp" line="1791"/> <source>[ INFO ] - Map reload request received from system...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1667"/> + <location filename="../src/TMainConsole.cpp" line="1796"/> <source>[ OK ] - ... System Map reload request completed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1669"/> + <location filename="../src/TMainConsole.cpp" line="1798"/> <source>[ WARN ] - ... System Map reload request failed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1696"/> + <location filename="../src/TMainConsole.cpp" line="2122"/> <source>+--------------------------------------------------------------+ | system statistics | +--------------------------------------------------------------+</source> @@ -3972,110 +3972,110 @@ Tried these locations: <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1706"/> + <location filename="../src/TMainConsole.cpp" line="2132"/> <source>GMCP events:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1711"/> + <location filename="../src/TMainConsole.cpp" line="2137"/> <source>ATCP events:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1716"/> + <location filename="../src/TMainConsole.cpp" line="2142"/> <source>Channel102 events:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1721"/> + <location filename="../src/TMainConsole.cpp" line="2147"/> <source>MXP events:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1726"/> + <location filename="../src/TMainConsole.cpp" line="2152"/> <source>MSSP events:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1732"/> + <location filename="../src/TMainConsole.cpp" line="2158"/> <source>MSDP events:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1745"/> + <location filename="../src/TMainConsole.cpp" line="2171"/> <source>Telnet Options:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1749"/> + <location filename="../src/TMainConsole.cpp" line="2175"/> <source>Trigger Report:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1754"/> + <location filename="../src/TMainConsole.cpp" line="2180"/> <source>Timer Report:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1760"/> + <location filename="../src/TMainConsole.cpp" line="2186"/> <source>Alias Report:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1765"/> + <location filename="../src/TMainConsole.cpp" line="2191"/> <source>Keybinding Report:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1770"/> + <location filename="../src/TMainConsole.cpp" line="2196"/> <source>Script Report:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1775"/> + <location filename="../src/TMainConsole.cpp" line="2201"/> <source>Gif Report:</source> <extracomment>Heading for the system's statistics information displayed in the console</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1825"/> + <location filename="../src/TMainConsole.cpp" line="2251"/> <source>Save profile?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1825"/> + <location filename="../src/TMainConsole.cpp" line="2251"/> <source>Do you want to save the profile %1?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1839"/> + <location filename="../src/TMainConsole.cpp" line="2265"/> <source>Could not save profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1839"/> + <location filename="../src/TMainConsole.cpp" line="2265"/> <source>Sorry, could not save your profile as "%1" - got the following error: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1848"/> + <location filename="../src/TMainConsole.cpp" line="2274"/> <source>Could not save map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMainConsole.cpp" line="1849"/> + <location filename="../src/TMainConsole.cpp" line="2275"/> <source>Sorry, could not save the map. Would you like to retry or close without saving the map?</source> <translation type="unfinished"></translation> </message> @@ -4083,118 +4083,118 @@ Tried these locations: <context> <name>TMap</name> <message> - <location filename="../src/TMap.cpp" line="612"/> + <location filename="../src/TMap.cpp" line="617"/> <source>[ INFO ] - CONVERTING: old style label, areaID:%1 labelID:%2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="615"/> + <location filename="../src/TMap.cpp" line="620"/> <source>[ INFO ] - Converting old style label id: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="620"/> + <location filename="../src/TMap.cpp" line="625"/> <source>[ WARN ] - CONVERTING: cannot convert old style label in area with id: %1, label id is: %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="623"/> + <location filename="../src/TMap.cpp" line="628"/> <source>[ WARN ] - CONVERTING: cannot convert old style label with id: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="650"/> + <location filename="../src/TMap.cpp" line="655"/> <source>[ OK ] - Auditing of map completed (%1s). Enjoy your game...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="83"/> + <location filename="../src/TMap.cpp" line="88"/> <source>Default Area</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="84"/> + <location filename="../src/TMap.cpp" line="89"/> <source>Unnamed Area</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="579"/> + <location filename="../src/TMap.cpp" line="584"/> <source>[ INFO ] - Map audit starting...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1565"/> + <location filename="../src/TMap.cpp" line="1576"/> <source>[ INFO ] - You might wish to donate THIS map file to the Mudlet Museum! There is so much data that it DOES NOT have that you could be better off starting again...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1630"/> + <location filename="../src/TMap.cpp" line="1641"/> <source>[ ALERT ] - Failed to load a Mudlet JSON Map file, reason: %1; the file is: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1636"/> + <location filename="../src/TMap.cpp" line="1647"/> <source>[ INFO ] - Ignoring this map file.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1808"/> + <location filename="../src/TMap.cpp" line="1825"/> <source>[ INFO ] - Default (reset) area (for rooms that have not been assigned to an area) not found, adding reserved -1 id.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1899"/> + <location filename="../src/TMap.cpp" line="1916"/> <source>[ INFO ] - Successfully read the map file (%1s), checking some consistency details...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2413"/> + <location filename="../src/TMap.cpp" line="2430"/> <source>Map issues</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2420"/> + <location filename="../src/TMap.cpp" line="2437"/> <source>Area issues</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2426"/> + <location filename="../src/TMap.cpp" line="2443"/> <source>Area id: %1 "%2"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2428"/> + <location filename="../src/TMap.cpp" line="2445"/> <source>Area id: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2437"/> + <location filename="../src/TMap.cpp" line="2454"/> <source>Room issues</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2444"/> + <location filename="../src/TMap.cpp" line="2461"/> <source>Room id: %1 "%2"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2446"/> + <location filename="../src/TMap.cpp" line="2463"/> <source>Room id: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2456"/> + <location filename="../src/TMap.cpp" line="2473"/> <source>End of report</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2462"/> + <location filename="../src/TMap.cpp" line="2479"/> <source>[ ALERT ] - At least one thing was detected during that last map operation that it is recommended that you review the most recent report in the file: @@ -4204,7 +4204,7 @@ the file: <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2470"/> + <location filename="../src/TMap.cpp" line="2487"/> <source>[ INFO ] - The equivalent to the above information about that last map operation has been saved for review as the most recent report in the file: @@ -4214,21 +4214,29 @@ the file: <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2491"/> + <location filename="../src/TMap.cpp" line="2508"/> <source>[ WARN ] - Attempt made to download an XML map when one has already been requested or is being imported from a local file - wait for that operation to complete (if it cannot be canceled) before retrying!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2512"/> + <location filename="../src/TMap.cpp" line="2517"/> + <source>[ WARN ] - Attempt made to download an XML map while a map import or +export is already in progress - wait for that operation to complete +before retrying!</source> + <extracomment>Shown in the main console when a map download is refused</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/TMap.cpp" line="2538"/> <source>[ WARN ] - Attempt made to download an XML from an invalid URL. The URL was: %1 and the error message (may contain technical details) was:"%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2526"/> + <location filename="../src/TMap.cpp" line="2552"/> <source>[ ERROR ] - Unable to use or create directory to store map. Please check that you have permissions/access to: "%1" @@ -4236,257 +4244,272 @@ and there is enough space. The download operation has failed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2552"/> + <location filename="../src/TMap.cpp" line="2578"/> <source>[ INFO ] - Map download initiated, please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2687"/> + <location filename="../src/TMap.cpp" line="2620"/> + <source>loadMap: unable to perform request, a map import or export is +already in progress.</source> + <extracomment>Error returned by the loadMap() Lua function</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/TMap.cpp" line="2624"/> + <source>[ WARN ] - Attempt made to import an XML map while a map import or +export is already in progress - wait for that operation to complete +before retrying!</source> + <extracomment>Shown in the main console when a map import is refused</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/TMap.cpp" line="2730"/> <source>[ ERROR ] - Map download encountered an error: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2737"/> + <location filename="../src/TMap.cpp" line="2780"/> <source>[ ALERT ] - Map download failed, unable to save destination file: %1 reason: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3015"/> + <location filename="../src/TMap.cpp" line="3069"/> <source>Map JSON export</source> <extracomment>This is a title of a progress window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3240"/> + <location filename="../src/TMap.cpp" line="3288"/> <source>Map JSON import</source> <extracomment>This is a title of a progress window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2999"/> - <location filename="../src/TMap.cpp" line="3468"/> + <location filename="../src/TMap.cpp" line="3070"/> + <location filename="../src/TMap.cpp" line="3519"/> <source>Exporting JSON map data from %1 Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3144"/> + <location filename="../src/TMap.cpp" line="3203"/> <source>Exporting JSON map file from %1 - writing data to file: %2 ...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3171"/> + <location filename="../src/TMap.cpp" line="3229"/> <source>import or export already in progress</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3177"/> + <location filename="../src/TMap.cpp" line="3235"/> <source>could not open file</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3186"/> + <location filename="../src/TMap.cpp" line="3244"/> <source>could not parse file, reason: "%1" at offset %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3192"/> + <location filename="../src/TMap.cpp" line="3250"/> <source>empty Json file, no map data detected</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3206"/> + <location filename="../src/TMap.cpp" line="3264"/> <source>invalid format version "%1" detected</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3211"/> + <location filename="../src/TMap.cpp" line="3269"/> <source>no format version detected</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3215"/> + <location filename="../src/TMap.cpp" line="3273"/> <source>no areas detected</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3336"/> + <location filename="../src/TMap.cpp" line="3388"/> <source>aborted by user</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="3224"/> - <location filename="../src/TMap.cpp" line="3478"/> + <location filename="../src/TMap.cpp" line="3289"/> + <location filename="../src/TMap.cpp" line="3529"/> <source>Importing JSON map data to %1 Areas: %2 of: %3 Rooms: %4 of: %5 Labels: %6 of: %7...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="158"/> + <location filename="../src/TMap.cpp" line="163"/> <source>[MAP ERROR:] %1</source> <extracomment>Used to print a map error in the Errors console in the Editor, %1 is the message text and a line-feed is also appended.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="181"/> + <location filename="../src/TMap.cpp" line="186"/> <source>Can not set room with RoomID %1 to AreaID %2. Room does not exist!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="191"/> + <location filename="../src/TMap.cpp" line="196"/> <source>Can not set room with RoomID %1 to AreaID %2. Area does not exist!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1115"/> + <location filename="../src/TMap.cpp" line="1120"/> <source>[ ERROR ] - The format version "%1" you are trying to save the map with is too new for this version of Mudlet. Supported are only formats up to version %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1131"/> + <location filename="../src/TMap.cpp" line="1136"/> <source>[ ALERT ] - Saving map in format version "%1" that is different than "%2" which it was loaded as. This may be an issue if you want to share the resulting map with others relying on the original format.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1141"/> + <location filename="../src/TMap.cpp" line="1146"/> <source>[ WARN ] - Saving map in format version "%1" different from the recommended map version %2 for this version of Mudlet.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1505"/> - <location filename="../src/TMap.cpp" line="1942"/> + <location filename="../src/TMap.cpp" line="1516"/> + <location filename="../src/TMap.cpp" line="1959"/> <source>[ ERROR ] - Unable to open map file for reading: "%1"!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1527"/> + <location filename="../src/TMap.cpp" line="1538"/> <source>[ ALERT ] - File does not seem to be a Mudlet Map file. The part that indicates its format version seems to be "%1" and that doesn't make sense. The file is: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1542"/> + <location filename="../src/TMap.cpp" line="1553"/> <source>[ ALERT ] - Map file is too new. Its format version "%1" is higher than this version of Mudlet can handle (%2)! The file is: "%3".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1549"/> + <location filename="../src/TMap.cpp" line="1560"/> <source>[ INFO ] - You will need to update your Mudlet to read the map file.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1558"/> + <location filename="../src/TMap.cpp" line="1569"/> <source>[ ALERT ] - Map file is really old. Its format version "%1" is so ancient that this version of Mudlet may not gain enough information from it but it will try! The file is: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1572"/> + <location filename="../src/TMap.cpp" line="1583"/> <source>[ INFO ] - Reading map. Format version: %1. File: "%2", please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1577"/> + <location filename="../src/TMap.cpp" line="1588"/> <source>[ INFO ] - Reading map. Format version: %1. File: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1958"/> + <location filename="../src/TMap.cpp" line="1975"/> <source>[ INFO ] - Checking map file "%1", format version "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2559"/> + <location filename="../src/TMap.cpp" line="2585"/> <source>Downloading map file for use in %1...</source> <extracomment>%1 is the name of the current Mudlet profile</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2825"/> - <location filename="../src/TMap.cpp" line="3008"/> - <location filename="../src/TMap.cpp" line="3233"/> + <location filename="../src/TMap.cpp" line="2873"/> + <location filename="../src/TMap.cpp" line="3079"/> + <location filename="../src/TMap.cpp" line="3298"/> <source>Abort</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="1534"/> + <location filename="../src/TMap.cpp" line="1545"/> <source>[ INFO ] - Ignoring this unlikely map file.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2561"/> + <location filename="../src/TMap.cpp" line="2587"/> <source>Map download</source> <extracomment>This is a title of a progress window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2578"/> + <location filename="../src/TMap.cpp" line="2604"/> <source>loadMap: unable to perform request, a map is already being downloaded or imported at user request.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2608"/> + <location filename="../src/TMap.cpp" line="2651"/> <source>Importing XML map file for use in %1...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2608"/> + <location filename="../src/TMap.cpp" line="2651"/> <source>Map import</source> <extracomment>This is a title of a progress window.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2629"/> - <location filename="../src/TMap.cpp" line="2636"/> + <location filename="../src/TMap.cpp" line="2672"/> + <location filename="../src/TMap.cpp" line="2679"/> <source>loadMap: failure to import XML map file, further information may be available in main console!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2671"/> + <location filename="../src/TMap.cpp" line="2714"/> <source>[ ALERT ] - Map download was canceled, on user's request.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2724"/> + <location filename="../src/TMap.cpp" line="2767"/> <source>[ ALERT ] - Map download failed, unable to open destination file: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2731"/> + <location filename="../src/TMap.cpp" line="2774"/> <source>[ ALERT ] - Map download failed, unable to write destination file: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2750"/> + <location filename="../src/TMap.cpp" line="2793"/> <source>[ INFO ] - ... map downloaded and stored, now parsing it...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2786"/> + <location filename="../src/TMap.cpp" line="2829"/> <source>[ ERROR ] - Map download problem, failure in parsing destination file: %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMap.cpp" line="2766"/> + <location filename="../src/TMap.cpp" line="2809"/> <source>[ ERROR ] - Map download problem, unable to read destination file: %1.</source> <translation type="unfinished"></translation> @@ -4525,58 +4548,65 @@ in main console!</source> <context> <name>TMedia</name> <message> - <location filename="../src/TMedia.cpp" line="321"/> + <location filename="../src/TMedia.cpp" line="349"/> <source>fades</source> <extracomment>This word is part of a sentence like "Music fades" when the music is about to stop.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="1090"/> + <location filename="../src/TMedia.cpp" line="1359"/> <source>Too many stopped media players. Purging stopped players.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="1098"/> + <location filename="../src/TMedia.cpp" line="1367"/> <source>Too many stopped media players. Removed oldest active player.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="1190"/> + <location filename="../src/TMedia.cpp" line="1459"/> <source>Maximum allowed active media players reached for media type. Cannot play additional media.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="1299"/> + <location filename="../src/TMedia.cpp" line="651"/> + <location filename="../src/TMedia.cpp" line="1644"/> <source>stops</source> <extracomment>This word is part of a sentence like "Music stops" when the music is about to stop.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="1323"/> + <location filename="../src/TMedia.cpp" line="1221"/> + <source>Media error: %1</source> + <extracomment>%1 is the media backend's own description of what went wrong, e.g. "Failed to load media".</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/TMedia.cpp" line="1692"/> <source>plays</source> <extracomment>This word is part of a sentence like "Music plays" when the music is starting to play.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="1347"/> + <location filename="../src/TMedia.cpp" line="1716"/> <source>pauses</source> <extracomment>This word is part of a sentence like "Music pauses" when the music stops playing for a while.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="2094"/> + <location filename="../src/TMedia.cpp" line="2413"/> <source>music</source> <extracomment>This word is part of a sentence like "Music stops" when Mudlet handles a piece of music.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="2096"/> + <location filename="../src/TMedia.cpp" line="2415"/> <source>video</source> <extracomment>This word is part of a sentence like "Video stops" when Mudlet handles a video.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TMedia.cpp" line="2098"/> + <location filename="../src/TMedia.cpp" line="2417"/> <source>sound</source> <translation type="unfinished"></translation> </message> @@ -4585,7 +4615,7 @@ in main console!</source> <name>TRoom</name> <message> <location filename="../src/TRoom.cpp" line="87"/> - <location filename="../src/TRoom.cpp" line="1092"/> + <location filename="../src/TRoom.cpp" line="1105"/> <source>North</source> <translation type="unfinished"></translation> </message> @@ -4601,7 +4631,7 @@ in main console!</source> </message> <message> <location filename="../src/TRoom.cpp" line="93"/> - <location filename="../src/TRoom.cpp" line="1134"/> + <location filename="../src/TRoom.cpp" line="1147"/> <source>South</source> <translation type="unfinished"></translation> </message> @@ -4617,37 +4647,37 @@ in main console!</source> </message> <message> <location filename="../src/TRoom.cpp" line="99"/> - <location filename="../src/TRoom.cpp" line="1176"/> + <location filename="../src/TRoom.cpp" line="1189"/> <source>East</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/TRoom.cpp" line="101"/> - <location filename="../src/TRoom.cpp" line="1190"/> + <location filename="../src/TRoom.cpp" line="1203"/> <source>West</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/TRoom.cpp" line="103"/> - <location filename="../src/TRoom.cpp" line="1204"/> + <location filename="../src/TRoom.cpp" line="1217"/> <source>Up</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/TRoom.cpp" line="105"/> - <location filename="../src/TRoom.cpp" line="1218"/> + <location filename="../src/TRoom.cpp" line="1231"/> <source>Down</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/TRoom.cpp" line="107"/> - <location filename="../src/TRoom.cpp" line="1232"/> + <location filename="../src/TRoom.cpp" line="1245"/> <source>In</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/TRoom.cpp" line="109"/> - <location filename="../src/TRoom.cpp" line="1246"/> + <location filename="../src/TRoom.cpp" line="1259"/> <source>Out</source> <translation type="unfinished"></translation> </message> @@ -4662,99 +4692,99 @@ in main console!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1106"/> + <location filename="../src/TRoom.cpp" line="1119"/> <source>Northeast</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1120"/> + <location filename="../src/TRoom.cpp" line="1133"/> <source>Northwest</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1148"/> + <location filename="../src/TRoom.cpp" line="1161"/> <source>Southeast</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1162"/> + <location filename="../src/TRoom.cpp" line="1175"/> <source>Southwest</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1268"/> + <location filename="../src/TRoom.cpp" line="1281"/> <source>[ WARN ] - In room ID: %1 removing invalid (special) exit to %2 (with no name!)</source> <extracomment>%1 is the room ID, %2 is the destination room ID</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1281"/> + <location filename="../src/TRoom.cpp" line="1294"/> <source>[ INFO ] - In room with ID: %1 correcting special exit "%2" that was to room with an exit to invalid room: %3 to now go to: %4.</source> <extracomment>%1 is the room ID, %2 is the exit name, %3 is the old destination room ID, %4 is the new destination room ID</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1312"/> + <location filename="../src/TRoom.cpp" line="1325"/> <source>[ WARN ] - Room with ID: %1 has a special exit "%2" with an exit to: %3 but that room does not exist. The exit will be removed (but the destination room ID will be stored in the room user data under a key: "%4").</source> <extracomment>%1 is the room ID, %2 is the exit name, %3 is the destination room ID, %4 is the audit key</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1356"/> + <location filename="../src/TRoom.cpp" line="1369"/> <source>[ INFO ] - In room with ID: %1 special exit "%2" that was to room with an invalid ID: %3 that does not exist. The exit will be removed (the bad destination room ID will be stored in the room user data under a key: "%4").</source> <extracomment>%1 is the room ID, %2 is the exit name, %3 is the invalid destination room ID, %4 is the audit key</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1409"/> + <location filename="../src/TRoom.cpp" line="1422"/> <source>[ INFO ] - In room with ID: %1 found one or more surplus door items that were removed: %2.</source> <extracomment>%1 is the room ID, %2 is a list of door items</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1426"/> + <location filename="../src/TRoom.cpp" line="1439"/> <source>[ INFO ] - In room with ID: %1 found one or more surplus weight items that were removed: %2.</source> <extracomment>%1 is the room ID, %2 is a list of weight items</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1443"/> + <location filename="../src/TRoom.cpp" line="1456"/> <source>[ INFO ] - In room with ID: %1 found one or more surplus exit lock items that were removed: %2.</source> <extracomment>%1 is the room ID, %2 is a list of exit lock items</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1523"/> + <location filename="../src/TRoom.cpp" line="1536"/> <source>[ INFO ] - In room with ID: %1 found one or more surplus custom line elements that were removed: %2.</source> <extracomment>%1 is the room ID, %2 is a list of custom line elements</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1550"/> + <location filename="../src/TRoom.cpp" line="1563"/> <source>[ INFO ] - In room with ID: %1 correcting exit "%2" that was to room with an exit to invalid room: %3 to now go to: %4.</source> <extracomment>%1 is the room ID, %2 is the exit direction, %3 is the old destination room ID, %4 is the new destination room ID</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1569"/> + <location filename="../src/TRoom.cpp" line="1582"/> <source>[ WARN ] - Room with ID: %1 has an exit "%2" to: %3 but that room does not exist. The exit will be removed (but the destination room ID will be stored in the room user data under a key: "%4") and the exit will be turned into a stub.</source> <extracomment>%1 is the room ID, %2 is the exit direction, %3 is the destination room ID that doesn't exist, %4 is the audit key</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1617"/> + <location filename="../src/TRoom.cpp" line="1630"/> <source>[ ALERT ] - Room with ID: %1 has an exit "%2" to: %3 but also has a stub exit in the same direction! As a real exit precludes a stub, the latter will be removed.</source> <extracomment>%1 is the room ID, %2 is the exit direction, %3 is the destination room ID</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1675"/> + <location filename="../src/TRoom.cpp" line="1688"/> <source>[ INFO ] - In room with ID: %1 exit "%2" that was to room with an invalid ID: %3 that does not exist. The exit will be removed (the bad destination room ID will be stored in the room user data under a key: "%4") and the exit will be turned into a stub.</source> <extracomment>%1 is the room ID, %2 is the exit direction, %3 is the invalid destination room ID, %4 is the audit key</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1393"/> + <location filename="../src/TRoom.cpp" line="1406"/> <source>%1 {none}</source> <translation type="unfinished"></translation> </message> @@ -4775,33 +4805,33 @@ in main console!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1396"/> + <location filename="../src/TRoom.cpp" line="1409"/> <source>%1 (open)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1399"/> + <location filename="../src/TRoom.cpp" line="1412"/> <source>%1 (closed)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1402"/> + <location filename="../src/TRoom.cpp" line="1415"/> <source>%1 (locked)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1405"/> + <location filename="../src/TRoom.cpp" line="1418"/> <source>%1 {invalid}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1695"/> + <location filename="../src/TRoom.cpp" line="1708"/> <source>It had a weight, this is recorded as user data with key: "%1".</source> <extracomment>%1 is the audit key for the weight</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TRoom.cpp" line="1705"/> + <location filename="../src/TRoom.cpp" line="1718"/> <source>[ WARN ] - There was a custom exit line associated with the invalid exit but it has not been possible to salvage this, it has been lost!</source> <translation type="unfinished"></translation> </message> @@ -5090,499 +5120,499 @@ area) not found, adding "%1" against the reserved -1 id.</source> <context> <name>TTextEdit</name> <message> - <location filename="../src/TTextEdit.cpp" line="2446"/> + <location filename="../src/TTextEdit.cpp" line="2445"/> <source>Copy</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2457"/> + <location filename="../src/TTextEdit.cpp" line="2456"/> <source>Copy HTML</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2461"/> + <location filename="../src/TTextEdit.cpp" line="2460"/> <source>Copy as image</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2464"/> + <location filename="../src/TTextEdit.cpp" line="2463"/> <source>Select all</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2468"/> + <location filename="../src/TTextEdit.cpp" line="2467"/> <source>Unknown</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2469"/> + <location filename="../src/TTextEdit.cpp" line="2468"/> <source>Search on %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2484"/> + <location filename="../src/TTextEdit.cpp" line="2483"/> <source>Analyse characters</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2493"/> + <location filename="../src/TTextEdit.cpp" line="2492"/> <source>Hover on this item to display the Unicode codepoints in the selection <i>(only the first line!)</i></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2502"/> + <location filename="../src/TTextEdit.cpp" line="2501"/> <source>restore Main menu</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2504"/> + <location filename="../src/TTextEdit.cpp" line="2503"/> <source>Use this to restore the Main menu to get access to controls.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2506"/> + <location filename="../src/TTextEdit.cpp" line="2505"/> <source>restore Main Toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2508"/> + <location filename="../src/TTextEdit.cpp" line="2507"/> <source>Use this to restore the Main Toolbar to get access to controls.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2516"/> + <location filename="../src/TTextEdit.cpp" line="2515"/> <source>Clear console</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2519"/> + <location filename="../src/TTextEdit.cpp" line="2518"/> <source>*** starting new session ***</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2728"/> + <location filename="../src/TTextEdit.cpp" line="2727"/> <source>{tab}</source> <extracomment>Unicode U+0009 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2730"/> + <location filename="../src/TTextEdit.cpp" line="2729"/> <source>{line-feed}</source> <extracomment>Unicode U+000A codepoint. Not likely to be seen as it gets filtered out.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2732"/> + <location filename="../src/TTextEdit.cpp" line="2731"/> <source>{carriage-return}</source> <extracomment>Unicode U+000D codepoint. Not likely to be seen as it gets filtered out.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2734"/> + <location filename="../src/TTextEdit.cpp" line="2733"/> <source>{space}</source> <extracomment>Unicode U+0020 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2736"/> + <location filename="../src/TTextEdit.cpp" line="2735"/> <source>{non-breaking space}</source> <extracomment>Unicode U+00A0 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2738"/> + <location filename="../src/TTextEdit.cpp" line="2737"/> <source>{soft hyphen}</source> <extracomment>Unicode U+00AD codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2740"/> + <location filename="../src/TTextEdit.cpp" line="2739"/> <source>{combining grapheme joiner}</source> <extracomment>Unicode U+034F codepoint (badly named apparently - see Wikipedia!)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2742"/> + <location filename="../src/TTextEdit.cpp" line="2741"/> <source>{ogham space mark}</source> <extracomment>Unicode U+1680 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2744"/> + <location filename="../src/TTextEdit.cpp" line="2743"/> <source>{'n' quad}</source> <extracomment>Unicode U+2000 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2746"/> + <location filename="../src/TTextEdit.cpp" line="2745"/> <source>{'m' quad}</source> <extracomment>Unicode U+2001 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2748"/> + <location filename="../src/TTextEdit.cpp" line="2747"/> <source>{'n' space}</source> <extracomment>Unicode U+2002 codepoint - En ('n') wide space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2750"/> + <location filename="../src/TTextEdit.cpp" line="2749"/> <source>{'m' space}</source> <extracomment>Unicode U+2003 codepoint - Em ('m') wide space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2752"/> + <location filename="../src/TTextEdit.cpp" line="2751"/> <source>{3-per-em space}</source> <extracomment>Unicode U+2004 codepoint - three-per-em ('m') wide (thick) space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2754"/> + <location filename="../src/TTextEdit.cpp" line="2753"/> <source>{4-per-em space}</source> <extracomment>Unicode U+2005 codepoint - four-per-em ('m') wide (Middle) space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2756"/> + <location filename="../src/TTextEdit.cpp" line="2755"/> <source>{6-per-em space}</source> <extracomment>Unicode U+2006 codepoint - six-per-em ('m') wide (Sometimes the same as a Thin) space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2758"/> + <location filename="../src/TTextEdit.cpp" line="2757"/> <source>{digit space}</source> <extracomment>Unicode U+2007 codepoint - figure (digit) wide space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2760"/> + <location filename="../src/TTextEdit.cpp" line="2759"/> <source>{punctuation wide space}</source> <extracomment>Unicode U+2008 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2762"/> + <location filename="../src/TTextEdit.cpp" line="2761"/> <source>{5-per-em space}</source> <extracomment>Unicode U+2009 codepoint - five-per-em ('m') wide space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2764"/> + <location filename="../src/TTextEdit.cpp" line="2763"/> <source>{hair width space}</source> <extracomment>Unicode U+200A codepoint - thinnest space.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2766"/> + <location filename="../src/TTextEdit.cpp" line="2765"/> <source>{zero width space}</source> <extracomment>Unicode U+200B codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2768"/> + <location filename="../src/TTextEdit.cpp" line="2767"/> <source>{Zero width non-joiner}</source> <extracomment>Unicode U+200C codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2770"/> + <location filename="../src/TTextEdit.cpp" line="2769"/> <source>{zero width joiner}</source> <extracomment>Unicode U+200D codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2772"/> + <location filename="../src/TTextEdit.cpp" line="2771"/> <source>{left-to-right mark}</source> <extracomment>Unicode U+200E codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2774"/> + <location filename="../src/TTextEdit.cpp" line="2773"/> <source>{right-to-left mark}</source> <extracomment>Unicode U+200F codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2776"/> + <location filename="../src/TTextEdit.cpp" line="2775"/> <source>{line separator}</source> <extracomment>Unicode 0x2028 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2778"/> + <location filename="../src/TTextEdit.cpp" line="2777"/> <source>{paragraph separator}</source> <extracomment>Unicode U+2029 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2780"/> + <location filename="../src/TTextEdit.cpp" line="2779"/> <source>{Left-to-right embedding}</source> <extracomment>Unicode U+202A codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2782"/> + <location filename="../src/TTextEdit.cpp" line="2781"/> <source>{right-to-left embedding}</source> <extracomment>Unicode U+202B codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2784"/> + <location filename="../src/TTextEdit.cpp" line="2783"/> <source>{pop directional formatting}</source> <extracomment>Unicode U+202C codepoint - pop (undo last) directional formatting.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2786"/> + <location filename="../src/TTextEdit.cpp" line="2785"/> <source>{Left-to-right override}</source> <extracomment>Unicode U+202D codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2788"/> + <location filename="../src/TTextEdit.cpp" line="2787"/> <source>{right-to-left override}</source> <extracomment>Unicode U+202E codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2790"/> + <location filename="../src/TTextEdit.cpp" line="2789"/> <source>{narrow width no-break space}</source> <extracomment>Unicode U+202F codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2792"/> + <location filename="../src/TTextEdit.cpp" line="2791"/> <source>{medium width mathematical space}</source> <extracomment>Unicode U+205F codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2794"/> + <location filename="../src/TTextEdit.cpp" line="2793"/> <source>{zero width non-breaking space}</source> <extracomment>Unicode U+2060 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2796"/> + <location filename="../src/TTextEdit.cpp" line="2795"/> <source>{function application}</source> <extracomment>Unicode U+2061 codepoint - function application (whatever that means!)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2798"/> + <location filename="../src/TTextEdit.cpp" line="2797"/> <source>{invisible times}</source> <extracomment>Unicode U+2062 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2800"/> + <location filename="../src/TTextEdit.cpp" line="2799"/> <source>{invisible separator}</source> <extracomment>Unicode U+2063 codepoint - invisible separator or comma.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2802"/> + <location filename="../src/TTextEdit.cpp" line="2801"/> <source>{invisible plus}</source> <extracomment>Unicode U+2064 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2804"/> + <location filename="../src/TTextEdit.cpp" line="2803"/> <source>{left-to-right isolate}</source> <extracomment>Unicode U+2066 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2806"/> + <location filename="../src/TTextEdit.cpp" line="2805"/> <source>{right-to-left isolate}</source> <extracomment>Unicode U+2067 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2808"/> + <location filename="../src/TTextEdit.cpp" line="2807"/> <source>{first strong isolate}</source> <extracomment>Unicode U+2068 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2810"/> + <location filename="../src/TTextEdit.cpp" line="2809"/> <source>{pop directional isolate}</source> <extracomment>Unicode U+2069 codepoint - pop (undo last) directional isolate.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2812"/> + <location filename="../src/TTextEdit.cpp" line="2811"/> <source>{inhibit symmetrical swapping}</source> <extracomment>Unicode U+206A codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2814"/> + <location filename="../src/TTextEdit.cpp" line="2813"/> <source>{activate symmetrical swapping}</source> <extracomment>Unicode U+206B codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2816"/> + <location filename="../src/TTextEdit.cpp" line="2815"/> <source>{inhibit arabic form-shaping}</source> <extracomment>Unicode U+206C codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2818"/> + <location filename="../src/TTextEdit.cpp" line="2817"/> <source>{activate arabic form-shaping}</source> <extracomment>Unicode U+206D codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2820"/> + <location filename="../src/TTextEdit.cpp" line="2819"/> <source>{national digit shapes}</source> <extracomment>Unicode U+206E codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2822"/> + <location filename="../src/TTextEdit.cpp" line="2821"/> <source>{nominal Digit shapes}</source> <extracomment>Unicode U+206F codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2824"/> + <location filename="../src/TTextEdit.cpp" line="2823"/> <source>{ideographic space}</source> <extracomment>Unicode U+3000 codepoint - ideographic (CJK Wide) space</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2826"/> + <location filename="../src/TTextEdit.cpp" line="2825"/> <source>{variation selector 1}</source> <extracomment>Unicode U+FE00 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2828"/> + <location filename="../src/TTextEdit.cpp" line="2827"/> <source>{variation selector 2}</source> <extracomment>Unicode U+FE01 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2830"/> + <location filename="../src/TTextEdit.cpp" line="2829"/> <source>{variation selector 3}</source> <extracomment>Unicode U+FE02 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2832"/> + <location filename="../src/TTextEdit.cpp" line="2831"/> <source>{variation selector 4}</source> <extracomment>Unicode U+FE03 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2834"/> + <location filename="../src/TTextEdit.cpp" line="2833"/> <source>{variation selector 5}</source> <extracomment>Unicode U+FE04 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2836"/> + <location filename="../src/TTextEdit.cpp" line="2835"/> <source>{variation selector 6}</source> <extracomment>Unicode U+FE05 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2838"/> + <location filename="../src/TTextEdit.cpp" line="2837"/> <source>{variation selector 7}</source> <extracomment>Unicode U+FE06 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2840"/> + <location filename="../src/TTextEdit.cpp" line="2839"/> <source>{variation selector 8}</source> <extracomment>Unicode U+FE07 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2842"/> + <location filename="../src/TTextEdit.cpp" line="2841"/> <source>{variation selector 9}</source> <extracomment>Unicode U+FE08 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2844"/> + <location filename="../src/TTextEdit.cpp" line="2843"/> <source>{variation selector 10}</source> <extracomment>Unicode U+FE09 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2846"/> + <location filename="../src/TTextEdit.cpp" line="2845"/> <source>{variation selector 11}</source> <extracomment>Unicode U+FE0A codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2848"/> + <location filename="../src/TTextEdit.cpp" line="2847"/> <source>{variation selector 12}</source> <extracomment>Unicode U+FE0B codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2850"/> + <location filename="../src/TTextEdit.cpp" line="2849"/> <source>{variation selector 13}</source> <extracomment>Unicode U+FE0C codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2852"/> + <location filename="../src/TTextEdit.cpp" line="2851"/> <source>{variation selector 14}</source> <extracomment>Unicode U+FE0D codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2854"/> + <location filename="../src/TTextEdit.cpp" line="2853"/> <source>{variation selector 15}</source> <extracomment>Unicode U+FE0E codepoint - after an Emoji codepoint forces the textual (black & white) rendition.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2856"/> + <location filename="../src/TTextEdit.cpp" line="2855"/> <source>{variation selector 16}</source> <extracomment>Unicode U+FE0F codepoint - after an Emoji codepoint forces the proper coloured 'Emoji' rendition.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2858"/> + <location filename="../src/TTextEdit.cpp" line="2857"/> <source>{zero width no-break space}</source> <extracomment>Unicode U+FEFF codepoint - also known as the Byte-order-mark at start of text!).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2866"/> + <location filename="../src/TTextEdit.cpp" line="2865"/> <source>{interlinear annotation anchor}</source> <extracomment>Unicode U+FFF9 codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2868"/> + <location filename="../src/TTextEdit.cpp" line="2867"/> <source>{interlinear annotation separator}</source> <extracomment>Unicode U+FFFA codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2870"/> + <location filename="../src/TTextEdit.cpp" line="2869"/> <source>{interlinear annotation terminator}</source> <extracomment>Unicode U+FFFB codepoint</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2872"/> + <location filename="../src/TTextEdit.cpp" line="2871"/> <source>{object replacement character}</source> <extracomment>Unicode U+FFFC codepoint.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2885"/> - <location filename="../src/TTextEdit.cpp" line="2889"/> - <location filename="../src/TTextEdit.cpp" line="2911"/> + <location filename="../src/TTextEdit.cpp" line="2884"/> + <location filename="../src/TTextEdit.cpp" line="2888"/> + <location filename="../src/TTextEdit.cpp" line="2910"/> <source>{noncharacter}</source> <extracomment>Unicode codepoint in range U+FFD0 to U+FDEF - not a character ---------- @@ -5592,148 +5622,148 @@ Unicode codepoint is U+00xxFFFE or U+00xxFFFF - not a character.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2898"/> + <location filename="../src/TTextEdit.cpp" line="2897"/> <source>{FitzPatrick modifier 1 or 2}</source> <extracomment>Unicode codepoint U+0001F3FB - FitzPatrick modifier (Emoji Human skin-tone) 1-2.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2900"/> + <location filename="../src/TTextEdit.cpp" line="2899"/> <source>{FitzPatrick modifier 3}</source> <extracomment>Unicode codepoint U+0001F3FC - FitzPatrick modifier (Emoji Human skin-tone) 3.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2902"/> + <location filename="../src/TTextEdit.cpp" line="2901"/> <source>{FitzPatrick modifier 4}</source> <extracomment>Unicode codepoint U+0001F3FD - FitzPatrick modifier (Emoji Human skin-tone) 4.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2904"/> + <location filename="../src/TTextEdit.cpp" line="2903"/> <source>{FitzPatrick modifier 5}</source> <extracomment>Unicode codepoint U+0001F3FE - FitzPatrick modifier (Emoji Human skin-tone) 5.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="2906"/> + <location filename="../src/TTextEdit.cpp" line="2905"/> <source>{FitzPatrick modifier 6}</source> <extracomment>Unicode codepoint U+0001F3FF - FitzPatrick modifier (Emoji Human skin-tone) 6.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3191"/> - <location filename="../src/TTextEdit.cpp" line="3257"/> + <location filename="../src/TTextEdit.cpp" line="3190"/> + <location filename="../src/TTextEdit.cpp" line="3256"/> <source>Index (UTF-16)</source> <extracomment>1st Row heading for Text analyser output, table item is the count into the QChars/TChars that make up the text {this translation used 2 times}</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3196"/> - <location filename="../src/TTextEdit.cpp" line="3262"/> + <location filename="../src/TTextEdit.cpp" line="3195"/> + <location filename="../src/TTextEdit.cpp" line="3261"/> <source>U+<i>####</i> Unicode Code-point <i>(High:Low Surrogates)</i></source> <extracomment>2nd Row heading for Text analyser output, table item is the unicode code point (will be between 000001 and 10FFFF in hexadecimal) {this translation used 2 times}</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3201"/> - <location filename="../src/TTextEdit.cpp" line="3267"/> + <location filename="../src/TTextEdit.cpp" line="3200"/> + <location filename="../src/TTextEdit.cpp" line="3266"/> <source>Visual</source> <extracomment>3rd Row heading for Text analyser output, table item is a visual representation of the character/part of the character or a '{'...'}' wrapped letter code if the character is whitespace or otherwise unshowable {this translation used 2 times}</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3206"/> - <location filename="../src/TTextEdit.cpp" line="3272"/> + <location filename="../src/TTextEdit.cpp" line="3205"/> + <location filename="../src/TTextEdit.cpp" line="3271"/> <source>Index (UTF-8)</source> <extracomment>4th Row heading for Text analyser output, table item is the count into the bytes that make up the UTF-8 form of the text that the Lua system uses {this translation used 2 times}</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3211"/> - <location filename="../src/TTextEdit.cpp" line="3277"/> + <location filename="../src/TTextEdit.cpp" line="3210"/> + <location filename="../src/TTextEdit.cpp" line="3276"/> <source>Byte</source> <extracomment>5th Row heading for Text analyser output, table item is the unsigned 8-bit integer for the particular byte in the UTF-8 form of the text that the Lua system uses {this translation used 2 times}</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3217"/> - <location filename="../src/TTextEdit.cpp" line="3283"/> + <location filename="../src/TTextEdit.cpp" line="3216"/> + <location filename="../src/TTextEdit.cpp" line="3282"/> <source>Lua character or code</source> <extracomment>6th Row heading for Text analyser output, table item is either the ASCII character or the numeric code for the byte in the row about this item in the table, as displayed the thing shown can be used in a Lua string entry to reproduce this byte {this translation used 2 times}"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3497"/> + <location filename="../src/TTextEdit.cpp" line="3496"/> <source>link</source> <extracomment>Generic screen-reader announcement for a link with no tooltip or URL — used as fallback link description</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3502"/> + <location filename="../src/TTextEdit.cpp" line="3501"/> <source>, visited</source> <extracomment>Appended to link announcement when the link has been previously visited</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3506"/> + <location filename="../src/TTextEdit.cpp" line="3505"/> <source>, disabled</source> <extracomment>Appended to link announcement when the link is disabled</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3510"/> + <location filename="../src/TTextEdit.cpp" line="3509"/> <source>, selected</source> <extracomment>Appended to link announcement when the link is selected</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3515"/> + <location filename="../src/TTextEdit.cpp" line="3514"/> <source>, has menu</source> <extracomment>Appended to link announcement when the link opens a menu</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3789"/> + <location filename="../src/TTextEdit.cpp" line="3788"/> <source>Wrapping to first link</source> <extracomment>Screen-reader announcement when forward link navigation (Tab / Ctrl+]) wraps past the last link back to the first</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3792"/> + <location filename="../src/TTextEdit.cpp" line="3791"/> <source>Wrapping to last link</source> <extracomment>Screen-reader announcement when backward link navigation (Shift+Tab / Ctrl+[) wraps past the first link back to the last</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3931"/> + <location filename="../src/TTextEdit.cpp" line="3937"/> <source>Jumped to start of buffer.</source> <extracomment>Screen-reader announcement when the user presses Ctrl+Home in caret mode to jump to the start of the buffer</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="3945"/> + <location filename="../src/TTextEdit.cpp" line="3951"/> <source>Jumped to latest content.</source> <extracomment>Screen-reader announcement when the user presses Ctrl+End in caret mode to jump to the latest (most recent) content in the buffer</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="1992"/> + <location filename="../src/TTextEdit.cpp" line="1994"/> <source>Mudlet, debug console extract</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="1994"/> + <location filename="../src/TTextEdit.cpp" line="1996"/> <source>Mudlet, %1 mini-console extract from %2 profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="1996"/> + <location filename="../src/TTextEdit.cpp" line="1998"/> <source>Mudlet, %1 user window extract from %2 profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTextEdit.cpp" line="1998"/> + <location filename="../src/TTextEdit.cpp" line="2000"/> <source>Mudlet, main console extract from %1 profile</source> <translation type="unfinished"></translation> </message> @@ -5741,7 +5771,7 @@ Unicode codepoint is U+00xxFFFE or U+00xxFFFF - not a character.</extracomment> <context> <name>TToolBar</name> <message> - <location filename="../src/TToolBar.cpp" line="74"/> + <location filename="../src/TToolBar.cpp" line="76"/> <source>Toolbar - %1 - %2</source> <translation type="unfinished"></translation> </message> @@ -5769,12 +5799,12 @@ Unicode codepoint is U+00xxFFFE or U+00xxFFFF - not a character.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TTrigger.cpp" line="1084"/> + <location filename="../src/TTrigger.cpp" line="1111"/> <source>Trigger name=%1 expired.</source> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/TTrigger.cpp" line="1089"/> + <location filename="../src/TTrigger.cpp" line="1116"/> <source>Trigger name=%1 will fire %n more time(s).</source> <translation type="unfinished"> <numerusform></numerusform> @@ -5893,6 +5923,29 @@ Unicode codepoint is U+00xxFFFE or U+00xxFFFF - not a character.</extracomment> <translation type="unfinished"></translation> </message> </context> +<context> + <name>TriggerUnit</name> + <message numerus="yes"> + <location filename="../src/TriggerUnit.cpp" line="355"/> + <source>%n trigger(s) created while processing this line have been stopped: temporary ones removed, permanent ones switched off until the profile is reloaded.</source> + <extracomment>%n is a count of triggers. Shown in the game window when a trigger keeps creating new triggers that match the same line, which would otherwise never end</extracomment> + <translation type="unfinished"> + <numerusform></numerusform> + </translation> + </message> + <message> + <location filename="../src/TriggerUnit.cpp" line="360"/> + <source>[ ERROR ] - Trigger processing stopped to prevent a freeze: a trigger (or another trigger it creates) keeps creating new triggers that match the line being processed, so that line never finishes. %1 Create the trigger once, outside its own script, or give it a pattern that does not match the line it is created on.</source> + <extracomment>%1 is the sentence above, about the triggers that were stopped</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/TriggerUnit.cpp" line="366"/> + <source>[ ERROR ] - Trigger processing stopped to prevent a freeze: trigger '%1' (or another trigger it creates) keeps creating new triggers that match the line being processed, so that line never finishes. %2 Create the trigger once, outside its own script, or give it a pattern that does not match the line it is created on.</source> + <extracomment>%1 is the name of a trigger - the name of a trigger made by tempTrigger() and friends is its id number - and %2 is the sentence above, about the triggers that were stopped</extracomment> + <translation type="unfinished"></translation> + </message> +</context> <context> <name>UpdateDialog</name> <message> @@ -5970,9 +6023,9 @@ Would you like to update now?</source> <context> <name>Updater</name> <message> - <location filename="../src/updater.cpp" line="82"/> - <location filename="../src/updater.cpp" line="334"/> - <location filename="../src/updater.cpp" line="379"/> + <location filename="../src/updater.cpp" line="83"/> + <location filename="../src/updater.cpp" line="362"/> + <location filename="../src/updater.cpp" line="407"/> <source>Update</source> <extracomment>Label for the update/restart button in the main toolbar ---------- @@ -5980,54 +6033,54 @@ Label for the update button shown in the update dialog</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="211"/> + <location filename="../src/updater.cpp" line="239"/> <source>Changelog Error</source> <extracomment>Error title for dialog shown when changelog fails to load</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="213"/> + <location filename="../src/updater.cpp" line="241"/> <source>Could not load the changelog. Please try again later.</source> <extracomment>Error message shown when changelog fails to load from the server</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="236"/> + <location filename="../src/updater.cpp" line="264"/> <source>No download available for version %1. Please try again later or download manually from https://www.mudlet.org/download/</source> <extracomment>Error shown when no download is available for the user's platform. %1 is the version number.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="317"/> - <location filename="../src/updater.cpp" line="362"/> + <location filename="../src/updater.cpp" line="345"/> + <location filename="../src/updater.cpp" line="390"/> <source>Update download failed. Please try again or download manually from https://www.mudlet.org/download/</source> <extracomment>Error shown when the automatic update download finished but produced no file</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="420"/> + <location filename="../src/updater.cpp" line="448"/> <source>Failed to extract the update. Please try again or download manually from https://www.mudlet.org/download/</source> <extracomment>Error shown when extracting the downloaded update archive fails on Linux</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="440"/> - <location filename="../src/updater.cpp" line="446"/> - <location filename="../src/updater.cpp" line="460"/> - <location filename="../src/updater.cpp" line="473"/> + <location filename="../src/updater.cpp" line="468"/> + <location filename="../src/updater.cpp" line="474"/> + <location filename="../src/updater.cpp" line="488"/> + <location filename="../src/updater.cpp" line="501"/> <source>Failed to install the update. Please try again or download manually from https://www.mudlet.org/download/</source> <extracomment>Error shown when the automatic update fails to install on Linux</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="454"/> + <location filename="../src/updater.cpp" line="482"/> <source>Failed to install the update and could not restore the previous version. Your previous version is saved at: %1 - please rename it back manually. Alternatively, download a fresh copy from https://www.mudlet.org/download/</source> <extracomment>Error shown when the update fails and the previous version could not be restored automatically. %1 is the file path to the backup copy.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="502"/> - <location filename="../src/updater.cpp" line="583"/> + <location filename="../src/updater.cpp" line="538"/> + <location filename="../src/updater.cpp" line="638"/> <source>Update Error</source> <extracomment>Error title for update-related warning dialogs ---------- @@ -6035,20 +6088,20 @@ Error title for dialog shown when Mudlet fails to restart after updating</extrac <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="507"/> + <location filename="../src/updater.cpp" line="543"/> <source>The update installer could not be found. Please try checking for updates again.</source> <extracomment>Error shown when the downloaded installer file cannot be found on disk</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="519"/> + <location filename="../src/updater.cpp" line="555"/> <source>Could not prepare the update installer. Please try again or download the update manually from https://www.mudlet.org/download/</source> <extracomment>Error shown when the installer file cannot be copied to a temporary location for launch</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="551"/> - <location filename="../src/updater.cpp" line="566"/> + <location filename="../src/updater.cpp" line="587"/> + <location filename="../src/updater.cpp" line="602"/> <source>Could not prepare the update. Please close Mudlet and run the installer manually: %1</source> <extracomment>Error shown when the batch file for managing the update process cannot be written. %1 is the path to the installer. @@ -6057,25 +6110,25 @@ Error shown when the batch file for managing the update process cannot be create <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="559"/> + <location filename="../src/updater.cpp" line="595"/> <source>Could not launch the update installer. Please restart Mudlet and try again.</source> <extracomment>Error shown when the update installer process fails to start</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="585"/> + <location filename="../src/updater.cpp" line="640"/> <source>Could not restart Mudlet after the update. Please start it manually.</source> <extracomment>Error message shown when Mudlet fails to restart after updating on Linux</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="610"/> + <location filename="../src/updater.cpp" line="665"/> <source>Restart to apply update</source> <extracomment>Label for the button shown after the update has been downloaded and installed, prompting user to restart</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater.cpp" line="613"/> + <location filename="../src/updater.cpp" line="668"/> <source>Update failed</source> <extracomment>Label for the update button shown when the update installation failed</extracomment> <translation type="unfinished"></translation> @@ -6110,7 +6163,7 @@ Error shown when the batch file for managing the update process cannot be create <context> <name>XMLimport</name> <message> - <location filename="../src/XMLimport.cpp" line="151"/> + <location filename="../src/XMLimport.cpp" line="153"/> <source>[ ALERT ] - Sorry, the file being read: "%1" reports it has a version (%2) it must have come from a later Mudlet version, @@ -6118,27 +6171,27 @@ and this one cannot read it, you need a newer Mudlet!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/XMLimport.cpp" line="354"/> + <location filename="../src/XMLimport.cpp" line="356"/> <source>Parsing area data...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/XMLimport.cpp" line="358"/> + <location filename="../src/XMLimport.cpp" line="360"/> <source>Parsing room data...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/XMLimport.cpp" line="362"/> + <location filename="../src/XMLimport.cpp" line="364"/> <source>Parsing environment data...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/XMLimport.cpp" line="370"/> + <location filename="../src/XMLimport.cpp" line="372"/> <source>Assigning rooms to their areas...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/XMLimport.cpp" line="577"/> + <location filename="../src/XMLimport.cpp" line="579"/> <source>Parsing room data [count: %1]...</source> <translation type="unfinished"></translation> </message> @@ -6179,113 +6232,123 @@ and this one cannot read it, you need a newer Mudlet!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="100"/> + <location filename="../src/ui/actions_main_area.ui" line="103"/> <source>ID:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="165"/> + <location filename="../src/ui/actions_main_area.ui" line="168"/> <source>Button Bar Properties</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="177"/> - <source>Number of columns/rows (depending on orientation):</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/ui/actions_main_area.ui" line="200"/> + <location filename="../src/ui/actions_main_area.ui" line="226"/> <source>Orientation Horizontal</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="205"/> + <location filename="../src/ui/actions_main_area.ui" line="231"/> <source>Orientation Vertical</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="220"/> + <location filename="../src/ui/actions_main_area.ui" line="246"/> <source>Dock Area Top</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="225"/> + <location filename="../src/ui/actions_main_area.ui" line="251"/> <source>Dock Area Left</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="230"/> + <location filename="../src/ui/actions_main_area.ui" line="256"/> <source>Dock Area Right</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="235"/> + <location filename="../src/ui/actions_main_area.ui" line="261"/> <source>Floating Toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="258"/> + <location filename="../src/ui/actions_main_area.ui" line="284"/> <source>Button Properties</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="264"/> + <location filename="../src/ui/actions_main_area.ui" line="290"/> <source>Button Rotation:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="281"/> + <location filename="../src/ui/actions_main_area.ui" line="310"/> <source>no rotation</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="286"/> + <location filename="../src/ui/actions_main_area.ui" line="315"/> <source>90° rotation to the left</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="291"/> + <location filename="../src/ui/actions_main_area.ui" line="320"/> <source>90° rotation to the right</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="299"/> + <location filename="../src/ui/actions_main_area.ui" line="328"/> <source>Push down button</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="306"/> + <location filename="../src/ui/actions_main_area.ui" line="335"/> <source>Command:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="319"/> - <location filename="../src/ui/actions_main_area.ui" line="339"/> + <location filename="../src/ui/actions_main_area.ui" line="351"/> + <location filename="../src/ui/actions_main_area.ui" line="374"/> <source>Text to send to the game as-is (optional)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="326"/> + <location filename="../src/ui/actions_main_area.ui" line="358"/> <source>Command (up):</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="72"/> + <location filename="../src/ui/actions_main_area.ui" line="75"/> <source><p>Choose a good, ideally unique, name for your button, menu or toolbar. This will be displayed in the buttons tree.</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="316"/> + <location filename="../src/ui/actions_main_area.ui" line="180"/> + <source>Number of rows:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/ui/actions_main_area.ui" line="199"/> + <source>Offset of first button:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/ui/actions_main_area.ui" line="348"/> <source><p>Type in one or more commands you want the button to send directly to the game if it is pressed. (Optional)</p><p>If this is a <i>push-down</i> button then this is sent only when the button goes from the <i>up</i> to <i>down</i> state.</p><p>To send more complex commands, that could depend on or need to modifies variables within this profile a Lua script should be entered <i>instead</i> in the editor area below. Anything entered here is, literally, just sent to the game server.</p><p>It is permissible to use both this <i>and</i> a Lua script - this will be sent <b>before</b> the script is run.</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="336"/> + <location filename="../src/ui/actions_main_area.ui" line="371"/> <source><p>Type in one or more commands you want the button to send directly to the game when this button goes from the <i>down</i> to <i>up</i> state.</p><p>To send more complex commands, that could depend on or need to modifies variables within this profile a Lua script should be entered <i>instead</i> in the editor area below. Anything entered here is, literally, just sent to the game server.</p><p>It is permissible to use both this <i>and</i> a Lua script - this will be sent <b>before</b> the script is run.</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/actions_main_area.ui" line="358"/> + <location filename="../src/ui/actions_main_area.ui" line="384"/> + <source>Icon</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/ui/actions_main_area.ui" line="419"/> <source>Stylesheet:</source> <translation type="unfinished"></translation> </message> @@ -6341,26 +6404,26 @@ and this one cannot read it, you need a newer Mudlet!</source> <context> <name>cTelnet</name> <message> - <location filename="../src/ctelnet.cpp" line="766"/> + <location filename="../src/ctelnet.cpp" line="773"/> <source>hh:mm:ss.zzz</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="794"/> - <location filename="../src/ctelnet.cpp" line="850"/> + <location filename="../src/ctelnet.cpp" line="801"/> + <location filename="../src/ctelnet.cpp" line="857"/> <source>User Disconnected</source> <extracomment>A reason why a connection to a game server ended, could be one of several to be listed. This text used in two places, ensure the same text is used in both.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="799"/> - <location filename="../src/ctelnet.cpp" line="858"/> + <location filename="../src/ctelnet.cpp" line="806"/> + <location filename="../src/ctelnet.cpp" line="865"/> <source>Connection/login attempt rejected by server</source> <extracomment>A reason why a connection to a game server ended, could be one of several to be listed. This text used in two places, ensure the same text is used in both.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1308"/> + <location filename="../src/ctelnet.cpp" line="1315"/> <source>[ ERROR ] - Internal error, no codec found for current setting of {"%1"} so Mudlet cannot send data in that format to the Game Server. Please check to see if there is an alternative that the MUD and Mudlet can @@ -6371,95 +6434,95 @@ changed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1512"/> + <location filename="../src/ctelnet.cpp" line="1547"/> <source>[ INFO ] - Package download cancelled.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1515"/> + <location filename="../src/ctelnet.cpp" line="1550"/> <source>[ WARN ] - Package download failed from '%1', reason: %2</source> <extracomment>%1 is the URL, %2 is the error message</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1519"/> + <location filename="../src/ctelnet.cpp" line="1554"/> <source> The package is hosted on a server with an SSL certificate problem. The URL may be using HTTPS when it should use HTTP, or the server's security certificate is not trusted by your system.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1535"/> + <location filename="../src/ctelnet.cpp" line="1570"/> <source>[ WARN ] - Package download failed: could not open file '%1' for writing, reason: %2</source> <extracomment>%1 is the file path, %2 is the error message</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1546"/> + <location filename="../src/ctelnet.cpp" line="1581"/> <source>[ WARN ] - Package download failed: could not save file, reason: %1</source> <extracomment>%1 is the error message</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1559"/> + <location filename="../src/ctelnet.cpp" line="1594"/> <source>[ WARN ] - Package installation failed for '%1', reason: %2</source> <extracomment>%1 is the package file path, %2 is the error message</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="2467"/> + <location filename="../src/ctelnet.cpp" line="2517"/> <source>[ INFO ] - This game appears to use KaVir's protocol handler, which works best when Mudlet reports its version number during connection. Version reporting in terminal type has been automatically enabled for improved color support. Reconnecting...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="541"/> - <location filename="../src/ctelnet.cpp" line="1222"/> + <location filename="../src/ctelnet.cpp" line="548"/> + <location filename="../src/ctelnet.cpp" line="1229"/> <source>[%1]</source> <extracomment>For an IPv6 address (which is composed of hex-digits and colons) if we want to show it with a port number appended (as a colon and then an integer between 1 and 65535) we need to wrap it with '['...']' to separate the latter from the former, however some Far-East locales may expect to use the wide versions of these character here.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="544"/> + <location filename="../src/ctelnet.cpp" line="551"/> <source>Looking up the details of server: %1:%2 ...</source> <extracomment>%1 is the URL or an IP address (suitably wrapped if it is an IPv6 one) of the Game Server (or Proxy); %2 is the port number.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="691"/> + <location filename="../src/ctelnet.cpp" line="698"/> <source>[ OK ] - Secure connection made (IPv6).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="693"/> + <location filename="../src/ctelnet.cpp" line="700"/> <source>[ OK ] - Secure connection made (IPv4).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="697"/> + <location filename="../src/ctelnet.cpp" line="704"/> <source>[ OK ] - Open connection made (IPv6).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="699"/> + <location filename="../src/ctelnet.cpp" line="706"/> <source>[ OK ] - Open connection made (IPv4).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="704"/> + <location filename="../src/ctelnet.cpp" line="711"/> <source>[ OK ] - Connection made (IPv6).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="706"/> + <location filename="../src/ctelnet.cpp" line="713"/> <source>[ OK ] - Connection made (IPv4).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="761"/> + <location filename="../src/ctelnet.cpp" line="768"/> <source>[ INFO ] - Connection time: %1.</source> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/ctelnet.cpp" line="824"/> + <location filename="../src/ctelnet.cpp" line="831"/> <source>[ ALERT ] - Socket got disconnected, for %n reason(s): %1</source> <extracomment>This message is used when we have been trying to connect or we were connected securely, but the connection has been lost. It is possible with a secure connection that there is MORE than one error message to show, but for English or other locales where the singular case (%n==1) is distinct it would be perfectly feasible to replace "for %n reason(s)" with "because" for that number (1) of errors - however the text should then be repeated in the corresponding situation for an "open" connection which is different in that it only ever has one "reason" to report.</extracomment> @@ -6468,27 +6531,27 @@ The package is hosted on a server with an SSL certificate problem. The URL may b </translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="837"/> - <location filename="../src/ctelnet.cpp" line="870"/> + <location filename="../src/ctelnet.cpp" line="844"/> + <location filename="../src/ctelnet.cpp" line="877"/> <source>[ ALERT ] - Socket got disconnected.</source> <extracomment>This message is used when we have been trying to connect or we were connected securely or in an open manner, but the connection has been lost and we do not have any explaination to give to the user as to why. Anyhow, in this case we do not have anything more to say about it. This text used in two places, ensure the same translation is used in both of them.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="853"/> + <location filename="../src/ctelnet.cpp" line="860"/> <source>Secure connections not supported by this game on this port; try turning the option off</source> <extracomment>A reason why a connection to a game server ended.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="880"/> + <location filename="../src/ctelnet.cpp" line="887"/> <source>[ ALERT ] - Socket got disconnected, for reason: %1</source> <extracomment>This message is used when we have been trying to connect or we were connected in an open, insecure manner, but the connection has been lost. Unlike the secure connection case there is only one error message to show; it would be desirable to use the same text for this message as the "one reason" (%n==1) situation for locales such as English (with a distinct form for the singular) use for the secure type of connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1006"/> + <location filename="../src/ctelnet.cpp" line="1013"/> <source>Host name lookup Failure! A connection cannot be established. The server name is not correct, or your nameservers are not working properly. @@ -6497,32 +6560,32 @@ working properly. <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1011"/> + <location filename="../src/ctelnet.cpp" line="1018"/> <source>[ ERROR ] - Unable to connect to "%1". Check your internet connection and the details entered for the game server.</source> <extracomment>%1 is the URL of the Game Server</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1023"/> + <location filename="../src/ctelnet.cpp" line="1030"/> <source>%1 (IPv6)</source> <extracomment>Used to add an IPv6 address line to the list displayed during connecting to a Host. Some, e.g. Far Eastern locales may require a different text here if they do not use spaces, or need "wide" '(' ')'s</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1029"/> + <location filename="../src/ctelnet.cpp" line="1036"/> <source>%1 (IPv4)</source> <extracomment>Used to add an IPv4 address line to the list displayed during connecting to a Host. Some, e.g. Far Eastern locales may require a different text here if they do not use spaces, or "wide" '('...')'</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1048"/> + <location filename="../src/ctelnet.cpp" line="1055"/> <source>A host name could not be found for the given IP address.</source> <extracomment>This text is used when the user has provided a raw IP address for the Game Server rather than a URL. In this case we try to perform a "reverse-lookup" to see if we can identify the URL that matches it - but nothing useful was found and we've got the original address back.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1055"/> + <location filename="../src/ctelnet.cpp" line="1062"/> <source>A host name for the IP address has been found. It is: "%1" </source> @@ -6530,7 +6593,7 @@ It is: "%1" <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/ctelnet.cpp" line="1066"/> + <location filename="../src/ctelnet.cpp" line="1073"/> <source>The %n IP address(es) of %1 has/have been found. It/They are:</source> <extracomment>This text is used in the (expected) case when the user has provided a URL (%1) for the Game Server rather than (unusually) an IP address. After a DNS lookup we have found at least one but possibly more (%n) IP addresses, which will be listed (one per line) immediately afterwards.</extracomment> <translation type="unfinished"> @@ -6538,15 +6601,15 @@ It is: "%1" </translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1103"/> + <location filename="../src/ctelnet.cpp" line="1110"/> <source>Trying secure (IPv4 and IPv6) connections to proxy %1:%2 ...</source> <extracomment>Happy-Eyeballs (both IPv4 and IPv6 addresses available) case. %1 is the URL for the server and %2 is the port number (on BOTH addresses) for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1108"/> - <location filename="../src/ctelnet.cpp" line="1141"/> - <location filename="../src/ctelnet.cpp" line="1168"/> + <location filename="../src/ctelnet.cpp" line="1115"/> + <location filename="../src/ctelnet.cpp" line="1148"/> + <location filename="../src/ctelnet.cpp" line="1175"/> <source>[ INFO ] - Attempting a secure connection to %1:%2 via proxy...</source> <extracomment>We don't need to worry about %1 being a raw IPv6 address here as we prohibit IP addresses for secure connections so it is a URL; %2 is the port number. ---------- @@ -6554,8 +6617,8 @@ It is: "%1" <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1114"/> - <location filename="../src/ctelnet.cpp" line="1146"/> + <location filename="../src/ctelnet.cpp" line="1121"/> + <location filename="../src/ctelnet.cpp" line="1153"/> <source>Trying secure (IPv4 and IPv6) connections to %1:%2 ...</source> <extracomment>Happy-Eyeballs (both IPv4 and IPv6 addresses available) case. %1 is the URL for the Server and %2 is the port number (on BOTH addresses) for the connection. ---------- @@ -6563,9 +6626,9 @@ It is: "%1" <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1119"/> - <location filename="../src/ctelnet.cpp" line="1151"/> - <location filename="../src/ctelnet.cpp" line="1176"/> + <location filename="../src/ctelnet.cpp" line="1126"/> + <location filename="../src/ctelnet.cpp" line="1158"/> + <location filename="../src/ctelnet.cpp" line="1183"/> <source>[ INFO ] - Attempting a secure connection to %1:%2 ...</source> <extracomment>We don't need to worry about %1 being a raw IPv6 address here as we prohibit IP addresses for secure connections so it is a URL; %2 is the port number. ---------- @@ -6573,33 +6636,33 @@ It is: "%1" <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1136"/> + <location filename="../src/ctelnet.cpp" line="1143"/> <source>Trying secure (IPv6) connection to %1:%2 via proxy...</source> <extracomment>%1 is the URL for the Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1165"/> + <location filename="../src/ctelnet.cpp" line="1172"/> <source>Trying secure (IPv4) connection to %1:%2 via proxy...</source> <extracomment>%1 is the URL for the Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1173"/> + <location filename="../src/ctelnet.cpp" line="1180"/> <source>Trying secure (IPv4) connection to %1:%2 ...</source> <extracomment>%1 is the URL for the Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1197"/> + <location filename="../src/ctelnet.cpp" line="1204"/> <source>Trying open (IPv4 and IPv6) connections to %1:%2 via proxy...</source> <extracomment>Happy-Eyeballs (both IPv4 and IPv6 addresses available) case. %1 is the URL for the proxy and %2 is the port number (on BOTH addresses) for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1200"/> - <location filename="../src/ctelnet.cpp" line="1231"/> - <location filename="../src/ctelnet.cpp" line="1258"/> + <location filename="../src/ctelnet.cpp" line="1207"/> + <location filename="../src/ctelnet.cpp" line="1238"/> + <location filename="../src/ctelnet.cpp" line="1265"/> <source>[ INFO ] - Attempting an open connection to %1:%2 via proxy...</source> <extracomment>%1 is a URL for the Game Server; %2 is the port number. ---------- @@ -6609,15 +6672,15 @@ It is: "%1" <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1206"/> + <location filename="../src/ctelnet.cpp" line="1213"/> <source>Trying open (IPv4 and IPv6) connections to %1:%2 ...</source> <extracomment>Happy-Eyeballs (both IPv4 and IPv6 addresses available) case. %1 is the URL for the Server and %2 is the port number (on BOTH addresses) for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1209"/> - <location filename="../src/ctelnet.cpp" line="1239"/> - <location filename="../src/ctelnet.cpp" line="1267"/> + <location filename="../src/ctelnet.cpp" line="1216"/> + <location filename="../src/ctelnet.cpp" line="1246"/> + <location filename="../src/ctelnet.cpp" line="1274"/> <source>[ INFO ] - Attempting an open connection to %1:%2 ...</source> <extracomment>%1 is a URL for the Game Server; %2 is the port number. ---------- @@ -6627,203 +6690,203 @@ It is: "%1" <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1227"/> + <location filename="../src/ctelnet.cpp" line="1234"/> <source>Trying open (IPv6) connection to %1:%2 via proxy...</source> <extracomment>%1 is the URL or IPv6 address (suitably wrapped) for the Game Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1235"/> + <location filename="../src/ctelnet.cpp" line="1242"/> <source>Trying open (IPv6) connection to %1:%2 ...</source> <extracomment>%1 is the URL or IPv6 address (suitably wrapped) for the Game Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1254"/> + <location filename="../src/ctelnet.cpp" line="1261"/> <source>Trying open (IPv4) connection to %1:%2 via proxy...</source> <extracomment>%1 is the URL or IPv4 address for the Game Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="1263"/> + <location filename="../src/ctelnet.cpp" line="1270"/> <source>Trying open (IPv4) connection to %1:%2 ...</source> <extracomment>%1 is the URL or IPv4 address for the Game Server and %2 is the port number for the connection.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="2486"/> + <location filename="../src/ctelnet.cpp" line="2536"/> <source>[ INFO ] - This game appears to support MXP (Mud eXtension Protocol), but has not turned it on properly. MXP processing has been automatically enabled for clickable links, room info, and richer interactions. You can disable this setting in Settings > Special Options.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="3575"/> - <location filename="../src/ctelnet.cpp" line="3965"/> + <location filename="../src/ctelnet.cpp" line="3628"/> + <location filename="../src/ctelnet.cpp" line="4027"/> <source>[ INFO ] - Upgrading the GUI to new version '%1' from version '%2' (url='%3').</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="3911"/> + <location filename="../src/ctelnet.cpp" line="3964"/> <source>[ INFO ] - Downloading and installing package '%1' (url='%2').</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="3922"/> + <location filename="../src/ctelnet.cpp" line="3988"/> <source>Cancel</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="3922"/> + <location filename="../src/ctelnet.cpp" line="3988"/> <source>Downloading game GUI from server...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4227"/> + <location filename="../src/ctelnet.cpp" line="4289"/> <source>[ INFO ] - A more secure connection on port %1 is available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4234"/> + <location filename="../src/ctelnet.cpp" line="4298"/> <source>For data transfer protection and privacy, this connection advertises a secure port.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4235"/> + <location filename="../src/ctelnet.cpp" line="4299"/> <source>Update to port %1 and connect with encryption?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4386"/> + <location filename="../src/ctelnet.cpp" line="4460"/> <source>ERROR</source> <extracomment>Keep the capitalisation, the translated text at 7 letters max so it aligns nicely</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4399"/> + <location filename="../src/ctelnet.cpp" line="4473"/> <source>LUA</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4411"/> + <location filename="../src/ctelnet.cpp" line="4485"/> <source>WARN</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4423"/> + <location filename="../src/ctelnet.cpp" line="4497"/> <source>ALERT</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4435"/> + <location filename="../src/ctelnet.cpp" line="4509"/> <source>INFO</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4447"/> + <location filename="../src/ctelnet.cpp" line="4521"/> <source>OK</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4458"/> + <location filename="../src/ctelnet.cpp" line="4532"/> <source>CHAT</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4698"/> + <location filename="../src/ctelnet.cpp" line="4775"/> <source>[ WARN ] - MCCP decompression error (%1), compression disabled. If the display looks garbled, please reconnect to the game.</source> <extracomment>%1 is the decompression error description. Shown when the server sends a corrupt MCCP (compressed) data stream.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4745"/> + <location filename="../src/ctelnet.cpp" line="4822"/> <source>[ INFO ] - Loading replay file: "%1".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4769"/> + <location filename="../src/ctelnet.cpp" line="4846"/> <source>Cannot replay file "%1", error message was: "replay file seems to be corrupt".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4771"/> + <location filename="../src/ctelnet.cpp" line="4848"/> <source>[ WARN ] - The replay has been aborted as the file seems to be corrupt.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4780"/> + <location filename="../src/ctelnet.cpp" line="4857"/> <source>Cannot perform replay, another one may already be in progress. Try again when it has finished.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4782"/> + <location filename="../src/ctelnet.cpp" line="4859"/> <source>[ WARN ] - Cannot perform replay, another one may already be in progress. Try again when it has finished.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4790"/> + <location filename="../src/ctelnet.cpp" line="4867"/> <source>Cannot read file "%1", error message was: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4792"/> + <location filename="../src/ctelnet.cpp" line="4869"/> <source>[ ERROR ] - Cannot read file "%1", error message was: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4831"/> + <location filename="../src/ctelnet.cpp" line="4908"/> <source>[ OK ] - The replay has ended.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="4962"/> + <location filename="../src/ctelnet.cpp" line="5050"/> <source>[ WARN ] - Too much data to process at once, some may have been lost.</source> <extracomment>Shown when too much data expands out of one compressed read (e.g. a decompression bomb) to process safely.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5535"/> + <location filename="../src/ctelnet.cpp" line="5611"/> <source>server %1</source> <extracomment>Telnet options report: server side of an option, %1 is "enabled" or "disabled"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5535"/> - <location filename="../src/ctelnet.cpp" line="5539"/> + <location filename="../src/ctelnet.cpp" line="5611"/> + <location filename="../src/ctelnet.cpp" line="5615"/> <source>enabled</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5535"/> - <location filename="../src/ctelnet.cpp" line="5539"/> + <location filename="../src/ctelnet.cpp" line="5611"/> + <location filename="../src/ctelnet.cpp" line="5615"/> <source>disabled</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5539"/> + <location filename="../src/ctelnet.cpp" line="5615"/> <source>client %1</source> <extracomment>Telnet options report: client side of an option, %1 is "enabled" or "disabled"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5542"/> + <location filename="../src/ctelnet.cpp" line="5618"/> <source> %1: %2</source> <extracomment>Telnet option line: %1 is the option name (e.g. "NAWS (31)"), %2 is one or both sides</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5546"/> + <location filename="../src/ctelnet.cpp" line="5622"/> <source> (none negotiated yet) </source> <extracomment>Shown in the Telnet options statistics report when no options have been negotiated yet</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ctelnet.cpp" line="5563"/> + <location filename="../src/ctelnet.cpp" line="5656"/> <source>[ WARN ] - This game appears to use character-at-a-time mode, which Mudlet does not support. Input may not work as expected. Consider using keybindings for immediate key response instead.</source> <extracomment>Warning shown when server uses character-at-a-time mode which Mudlet doesn't support</extracomment> <translation type="unfinished"></translation> @@ -6942,199 +7005,199 @@ error message was: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="112"/> + <location filename="../src/ui/connection_profiles.ui" line="117"/> <source>profiles list</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="388"/> + <location filename="../src/ui/connection_profiles.ui" line="395"/> <source>Remove</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="407"/> + <location filename="../src/ui/connection_profiles.ui" line="414"/> <source>Copy</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="432"/> + <location filename="../src/ui/connection_profiles.ui" line="439"/> <source>New</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="502"/> + <location filename="../src/ui/connection_profiles.ui" line="509"/> <source>welcome message</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="528"/> + <location filename="../src/ui/connection_profiles.ui" line="535"/> <source>Profile name:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="543"/> + <location filename="../src/ui/connection_profiles.ui" line="550"/> <source>Profile name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="546"/> + <location filename="../src/ui/connection_profiles.ui" line="553"/> <source>A unique name for the profile but which is limited to a subset of ascii characters only.</source> <comment>Using lower case letters for 'ASCII' may make speech synthesisers say 'askey' which is quicker than 'Aay Ess Cee Eye Eye'!</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="556"/> + <location filename="../src/ui/connection_profiles.ui" line="563"/> <source>Server address:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="577"/> + <location filename="../src/ui/connection_profiles.ui" line="584"/> <source>Game server URL</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="580"/> + <location filename="../src/ui/connection_profiles.ui" line="587"/> <source>The Internet host name or IP address</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="587"/> + <location filename="../src/ui/connection_profiles.ui" line="594"/> <source>Port:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="614"/> + <location filename="../src/ui/connection_profiles.ui" line="621"/> <source>Game server port</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="522"/> + <location filename="../src/ui/connection_profiles.ui" line="529"/> <source>Connect to</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="617"/> + <location filename="../src/ui/connection_profiles.ui" line="624"/> <source>The port that is used together with the server name to make the connection to the game server. If not specified a default of 23 for "Telnet" connections is used. Secure connections may require a different port number.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="636"/> + <location filename="../src/ui/connection_profiles.ui" line="643"/> <source>Connect via a secure protocol</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="639"/> + <location filename="../src/ui/connection_profiles.ui" line="646"/> <source>Make Mudlet use a secure SSL/TLS protocol instead of an unencrypted one</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="645"/> + <location filename="../src/ui/connection_profiles.ui" line="652"/> <source>Secure:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="666"/> + <location filename="../src/ui/connection_profiles.ui" line="673"/> <source>Options</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="724"/> + <location filename="../src/ui/connection_profiles.ui" line="731"/> <source>Profile history:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="747"/> + <location filename="../src/ui/connection_profiles.ui" line="754"/> <source>load newest profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="752"/> + <location filename="../src/ui/connection_profiles.ui" line="759"/> <source>load oldest profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="672"/> + <location filename="../src/ui/connection_profiles.ui" line="679"/> <source>Character name:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="682"/> + <location filename="../src/ui/connection_profiles.ui" line="689"/> <source>The characters name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="685"/> + <location filename="../src/ui/connection_profiles.ui" line="692"/> <source>Character name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="688"/> + <location filename="../src/ui/connection_profiles.ui" line="695"/> <source>If provided will be sent, along with password to identify the user in the game.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="769"/> + <location filename="../src/ui/connection_profiles.ui" line="776"/> <source>Auto-open profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="772"/> + <location filename="../src/ui/connection_profiles.ui" line="779"/> <source>Automatically start this profile when Mudlet is run</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="785"/> + <location filename="../src/ui/connection_profiles.ui" line="792"/> <source>Auto-reconnect</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="788"/> + <location filename="../src/ui/connection_profiles.ui" line="795"/> <source>Automatically reconnect this profile if it should become disconnected for any reason other than the user disconnecting from the game server.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="711"/> + <location filename="../src/ui/connection_profiles.ui" line="718"/> <source>Password</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="714"/> + <location filename="../src/ui/connection_profiles.ui" line="721"/> <source>If provided will be sent, along with the character name to identify the user in the game.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="817"/> + <location filename="../src/ui/connection_profiles.ui" line="824"/> <source>Information</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="874"/> - <location filename="../src/ui/connection_profiles.ui" line="877"/> + <location filename="../src/ui/connection_profiles.ui" line="881"/> + <location filename="../src/ui/connection_profiles.ui" line="884"/> <source>Game description or your notes</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="698"/> + <location filename="../src/ui/connection_profiles.ui" line="705"/> <source>Password:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="708"/> + <location filename="../src/ui/connection_profiles.ui" line="715"/> <source>Characters password. Note that the password isn't encrypted in storage</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="766"/> + <location filename="../src/ui/connection_profiles.ui" line="773"/> <source>With this enabled, Mudlet will automatically start and connect on this profile when it is launched</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="778"/> + <location filename="../src/ui/connection_profiles.ui" line="785"/> <source>Open profile on Mudlet start</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/connection_profiles.ui" line="794"/> + <location filename="../src/ui/connection_profiles.ui" line="801"/> <source>Reconnect automatically</source> <translation type="unfinished"></translation> </message> @@ -7371,22 +7434,38 @@ custom line?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="163"/> - <location filename="../src/updater/Feed.cpp" line="182"/> - <location filename="../src/updater/Feed.cpp" line="215"/> - <source>Could not verify the integrity of the download. Please try again later.</source> - <extracomment>Error shown when a manual update cannot be verified as safe to install</extracomment> + <location filename="../src/updater/Feed.cpp" line="164"/> + <source>This update does not publish the checksums needed to verify it. Please try again later, or download it from https://www.mudlet.org/download/</source> + <extracomment>Error shown when the release publishes no checksums at all, so the download cannot be verified as safe to install</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="258"/> + <location filename="../src/updater/Feed.cpp" line="225"/> + <source>Could not download the checksums needed to verify this update. Please try again later.</source> + <extracomment>Error shown when the checksums needed to verify the update could not be downloaded</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/updater/Feed.cpp" line="243"/> + <source>The checksums for this update could not be read, so it cannot be verified. Please try again later.</source> + <extracomment>Error shown when the checksum file for the update was downloaded but could not be read</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/updater/Feed.cpp" line="247"/> + <source>This update is missing a checksum for your platform, so it cannot be verified. Please try again later, or download it from https://www.mudlet.org/download/</source> + <extracomment>Error shown when the release publishes checksums but none of them cover this platform's download</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/updater/Feed.cpp" line="291"/> <source>Could not connect to the update server: %1</source> <extracomment>Error shown when the network request to the update server fails. %1 is the technical error description.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="272"/> - <location filename="../src/updater/Feed.cpp" line="294"/> + <location filename="../src/updater/Feed.cpp" line="305"/> + <location filename="../src/updater/Feed.cpp" line="327"/> <source>Could not read update information from the server</source> <extracomment>Error shown when the server response cannot be understood ---------- @@ -7394,56 +7473,56 @@ Error shown when the update server response cannot be understood</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="283"/> + <location filename="../src/updater/Feed.cpp" line="316"/> <source>Update check temporarily unavailable. Please try again in a few minutes.</source> <extracomment>Error shown when the GitHub API rate limit has been exceeded</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="286"/> + <location filename="../src/updater/Feed.cpp" line="319"/> <source>Could not check for updates: %1</source> <extracomment>Error shown when the GitHub API returns an error. %1 is the error message from the server.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="340"/> + <location filename="../src/updater/Feed.cpp" line="373"/> <source>Could not create temporary file for download: %1</source> <extracomment>Error shown when a temporary file cannot be created for the update download. %1 is the system error message.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="350"/> + <location filename="../src/updater/Feed.cpp" line="383"/> <source>Failed to save download data: %1</source> <extracomment>Error shown when writing download data to disk fails. %1 is the system error message.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="361"/> + <location filename="../src/updater/Feed.cpp" line="394"/> <source>Download failed: %1</source> <extracomment>Error shown when the update file download fails. %1 is the network error message.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="369"/> + <location filename="../src/updater/Feed.cpp" line="402"/> <source>Download failed. Please try again.</source> <extracomment>Error shown when the update download completed but nothing was received</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="377"/> + <location filename="../src/updater/Feed.cpp" line="410"/> <source>Failed to save download: %1</source> <extracomment>Error shown when flushing the downloaded file to disk fails. %1 is the system error message.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="385"/> - <location filename="../src/updater/Feed.cpp" line="394"/> + <location filename="../src/updater/Feed.cpp" line="418"/> + <location filename="../src/updater/Feed.cpp" line="427"/> <source>Failed to verify download integrity</source> <extracomment>Error shown when the downloaded file cannot be read back for checksum verification</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/Feed.cpp" line="403"/> + <location filename="../src/updater/Feed.cpp" line="436"/> <source>Could not verify download integrity.</source> <extracomment>Error shown when the downloaded file's SHA256 checksum does not match the expected value</extracomment> <translation type="unfinished"></translation> @@ -7452,32 +7531,32 @@ Error shown when the update server response cannot be understood</extracomment> <context> <name>dblsqd::UpdateDialog</name> <message> - <location filename="../src/updater/UpdateDialog.cpp" line="579"/> + <location filename="../src/updater/UpdateDialog.cpp" line="597"/> <source>Could not open the downloaded update. You can try opening it manually: %1</source> <extracomment>Error shown when the downloaded update file cannot be opened for installation. %1 is the file path.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/UpdateDialog.cpp" line="644"/> + <location filename="../src/updater/UpdateDialog.cpp" line="662"/> <source>Could not check for updates</source> <extracomment>Label shown in the update dialog when the update check fails due to a network or server error</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/UpdateDialog.cpp" line="659"/> + <location filename="../src/updater/UpdateDialog.cpp" line="677"/> <source>Download failed. Please try again.</source> <extracomment>Error shown when the download finished but no file was saved</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/UpdateDialog.cpp" line="682"/> + <location filename="../src/updater/UpdateDialog.cpp" line="700"/> <source>Download Error</source> <extracomment>Title for the download error warning dialog</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/updater/UpdateDialog.cpp" line="684"/> + <location filename="../src/updater/UpdateDialog.cpp" line="702"/> <source>There was an error while downloading the update.</source> <extracomment>Message shown in the download error warning dialog, followed by the specific error details</extracomment> <translation type="unfinished"></translation> @@ -7547,145 +7626,145 @@ Count</source> <context> <name>directions</name> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5854"/> + <location filename="../src/TLuaInterpreter.cpp" line="6151"/> <source>north</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5856"/> + <location filename="../src/TLuaInterpreter.cpp" line="6153"/> <source>n</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5858"/> + <location filename="../src/TLuaInterpreter.cpp" line="6155"/> <source>east</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5860"/> + <location filename="../src/TLuaInterpreter.cpp" line="6157"/> <source>e</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5862"/> + <location filename="../src/TLuaInterpreter.cpp" line="6159"/> <source>south</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5864"/> + <location filename="../src/TLuaInterpreter.cpp" line="6161"/> <source>s</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5866"/> + <location filename="../src/TLuaInterpreter.cpp" line="6163"/> <source>west</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5868"/> + <location filename="../src/TLuaInterpreter.cpp" line="6165"/> <source>w</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5870"/> + <location filename="../src/TLuaInterpreter.cpp" line="6167"/> <source>northeast</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5872"/> + <location filename="../src/TLuaInterpreter.cpp" line="6169"/> <source>ne</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5874"/> + <location filename="../src/TLuaInterpreter.cpp" line="6171"/> <source>southeast</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5876"/> + <location filename="../src/TLuaInterpreter.cpp" line="6173"/> <source>se</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5878"/> + <location filename="../src/TLuaInterpreter.cpp" line="6175"/> <source>southwest</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5880"/> + <location filename="../src/TLuaInterpreter.cpp" line="6177"/> <source>sw</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5882"/> + <location filename="../src/TLuaInterpreter.cpp" line="6179"/> <source>northwest</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5884"/> + <location filename="../src/TLuaInterpreter.cpp" line="6181"/> <source>nw</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5886"/> + <location filename="../src/TLuaInterpreter.cpp" line="6183"/> <source>in</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5888"/> + <location filename="../src/TLuaInterpreter.cpp" line="6185"/> <source>i</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5890"/> + <location filename="../src/TLuaInterpreter.cpp" line="6187"/> <source>out</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5892"/> + <location filename="../src/TLuaInterpreter.cpp" line="6189"/> <source>o</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5894"/> + <location filename="../src/TLuaInterpreter.cpp" line="6191"/> <source>up</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5896"/> + <location filename="../src/TLuaInterpreter.cpp" line="6193"/> <source>u</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5898"/> + <location filename="../src/TLuaInterpreter.cpp" line="6195"/> <source>down</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/TLuaInterpreter.cpp" line="5900"/> + <location filename="../src/TLuaInterpreter.cpp" line="6197"/> <source>d</source> <comment>Entering this direction will move the player in the game</comment> <translation type="unfinished"></translation> @@ -8056,6 +8135,21 @@ Count</source> <translation type="unfinished"></translation> </message> </context> +<context> + <name>dlgActionMainArea</name> + <message> + <location filename="../src/dlgActionMainArea.cpp" line="87"/> + <source>Number of columns:</source> + <extracomment>A toolbar is being set to vertical orientation - so multiple rows of this number of columns</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgActionMainArea.cpp" line="90"/> + <source>Number of rows:</source> + <extracomment>A toolbar is being set to horizontal orientation - so multiple columns of this number of rows</extracomment> + <translation type="unfinished"></translation> + </message> +</context> <context> <name>dlgAliasMainArea</name> <message> @@ -8252,197 +8346,213 @@ Count</source> <context> <name>dlgConnectionProfiles</name> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="93"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="167"/> <source>Connect</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="217"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="291"/> <source>Characters password. Note that the password is not encrypted in storage</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="295"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="369"/> <source>Game name: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="297"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="371"/> <source>Button to select a mud game to play, double-click it to connect and start playing it.</source> <extracomment>Some text to speech engines will spell out initials like MUD so stick to lower case if that is a better option</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1234"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1350"/> <source>This profile is currently loaded - close it before changing the connection parameters.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1549"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1683"/> <source>Reset icon</source> <extracomment>Reset the custom picture for this profile in the connection dialog and show the default one instead</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1553"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1687"/> <source>Set custom icon</source> <extracomment>Set a custom picture to show for the profile in the connection dialog</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1558"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1692"/> <source>Set custom color</source> <extracomment>Set a custom color to show for the profile in the connection dialog</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1568"/> - <source>Show my profiles only</source> - <extracomment>Context menu action to toggle hiding default game profiles that have not been used yet</extracomment> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2039"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2165"/> <source>The %1 character is not permitted. Use one of the following:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2062"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2186"/> <source>You have to enter a number. Other characters are not permitted.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2051"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2175"/> <source>This profile name is already in use.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="783"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="867"/> <source>Could not rename your profile data on the computer.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="95"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="169"/> <source>Offline</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="99"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="173"/> <source>Skip - show me the games list</source> <extracomment>Button shown on first launch to skip the tutorial and show the full games list</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="124"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="198"/> <source><p><center><img src="tutorialIcon"/></center></p><p><center><big><b>Welcome to Mudlet!</b></big></center></p><p><center>Play a short guided adventure to learn<br>how to navigate in games, use triggers, aliases, and scripting.</center></p><p><center><a href="mudlet-tutorial">Start Tutorial</a></center></p><p align="right"><span style=" font-family:'Sans';">The Mudlet Team </span><img src=":/icons/mudlet_main_16px.png"/></p></source> <extracomment>Welcome message shown on first launch, focused on starting the tutorial.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="137"/> - <location filename="../src/dlgConnectionProfiles.cpp" line="1687"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="211"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1807"/> <source>Copy</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="139"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="213"/> <source>Copy settings only</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="156"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="230"/> <source>copy profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="157"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="231"/> <source>copy the entire profile to new one that will require a different new name.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="169"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="243"/> <source>copy profile settings</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="170"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="244"/> <source>copy the settings and some other parts of the profile to a new one that will require a different new name.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="215"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="289"/> <source>Characters password, stored securely in the computer's credential manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="292"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="366"/> <source>Click to load but not connect the selected profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="293"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="367"/> <source>Click to load and connect the selected profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="294"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="368"/> <source>Need to have a valid profile name, game server address and port before this button can be enabled.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="791"/> - <location filename="../src/dlgConnectionProfiles.cpp" line="1721"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="875"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1836"/> <source>Could not create the new profile folder on your computer.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="639"/> - <location filename="../src/dlgConnectionProfiles.cpp" line="855"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="723"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="966"/> <source>new profile name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1011"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="130"/> + <source>My games</source> + <extracomment>Tab showing only the games the user already has profiles for</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgConnectionProfiles.cpp" line="132"/> + <source>All games</source> + <extracomment>Tab showing every game Mudlet has a built-in profile for</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgConnectionProfiles.cpp" line="134"/> + <source>games shown</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgConnectionProfiles.cpp" line="135"/> + <source>Switch between showing only your own games and all of the games Mudlet knows about.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgConnectionProfiles.cpp" line="1125"/> <source>Deleting '%1'</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1238"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1354"/> <source>A profile that is in use cannot be removed</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1587"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1707"/> <source>Select custom image for profile (should be 120x30)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1587"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1707"/> <source>Images (%1)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="1668"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="1788"/> <source>Copying...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2072"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2196"/> <source>Port number must be above zero and below 65535.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2092"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2216"/> <source>Mudlet can not load support for secure connections.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2114"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2238"/> <source>Please enter the URL or IP address of the Game server.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2133"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2257"/> <source>Please enter the URL of the Game server. <i>SSL/TLS connections require a URL, as an IP address is not a suitable identifier for the certification of the Game Server.</i></source> @@ -8450,33 +8560,33 @@ Count</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2152"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2276"/> <source>Load profile without connecting.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2168"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2292"/> <source>Please set a valid profile name, game server address and the game port before loading.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2173"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2297"/> <source>Please set a valid profile name, game server address and the game port before connecting.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2226"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2350"/> <source>Click to hide the password; it will also hide if another profile is selected.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2230"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2354"/> <source>Click to reveal the password for this profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgConnectionProfiles.cpp" line="2080"/> - <location filename="../src/dlgConnectionProfiles.cpp" line="2083"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2204"/> + <location filename="../src/dlgConnectionProfiles.cpp" line="2207"/> <source>Mudlet is not configured for secure connections.</source> <translation type="unfinished"></translation> </message> @@ -8804,49 +8914,49 @@ reason: %2.</source> <context> <name>dlgModuleManager</name> <message> - <location filename="../src/dlgModuleManager.cpp" line="49"/> + <location filename="../src/dlgModuleManager.cpp" line="52"/> <source>Module Manager - %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="63"/> + <location filename="../src/dlgModuleManager.cpp" line="66"/> <source>Module Name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="63"/> + <location filename="../src/dlgModuleManager.cpp" line="66"/> <source>Priority</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="63"/> + <location filename="../src/dlgModuleManager.cpp" line="66"/> <source>Sync</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="63"/> + <location filename="../src/dlgModuleManager.cpp" line="66"/> <source>Module Location</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="104"/> + <location filename="../src/dlgModuleManager.cpp" line="107"/> <source>Master module: saved and resynchronized across all sessions on Save Profile or session end.</source> <extracomment>Tooltip for master module checkbox</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="137"/> + <location filename="../src/dlgModuleManager.cpp" line="140"/> <source>Load Mudlet Module</source> <extracomment>Module manager - import modules from file dialog (multi-select enabled) Module manager - file filter for supported module types (mpackage, zip, xml)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="137"/> + <location filename="../src/dlgModuleManager.cpp" line="140"/> <source>Mudlet Packages (*.mpackage *.zip *.xml)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgModuleManager.cpp" line="166"/> + <location filename="../src/dlgModuleManager.cpp" line="169"/> <source>Failed to import: %1</source> <extracomment>Module manager - status message shown when some modules failed to import. %1 is a comma-separated list of module names</extracomment> <translation type="unfinished"></translation> @@ -8855,97 +8965,97 @@ reason: %2.</source> <context> <name>dlgNotepad</name> <message> - <location filename="../src/dlgNotepad.cpp" line="65"/> + <location filename="../src/dlgNotepad.cpp" line="73"/> <source>Prepend</source> <extracomment>label for prepended text entry box in notepad</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="69"/> + <location filename="../src/dlgNotepad.cpp" line="77"/> <source>Text to prepend to lines</source> <extracomment>placeholder text for text entry box in notepad - text which gets added before sending a line</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="73"/> + <location filename="../src/dlgNotepad.cpp" line="81"/> <source>Stop</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="99"/> + <location filename="../src/dlgNotepad.cpp" line="107"/> <source>Add new note tab (Ctrl+T)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="116"/> + <location filename="../src/dlgNotepad.cpp" line="124"/> <source>Find</source> <extracomment>Placeholder text for the search field in notepad</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="122"/> + <location filename="../src/dlgNotepad.cpp" line="130"/> <source>Find previous</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="128"/> + <location filename="../src/dlgNotepad.cpp" line="136"/> <source>Find next</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="134"/> + <location filename="../src/dlgNotepad.cpp" line="142"/> <source>Close find bar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="201"/> + <location filename="../src/dlgNotepad.cpp" line="209"/> <source>New Note</source> <extracomment>Default name for a new note tab</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="235"/> + <location filename="../src/dlgNotepad.cpp" line="243"/> <source>Rename Note Tab</source> <extracomment>Dialog title for renaming a note tab</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="237"/> + <location filename="../src/dlgNotepad.cpp" line="245"/> <source>New name:</source> <extracomment>Label for the input field when renaming a note tab</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="259"/> + <location filename="../src/dlgNotepad.cpp" line="267"/> <source>New Tab</source> <extracomment>Context menu action to create a new note tab</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="269"/> + <location filename="../src/dlgNotepad.cpp" line="277"/> <source>Rename Tab</source> <extracomment>Context menu action to rename a note tab</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="276"/> + <location filename="../src/dlgNotepad.cpp" line="284"/> <source>Close Tab</source> <extracomment>Context menu action to close a note tab</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="284"/> + <location filename="../src/dlgNotepad.cpp" line="292"/> <source>Close Other Tabs</source> <extracomment>Context menu action to close all note tabs except the clicked one</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgNotepad.cpp" line="378"/> - <location filename="../src/dlgNotepad.cpp" line="392"/> - <location filename="../src/dlgNotepad.cpp" line="404"/> - <location filename="../src/dlgNotepad.cpp" line="410"/> + <location filename="../src/dlgNotepad.cpp" line="386"/> + <location filename="../src/dlgNotepad.cpp" line="400"/> + <location filename="../src/dlgNotepad.cpp" line="412"/> <location filename="../src/dlgNotepad.cpp" line="418"/> - <location filename="../src/dlgNotepad.cpp" line="433"/> + <location filename="../src/dlgNotepad.cpp" line="426"/> + <location filename="../src/dlgNotepad.cpp" line="441"/> <source>Notes</source> <extracomment>Name for the migrated notes tab when upgrading from single-note to tabbed notepad ---------- @@ -9073,28 +9183,39 @@ Further reading material. e.g. a link to the Mudlet wiki, forums, Github package <source>Version</source> <translation type="unfinished"></translation> </message> + <message> + <location filename="../src/ui/dlgPackageExporter.ui" line="365"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="378"/> + <source>Webpage where users can find help for this package. It is shown by the "Module Help" button in the Module Manager.</source> + <translation type="unfinished"></translation> + </message> <message> <location filename="../src/ui/dlgPackageExporter.ui" line="368"/> + <source>Help URL</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/ui/dlgPackageExporter.ui" line="391"/> <source>Required packages</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/dlgPackageExporter.ui" line="468"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="491"/> <source>Does this package make use of other packages? List them here as requirements. Press 'Delete' to remove a package.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/dlgPackageExporter.ui" line="500"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="523"/> <source>Include assets (images, sounds, fonts)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/dlgPackageExporter.ui" line="510"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="533"/> <source>Drag and drop files and folders, or use the browse button below</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/dlgPackageExporter.ui" line="558"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="581"/> <source>Select files to include in package</source> <translation type="unfinished"></translation> </message> @@ -9104,134 +9225,134 @@ Further reading material. e.g. a link to the Mudlet wiki, forums, Github package <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/dlgPackageExporter.ui" line="365"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="388"/> <source>Does this package make use of other packages? List them here as requirements.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/ui/dlgPackageExporter.ui" line="608"/> + <location filename="../src/ui/dlgPackageExporter.ui" line="631"/> <source>Select export location</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="71"/> + <location filename="../src/dlgPackageExporter.cpp" line="74"/> <source>Triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="72"/> + <location filename="../src/dlgPackageExporter.cpp" line="75"/> <source>Aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="73"/> + <location filename="../src/dlgPackageExporter.cpp" line="76"/> <source>Timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="74"/> + <location filename="../src/dlgPackageExporter.cpp" line="77"/> <source>Scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="75"/> + <location filename="../src/dlgPackageExporter.cpp" line="78"/> <source>Keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="76"/> + <location filename="../src/dlgPackageExporter.cpp" line="79"/> <source>Buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="99"/> + <location filename="../src/dlgPackageExporter.cpp" line="102"/> <source>Export</source> <extracomment>Text for button to perform the package export on the items the user has selected.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="139"/> + <location filename="../src/dlgPackageExporter.cpp" line="142"/> <source>Package Exporter - %1</source> <extracomment>Title of the window. The %1 will be replaced by the current profile's name</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="163"/> + <location filename="../src/dlgPackageExporter.cpp" line="166"/> <source>Create Module - %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="166"/> + <location filename="../src/dlgPackageExporter.cpp" line="169"/> <source>Enter module name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="169"/> + <location filename="../src/dlgPackageExporter.cpp" line="172"/> <source>Create Module</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="172"/> + <location filename="../src/dlgPackageExporter.cpp" line="175"/> <source>Select where to save module</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="175"/> + <location filename="../src/dlgPackageExporter.cpp" line="178"/> <source>Select items to include in module</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="183"/> + <location filename="../src/dlgPackageExporter.cpp" line="186"/> <source>Add module description, icon, and assets (optional)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="186"/> + <location filename="../src/dlgPackageExporter.cpp" line="189"/> <source>Module location</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="192"/> + <location filename="../src/dlgPackageExporter.cpp" line="195"/> <source>Module description</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="193"/> + <location filename="../src/dlgPackageExporter.cpp" line="196"/> <source>Brief description of your module</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="194"/> + <location filename="../src/dlgPackageExporter.cpp" line="197"/> <source>Module author (recommended)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="195"/> + <location filename="../src/dlgPackageExporter.cpp" line="198"/> <source>Module version (recommended)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="198"/> + <location filename="../src/dlgPackageExporter.cpp" line="201"/> <source>Module dependencies</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="199"/> + <location filename="../src/dlgPackageExporter.cpp" line="202"/> <source>Include module assets (images, sounds, fonts)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="200"/> + <location filename="../src/dlgPackageExporter.cpp" line="203"/> <source>Select files to include in module</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="208"/> + <location filename="../src/dlgPackageExporter.cpp" line="211"/> <source>Select module dependencies</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="219"/> + <location filename="../src/dlgPackageExporter.cpp" line="222"/> <source>(optional) This module description is shown in the Module Manager. The editor supports Commonmark markdown. @@ -9257,109 +9378,73 @@ Further reading material, e.g., links to documentation or forum posts. <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="412"/> + <location filename="../src/dlgPackageExporter.cpp" line="415"/> <source>Failed to open file "%1" to place into package. Error message was: "%2".</source> <extracomment>This error message will appear when a file is to be placed into the package but the code cannot open it.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="420"/> + <location filename="../src/dlgPackageExporter.cpp" line="423"/> <source>Failed to add file "%1" to package. Error message was: "%3".</source> <extracomment>This error message will appear when a file is to be placed into the package but cannot be done for some reason.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="602"/> + <location filename="../src/dlgPackageExporter.cpp" line="606"/> <source>package name</source> <extracomment>package name will be added to other fields in the 'required fields missing: ...' tooltip when it's missing</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="624"/> + <location filename="../src/dlgPackageExporter.cpp" line="628"/> <source>Required field missing: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="627"/> + <location filename="../src/dlgPackageExporter.cpp" line="631"/> <source>Export package</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="855"/> + <location filename="../src/dlgPackageExporter.cpp" line="859"/> <source>Cannot create empty module. Please select at least one trigger, timer, alias, script, action, or key to include in the module.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="857"/> + <location filename="../src/dlgPackageExporter.cpp" line="861"/> <source>Cannot create empty package. Please select at least one item to include in the package.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="983"/> - <source>Module Already Exists</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="984"/> - <source>A module named "%1" is already installed.</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="985"/> - <source>Do you want to overwrite the existing module?</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="997"/> - <source>Module Overwritten</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="998"/> - <source>Module "%1" overwritten successfully!</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="999"/> - <source>The existing module has been replaced.</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="1007"/> - <location filename="../src/dlgPackageExporter.cpp" line="1017"/> + <location filename="../src/dlgPackageExporter.cpp" line="1035"/> <source>Module "%1" exported but installation failed: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1010"/> + <location filename="../src/dlgPackageExporter.cpp" line="1015"/> <source>Module "%1" exported but failed to uninstall existing version</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1014"/> - <source>Module "%1" exported successfully but not installed (already exists)</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgPackageExporter.cpp" line="1341"/> + <location filename="../src/dlgPackageExporter.cpp" line="1376"/> <source>Failed to open package file. Error is: "%1".</source> <extracomment>This zipError message is shown when the libzip library code is unable to open the file that was to be the end result of the export process. As this may be an existing file anywhere in the computer's file-system(s) it is possible that permissions on the directory or an existing file that is to be overwritten may be a source of problems here.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1507"/> + <location filename="../src/dlgPackageExporter.cpp" line="1542"/> <source>Failed to zip up the package. Error is: "%1".</source> <extracomment>This error message is displayed at the final stage of exporting a package when all the sourced files are finally put into the archive. Unfortunately this may be the point at which something breaks because a problem was not spotted/detected in the process earlier...</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1940"/> + <location filename="../src/dlgPackageExporter.cpp" line="1978"/> <source>Why not <a href="https://packages.mudlet.org/upload">upload</a> your package for other Mudlet users?</source> <extracomment>Only the text outside of the 'a' (HTML anchor) tags PLUS the verb 'upload' in between them in the source text, (associated with uploading the resulting package to the Mudlet forums) should be translated.</extracomment> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/dlgPackageExporter.cpp" line="1957"/> + <location filename="../src/dlgPackageExporter.cpp" line="1995"/> <source>Select what to export (%n item(s))</source> <extracomment>This is the text shown at the top of a groupbox when there is %n (one or more) items to export in the Package exporter dialogue; the initial (and when there is no items selected) is a separate text.</extracomment> <translation type="unfinished"> @@ -9367,98 +9452,128 @@ Further reading material, e.g., links to documentation or forum posts. </translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1960"/> + <location filename="../src/dlgPackageExporter.cpp" line="1998"/> <source>Select what to export</source> <extracomment>This is the text shown at the top of a groupbox initially and when there is NO items to export in the Package exporter dialogue.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="434"/> + <location filename="../src/dlgPackageExporter.cpp" line="437"/> <source>update installed package</source> <extracomment>First item in package selection dropdown - when selected, allows updating an existing installed package</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="429"/> + <location filename="../src/dlgPackageExporter.cpp" line="432"/> <source>add dependencies</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="587"/> - <location filename="../src/dlgPackageExporter.cpp" line="589"/> + <location filename="../src/dlgPackageExporter.cpp" line="591"/> + <location filename="../src/dlgPackageExporter.cpp" line="593"/> <source>Export to %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1309"/> + <location filename="../src/dlgPackageExporter.cpp" line="1344"/> <source>cannot copy %1 to the temporary location %2 - can you double-check it?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="636"/> + <location filename="../src/dlgPackageExporter.cpp" line="640"/> <source>Open Icon</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="636"/> + <location filename="../src/dlgPackageExporter.cpp" line="640"/> <source>Image Files (*.png *.jpg *.jpeg *.bmp *.tif *.ico *.icns)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="774"/> + <location filename="../src/dlgPackageExporter.cpp" line="778"/> <source>Please enter the package name.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="902"/> - <location filename="../src/dlgPackageExporter.cpp" line="1036"/> + <location filename="../src/dlgPackageExporter.cpp" line="888"/> + <source>Overwrite module?</source> + <extracomment>Title of the dialog asking whether to replace a module that already exists when creating a module</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgPackageExporter.cpp" line="890"/> + <source>A module named "%1" already exists.</source> + <extracomment>%1 is the name of the module that already exists</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgPackageExporter.cpp" line="893"/> + <source>Overwrite package?</source> + <extracomment>Title of the dialog asking whether to replace a package file that already exists when exporting</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgPackageExporter.cpp" line="895"/> + <source>A file named "%1" already exists.</source> + <extracomment>%1 is the file name of the package file that would be overwritten</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgPackageExporter.cpp" line="898"/> + <source>Do you want to overwrite it?</source> + <extracomment>Shown under the 'a file/module already exists' text when exporting a package or creating a module</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgPackageExporter.cpp" line="936"/> + <location filename="../src/dlgPackageExporter.cpp" line="1053"/> <source>Exporting package...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="922"/> + <location filename="../src/dlgPackageExporter.cpp" line="956"/> <source>Failed to export. Could not open the folder "%1" for writing. Do you have the necessary permissions and free disk-space to write to that folder?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="973"/> + <location filename="../src/dlgPackageExporter.cpp" line="1028"/> <source>Module "%1" created and installed successfully! Saved to: %2. You can now close this dialog.</source> <extracomment>%1 is the module name, %2 is a clickable link to the folder the module file was saved in</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1241"/> + <location filename="../src/dlgPackageExporter.cpp" line="1263"/> <source>Failed to export. Could not write Mudlet items to the file "%1".</source> <extracomment>This error message is shown when all the Mudlet items cannot be written to the 'packageName'.xml file in the base directory of the place where all the files are staged before being compressed into the package file. The full path and filename are shown in %1 to help the user diagnose what might have happened</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1304"/> + <location filename="../src/dlgPackageExporter.cpp" line="1339"/> <source>%1 doesn't seem to exist anymore - can you double-check it?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1425"/> + <location filename="../src/dlgPackageExporter.cpp" line="1460"/> <source>Failed to add directory "%1" to package. Error is: "%2".</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1468"/> + <location filename="../src/dlgPackageExporter.cpp" line="1503"/> <source>Required file "%1" was not found in the staging area. This area contains the Mudlet items chosen for the package, which you selected to be included in the package file. This suggests there may be a problem with that directory: "%2" - Do you have the necessary permissions and free disk-space?</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1021"/> + <location filename="../src/dlgPackageExporter.cpp" line="1038"/> <source>Package "%1" exported to: %2</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1499"/> + <location filename="../src/dlgPackageExporter.cpp" line="1534"/> <source>Export cancelled.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageExporter.cpp" line="1579"/> + <location filename="../src/dlgPackageExporter.cpp" line="1614"/> <source>Where do you want to save the package?</source> <translation type="unfinished"></translation> </message> @@ -9466,49 +9581,49 @@ Further reading material, e.g., links to documentation or forum posts. <context> <name>dlgPackageManager</name> <message> - <location filename="../src/dlgPackageManager.cpp" line="58"/> + <location filename="../src/dlgPackageManager.cpp" line="61"/> <source>Package Manager - %1</source> <extracomment>Package manager - window title</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="196"/> + <location filename="../src/dlgPackageManager.cpp" line="199"/> <source>Version </source> <extracomment>Package manager - label showing package version</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="320"/> + <location filename="../src/dlgPackageManager.cpp" line="323"/> <source>Import Mudlet Package</source> <extracomment>Package manager - import packages from file dialog (multi-select enabled) Package manager - file filter for supported package types (mpackage, zip, xml)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="320"/> + <location filename="../src/dlgPackageManager.cpp" line="323"/> <source>Mudlet Packages (*.mpackage *.zip *.xml)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="345"/> + <location filename="../src/dlgPackageManager.cpp" line="348"/> <source>Failed to import: %1</source> <extracomment>Package manager - status message shown when some packages failed to import. %1 is a comma-separated list of package names</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="357"/> + <location filename="../src/dlgPackageManager.cpp" line="360"/> <source>Downloading packages...</source> <extracomment>Package manager - cancel button text for download progress dialog</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="357"/> + <location filename="../src/dlgPackageManager.cpp" line="360"/> <source>Cancel</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="395"/> - <location filename="../src/dlgPackageManager.cpp" line="404"/> - <location filename="../src/dlgPackageManager.cpp" line="453"/> + <location filename="../src/dlgPackageManager.cpp" line="398"/> + <location filename="../src/dlgPackageManager.cpp" line="407"/> + <location filename="../src/dlgPackageManager.cpp" line="456"/> <source>Installation Failed</source> <extracomment>Package manager: package couldn't be downloaded ---------- @@ -9516,30 +9631,30 @@ Package manager: network error, package couldn't be downloaded</extracommen <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="395"/> - <location filename="../src/dlgPackageManager.cpp" line="404"/> + <location filename="../src/dlgPackageManager.cpp" line="398"/> + <location filename="../src/dlgPackageManager.cpp" line="407"/> <source>Package '%1' not found in repository</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="453"/> + <location filename="../src/dlgPackageManager.cpp" line="456"/> <source>Package '%1' could not be downloaded due to a network error</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="553"/> + <location filename="../src/dlgPackageManager.cpp" line="556"/> <source>Version %1 → %2</source> <extracomment>Package manager - version update indicator showing old and new versions</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="764"/> + <location filename="../src/dlgPackageManager.cpp" line="783"/> <source>All packages are up to date.</source> <extracomment>Package manager - message shown in description area when no updates are available</extracomment> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/dlgPackageManager.cpp" line="801"/> + <location filename="../src/dlgPackageManager.cpp" line="820"/> <source>Update (%n)</source> <extracomment>Message on button in package manager to update one or multiple (%n is the count) selected packages.</extracomment> <translation type="unfinished"> @@ -9547,19 +9662,19 @@ Package manager: network error, package couldn't be downloaded</extracommen </translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="804"/> + <location filename="../src/dlgPackageManager.cpp" line="823"/> <source>Update</source> <extracomment>Message on button in package manager when there are no selected packages - button will also be disabled.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="807"/> + <location filename="../src/dlgPackageManager.cpp" line="826"/> <source>Update selected packages</source> <extracomment>Tooltip for button in package manager when in Updates view</extracomment> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/dlgPackageManager.cpp" line="811"/> + <location filename="../src/dlgPackageManager.cpp" line="830"/> <source>Install (%n)</source> <extracomment>Message on button in package manager to install one or multiple (%n is the count) selected packages.</extracomment> <translation type="unfinished"> @@ -9567,8 +9682,8 @@ Package manager: network error, package couldn't be downloaded</extracommen </translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="814"/> - <location filename="../src/dlgPackageManager.cpp" line="821"/> + <location filename="../src/dlgPackageManager.cpp" line="833"/> + <location filename="../src/dlgPackageManager.cpp" line="840"/> <source>Install</source> <extracomment>Message on button in package manager when there are no selected packages - button will also be disabled. ---------- @@ -9576,14 +9691,14 @@ Message on button in package manager initially and when the view is the "In <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="817"/> - <location filename="../src/dlgPackageManager.cpp" line="824"/> + <location filename="../src/dlgPackageManager.cpp" line="836"/> + <location filename="../src/dlgPackageManager.cpp" line="843"/> <source>Install package from repository</source> <extracomment>Tooltip for button in package manager when in Explore view</extracomment> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/dlgPackageManager.cpp" line="836"/> + <location filename="../src/dlgPackageManager.cpp" line="855"/> <source>Remove (%n)</source> <extracomment>Message on button in package manager to remove one or multiple (%n is the count) selected packages.</extracomment> <translation type="unfinished"> @@ -9591,8 +9706,8 @@ Message on button in package manager initially and when the view is the "In </translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="839"/> - <location filename="../src/dlgPackageManager.cpp" line="843"/> + <location filename="../src/dlgPackageManager.cpp" line="858"/> + <location filename="../src/dlgPackageManager.cpp" line="862"/> <source>Remove</source> <extracomment>Message on button in package manager when there are no selected packages - button will also be disabled. ---------- @@ -9600,13 +9715,13 @@ Message on button in package manager initially and when the view is NOT the &quo <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="861"/> + <location filename="../src/dlgPackageManager.cpp" line="880"/> <source>Updates (%1)</source> <extracomment>Package manager - navigation button showing one or more available updates</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgPackageManager.cpp" line="864"/> + <location filename="../src/dlgPackageManager.cpp" line="883"/> <source>Updates</source> <extracomment>Package manager - navigation button for when there are no updates</extracomment> <translation type="unfinished"></translation> @@ -9615,132 +9730,132 @@ Message on button in package manager initially and when the view is NOT the &quo <context> <name>dlgProfilePreferences</name> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="176"/> + <location filename="../src/dlgProfilePreferences.cpp" line="177"/> <source>Location which will be used to store log files - matching logs will be appended to.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="177"/> + <location filename="../src/dlgProfilePreferences.cpp" line="178"/> <source>Select a directory where logs will be saved.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="178"/> + <location filename="../src/dlgProfilePreferences.cpp" line="179"/> <source>Reset the directory so that logs are saved to the profile's <i>log</i> directory.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="182"/> + <location filename="../src/dlgProfilePreferences.cpp" line="183"/> <source>Set a custom name for your log. (New logs are appended if a log file of the same name already exists).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="218"/> + <location filename="../src/dlgProfilePreferences.cpp" line="219"/> <source>Automatic updates are disabled in development builds to prevent an update from overwriting your Mudlet.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="245"/> + <location filename="../src/dlgProfilePreferences.cpp" line="250"/> <source>Select the only or the primary font used (depending on <i>Only use symbols (glyphs) from chosen font</i> setting) to produce the 2D mapper room symbols.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="328"/> + <location filename="../src/dlgProfilePreferences.cpp" line="333"/> <source>%1 (%2% done)</source> <comment>%1 is the (not-translated so users of the language can read it!) language name, %2 is percentage done.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="390"/> + <location filename="../src/dlgProfilePreferences.cpp" line="404"/> <source>Migrated all passwords to secure storage.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="401"/> + <location filename="../src/dlgProfilePreferences.cpp" line="415"/> <source>Migrated all passwords to profile storage.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="766"/> + <location filename="../src/dlgProfilePreferences.cpp" line="780"/> <source>From the dictionary file <tt>%1.dic</tt> (and its companion affix <tt>.aff</tt> file).</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="951"/> + <location filename="../src/dlgProfilePreferences.cpp" line="970"/> <source>yyyy-MM-dd#HH-mm-ss (e.g., 1970-01-01#00-00-00%1)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="953"/> + <location filename="../src/dlgProfilePreferences.cpp" line="972"/> <source>yyyy-MM-ddTHH-mm-ss (e.g., 1970-01-01T00-00-00%1)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="954"/> + <location filename="../src/dlgProfilePreferences.cpp" line="973"/> <source>yyyy-MM-dd (concatenate daily logs in, e.g. 1970-01-01%1)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="957"/> + <location filename="../src/dlgProfilePreferences.cpp" line="976"/> <source>yyyy-MM (concatenate month logs in, e.g. 1970-01%1)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="958"/> + <location filename="../src/dlgProfilePreferences.cpp" line="977"/> <source>Named file (concatenate logs in one file)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1053"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1072"/> <source>Other profiles to Map to:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1117"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1136"/> <source>2D Map Room Symbol scaling factor:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1149"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1168"/> <source>Show "%1" in the map area selection</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1232"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1251"/> <source>%1 (*Error, report to Mudlet Makers*)</source> <comment>The encoder code name is not in the mudlet class mEncodingNamesMap when it should be and the Mudlet Makers need to fix it!</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1409"/> - <location filename="../src/dlgProfilePreferences.cpp" line="4826"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1428"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4852"/> <source>Profile preferences - %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1883"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1902"/> <source>Profile preferences</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2839"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2865"/> <source>Load Mudlet map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2780"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2806"/> <source>Loading map - please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="185"/> + <location filename="../src/dlgProfilePreferences.cpp" line="186"/> <source>logfile</source> <extracomment>Must be a valid default filename for a log-file and is used if the user does not enter any other value (Ensure all instances have the same translation {one of two copies}).</extracomment> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/dlgProfilePreferences.cpp" line="196"/> - <location filename="../src/dlgProfilePreferences.cpp" line="3586"/> + <location filename="../src/dlgProfilePreferences.cpp" line="197"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3612"/> <source>copy to %n destination(s)</source> <extracomment>text on button to put the map from this profile into the other profiles to receive the map from this profile, %n is the number of other profiles that have already been selected to receive it and will be zero or more. The button will also be disabled (greyed out) in the zero case but the text will still be visible.</extracomment> <translation type="unfinished"> @@ -9748,294 +9863,306 @@ Message on button in package manager initially and when the view is NOT the &quo </translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="397"/> + <location filename="../src/dlgProfilePreferences.cpp" line="411"/> <source>Migrated %1...</source> <extracomment>This notifies the user that progress is being made on profile migration by saying what profile was just migrated to store passwords securely</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="721"/> + <location filename="../src/dlgProfilePreferences.cpp" line="735"/> <source>Enable spell check using Mudlet dictionary:</source> <extracomment>On Windows and MacOs, we have to bundle our own dictionaries with our application - and we also use them on *nix systems where we do not find the system ones</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="724"/> + <location filename="../src/dlgProfilePreferences.cpp" line="738"/> <source>Enable spell check using System dictionary:</source> <extracomment>On *nix systems where we find the system ones we use them</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="837"/> + <location filename="../src/dlgProfilePreferences.cpp" line="851"/> <source><p>Use the maximum buffer size your system can handle (%1 lines). This will be calculated based on available memory.</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="982"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1001"/> <source>Protocols</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="991"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1010"/> <source>GMCP: Generic Mud Communication Protocol</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1004"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1023"/> <source>MSDP: Mud Server Data Protocol</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1014"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1033"/> <source>MSSP: Mud Server Status Protocol</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1009"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1028"/> <source>MSP: Mud Sound Protocol</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1024"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1043"/> <source>MXP: Mud eXtension Protocol</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1019"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1038"/> <source>MTTS: Mud Terminal Type Standard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="240"/> + <location filename="../src/dlgProfilePreferences.cpp" line="245"/> <source><p>Hide success messages in Central Debug Console for timers with intervals below this threshold. Error messages always display.</p></source> <extracomment>Tooltip for timer debug output minimum interval</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="244"/> + <location filename="../src/dlgProfilePreferences.cpp" line="249"/> <source>Show all map symbols, their Unicode code-points, font availability, and which rooms use them.</source> <extracomment>Tooltip for show glyph usage button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="248"/> + <location filename="../src/dlgProfilePreferences.cpp" line="253"/> <source>Use only the selected font (may show � for missing symbols) or allow fallback fonts for better coverage.</source> <extracomment>Tooltip for map symbol font usage option</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="250"/> + <location filename="../src/dlgProfilePreferences.cpp" line="255"/> <source><p>Run all matching keybindings instead of just the first one. Disable for compatibility with pre-3.9.0 scripts.</p></source> <extracomment>Tooltip for run all keybindings option</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="253"/> + <location filename="../src/dlgProfilePreferences.cpp" line="258"/> <source><p>Controls display width for ambiguous East Asian characters. Auto-detects correct width for most encodings (default), or choose narrow/wide.</p></source> <extracomment>Tooltip for East Asian ambiguous width character option</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="256"/> + <location filename="../src/dlgProfilePreferences.cpp" line="261"/> <source><p>Enable context menu to analyze UTF-16/UTF-8 encoding of selected text. Useful for identifying multi-byte characters.</p></source> <extracomment>Tooltip for text analyzer option</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="259"/> + <location filename="../src/dlgProfilePreferences.cpp" line="264"/> <source><p>Control menu icon display: on, off, or auto (system default). May require restart.</p></source> <extracomment>Tooltip for show icons on menus option</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="914"/> + <location filename="../src/dlgProfilePreferences.cpp" line="933"/> <source>The Discord desktop app must be running for Rich Presence to work. Browser and mobile clients are not supported.</source> <extracomment>Tooltip shown when Discord Rich Presence cannot detect a logged-in user</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="986"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1005"/> <source>CHARSET: Character Encoding Standard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="996"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1015"/> <source>MNES: Mud New-Environ Standard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1000"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1019"/> <source>MNES uses the same telnet option as NEW-ENVIRON, so only one can be active. MNES sends a minimal set of variables, while NEW-ENVIRON sends extended variables including OSC link support.</source> <extracomment>Tooltip for MNES protocol option explaining mutual exclusivity with NEW-ENVIRON</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1029"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1048"/> <source>NAWS: Negotiate About Window Size</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1034"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1053"/> <source>NEW-ENVIRON: Client Variables Standard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1039"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1058"/> <source>NEW-ENVIRON uses the same telnet option as MNES, so only one can be active. NEW-ENVIRON sends extended variables including OSC link support, while MNES sends a minimal set.</source> <extracomment>Tooltip for NEW-ENVIRON protocol option explaining mutual exclusivity with MNES</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1094"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1113"/> <source>%1 {Default}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1106"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1125"/> <source>%1 {Experimental}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1108"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1127"/> <source>%1 {For older versions}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1342"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1361"/> <source>unknown error</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1343"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1362"/> <source>This profile could not be loaded correctly (%1). Settings cannot be saved. Close the profile and try loading an older version from 'Connect - Options - Profile history'.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1520"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1539"/> <source>Tab will switch between the input line and main window, and also step through hyperlinks while in caret mode. Ctrl+] and Ctrl+[ navigate links without conflicting with pane-switching. Press Enter or Space to activate the focused link, and the Menu key or Shift+F10 to open its context menu. Press Ctrl+End to jump to the latest content or Ctrl+Home to jump to the start of the buffer.</source> <extracomment>Screen-reader hint when the user picks Tab as the caret-mode pane-switching key, warning Tab is shared with hyperlink navigation and explaining how to activate links, open their menu, and jump to latest content. Do not translate the key names "Tab", "Ctrl+]", "Ctrl+[", "Enter", "Space", "Menu", "Shift+F10", "Ctrl+End" or "Ctrl+Home".</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1525"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1544"/> <source>In caret mode, use Ctrl+] for the next hyperlink and Ctrl+[ for the previous hyperlink. Press Enter or Space to activate the focused link, and the Menu key or Shift+F10 to open its context menu. Press Ctrl+End to jump to the latest content or Ctrl+Home to jump to the start of the buffer.</source> <extracomment>Screen-reader hint when the user picks any caret-mode pane-switching key other than Tab, explaining how to navigate, activate and open menus on hyperlinks, and jump to latest content. Do not translate the key names "Ctrl+]", "Ctrl+[", "Enter", "Space", "Menu", "Shift+F10", "Ctrl+End" or "Ctrl+Home".</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1621"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1640"/> <source>Warning: '%1' and '%2' now share the shortcut %3 - neither will work until one of them is changed.</source> <extracomment>Inline warning on the shortcuts preferences page when exactly two actions have been given the same shortcut. %1 and %2 are the action names, %3 is the shortcut itself.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1628"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1647"/> <source>Warning: %1 now share the shortcut %2 - none of them will work until they are changed.</source> <extracomment>Inline warning on the shortcuts preferences page when three or more actions have been given the same shortcut. %1 is the list of action names (each already quoted), %2 is the shortcut itself.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="1639"/> + <location filename="../src/dlgProfilePreferences.cpp" line="1658"/> <source>Shortcut conflict resolved.</source> <extracomment>Screen-reader announcement when editing the shortcuts removed the last duplicated assignment.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2177"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2105"/> + <source>[ WARN ] - Could not clear all of the stored media; some files may still be in use.</source> + <extracomment>Shown after the "Clear stored media" button in preferences fails to empty the profile's media directory.</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgProfilePreferences.cpp" line="2110"/> + <source>[ OK ] - The stored media files for this profile have been cleared.</source> + <extracomment>Shown after the "Clear stored media" button in preferences empties the profile's media directory.</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgProfilePreferences.cpp" line="2203"/> <source>Pick color</source> <extracomment>Generic pick color dialog title</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2480"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2506"/> <source>Forget saved sign-in?</source> <extracomment>Title of the dialog asking the user to confirm removing their saved sign-in.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2482"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2508"/> <source>This will remove the saved sign-in for this profile. You will need to sign in again next time. Continue?</source> <extracomment>Body of the dialog asking the user to confirm removing their saved sign-in; they will need to sign in again next time.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2501"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2527"/> <source>The saved sign-in has been forgotten.</source> <extracomment>Shown after the user's saved sign-in has actually been removed.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2505"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2531"/> <source>[ OK ] - The saved sign-in for this profile has been forgotten.</source> <extracomment>Shown in the main console after the user's saved sign-in has actually been removed.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2510"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2536"/> <source>Could not remove the saved sign-in; it may still be present.</source> <extracomment>Shown when removing the saved sign-in failed, so it may still be present.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2514"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2540"/> <source>[ WARN ] - Could not remove the saved sign-in; it may still be present.</source> <extracomment>Shown in the main console when removing the saved sign-in failed, so it may still be present.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2520"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2546"/> <source>No changes were made to the saved sign-in.</source> <extracomment>Shown when the user cancels removing their saved sign-in.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2522"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2548"/> <source>[ INFO ] - Cancelled: no changes were made to the saved sign-in.</source> <extracomment>Shown in the main console when the user cancels removing their saved sign-in.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2804"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2830"/> <source>Loaded map from %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2806"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2832"/> <source>Could not load map from %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2870"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2896"/> <source>Save Mudlet map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2898"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2924"/> <source>Saving map - please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2915"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2941"/> <source>Saved map to %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2917"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2943"/> <source>Could not save map to %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2948"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2974"/> <source>Migrating passwords to secure storage...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2955"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2981"/> <source>Migrating passwords to profiles...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2987"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3013"/> <source>[ ERROR ] - Unable to use or create directory to store map for other profile "%1". Please check that you have permissions/access to: "%2" @@ -10043,52 +10170,52 @@ and there is enough space. The copying operation has failed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2994"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3020"/> <source>Creating a destination directory failed...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3063"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3089"/> <source>Backing up current map - please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3073"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3099"/> <source>Could not backup the map - saving it failed.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3098"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3124"/> <source>Could not copy the map - failed to work out which map file we just saved the map as!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3110"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3136"/> <source>Copying over map to %1 - please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3116"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3142"/> <source>Could not copy the map to %1 - unable to copy the new map file over.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3120"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3146"/> <source>Map copied successfully to other profile %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3131"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3157"/> <source>Map copied, now signalling other profiles to reload it.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3169"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3195"/> <source>Where should Mudlet save log files?</source> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/dlgProfilePreferences.cpp" line="3591"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3617"/> <source>%n selected - change destinations...</source> <extracomment>text on button to select other profiles to receive the map from this profile, %n is the number of other profiles that have already been selected to receive it and will always be 1 or more</extracomment> <translation type="unfinished"> @@ -10096,286 +10223,286 @@ and there is enough space. The copying operation has failed.</source> </translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3596"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3622"/> <source>pick destinations...</source> <extracomment>text on button to select other profiles to receive the map from this profile, this is used when no profiles have been selected</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3833"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3859"/> <source>Could not update themes: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="3836"/> + <location filename="../src/dlgProfilePreferences.cpp" line="3862"/> <source>Updating themes from colorsublime.github.io...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4014"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4040"/> <source>{missing, possibly recently deleted trigger item}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4017"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4043"/> <source>{missing, possibly recently deleted alias item}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4020"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4046"/> <source>{missing, possibly recently deleted script item}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4023"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4049"/> <source>{missing, possibly recently deleted timer item}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4026"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4052"/> <source>{missing, possibly recently deleted key item}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4029"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4055"/> <source>{missing, possibly recently deleted button item}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4158"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4184"/> <source>The room symbol will appear like this if only symbols (glyphs) from the specific font are used.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4163"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4189"/> <source>The room symbol will appear like this if symbols (glyphs) from any font can be used.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4203"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4229"/> <source>How many rooms in the whole map have this symbol.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4221"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4247"/> <source>The rooms with this symbol, up to a maximum of thirty-two, if there are more than this, it is indicated but they are not shown.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4229"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4255"/> <source>The symbol can be made entirely from glyphs in the specified font.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4247"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4273"/> <source>The symbol cannot be drawn using any of the fonts in the system, either an invalid string was entered as the symbol for the indicated rooms or the map was created on a different systems with a different set of fonts available to use. You may be able to correct this by installing an additional font using whatever method is appropriate for this system or by editing the map to use a different symbol. It may be possible to do the latter via a lua script using the <i>getRoomChar</i> and <i>setRoomChar</i> functions.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4340"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4366"/> <source>Large icon</source> <extracomment>Discord Rich Presence large icon</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4342"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4368"/> <source>Detail</source> <extracomment>Discord Rich Presence detail</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4345"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4371"/> <source>Small icon</source> <extracomment>Discord Rich Presence small icon"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4347"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4373"/> <source>State</source> <extracomment>Discord Rich Presence state</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4350"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4376"/> <source>Party size</source> <extracomment>Discord Rich Presence party size</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4352"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4378"/> <source>Party max</source> <extracomment>Discord Rich Presence maximum party size</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4354"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4380"/> <source>Time</source> <extracomment>Discord Rich Presence time until or time elapsed</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4969"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4995"/> <source>Set outer color of player room mark.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4969"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4995"/> <source>Set inner color of player room mark.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="179"/> + <location filename="../src/dlgProfilePreferences.cpp" line="180"/> <source><p>This option sets the format of the log name.</p><p>If <i>Named file</i> is selected, you can set a custom file name. (Logs are appended if a log file of the same name already exists.)</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="753"/> + <location filename="../src/dlgProfilePreferences.cpp" line="767"/> <source>%1 - not recognised</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="767"/> + <location filename="../src/dlgProfilePreferences.cpp" line="781"/> <source><p>Mudlet does not recognise the code "%1", please report it to the Mudlet developers so we can describe it properly in future Mudlet versions!</p><p>The file <tt>%2.dic</tt> (and its companion affix <tt>.aff</tt> file) is still usable.</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="784"/> + <location filename="../src/dlgProfilePreferences.cpp" line="798"/> <source>No Hunspell dictionary files found, spell-checking will not be available.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="903"/> - <location filename="../src/dlgProfilePreferences.cpp" line="904"/> + <location filename="../src/dlgProfilePreferences.cpp" line="917"/> + <location filename="../src/dlgProfilePreferences.cpp" line="920"/> <source>Mudlet will only show Rich Presence information while you use this Discord username (useful if you have multiple Discord accounts). Leave empty to show it for any Discord account you log in to. This must be the unique Discord username that uses a restricted lowercase ASCII character set and not any "Nickname" that you may have set for a particular Server.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="910"/> + <location filename="../src/dlgProfilePreferences.cpp" line="929"/> <source>This is the unique username using a restricted character set for the Discord account, and not necessarily the nickname that you might have set for a particular Server.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="912"/> + <location filename="../src/dlgProfilePreferences.cpp" line="931"/> <source>(Not connected)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2791"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2817"/> <source>[ ERROR ] - Unable to load JSON map file: %1 reason: %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2831"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2857"/> <source>Any map file (*.dat *.json *.xml)</source> <comment>Do not change extensions (in braces) as they are used programmatically</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2832"/> - <location filename="../src/dlgProfilePreferences.cpp" line="2865"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2858"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2891"/> <source>Mudlet binary map (*.dat)</source> <comment>Do not change extensions (in braces) as they are used programmatically</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2833"/> - <location filename="../src/dlgProfilePreferences.cpp" line="2866"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2859"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2892"/> <source>Mudlet JSON map (*.json)</source> <comment>Do not change extensions (in braces) as they are used programmatically</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2834"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2860"/> <source>Mudlet XML map (*.xml)</source> <comment>Do not change extensions (in braces) as they are used programmatically</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="2835"/> + <location filename="../src/dlgProfilePreferences.cpp" line="2861"/> <source>Any file (*)</source> <comment>Do not change extensions (in braces) as they are used programmatically</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4191"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4217"/> <source><p>These are the sequence of hexadecimal numbers that are used by the Unicode consortium to identify the graphemes needed to create the symbol. These numbers can be utilised to determine precisely what is to be drawn even if some fonts have glyphs that are the same for different codepoints or combination of codepoints.</p><p>Character entry utilities such as <i>charmap.exe</i> on <i>Windows</i> or <i>gucharmap</i> on many Unix type operating systems will also use these numbers which cover everything from U+0020 {Space} to U+10FFFD the last usable number in the <i>Private Use Plane 16</i> via most of the written marks that humanity has ever made.</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4215"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4241"/> <source>more - not shown...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4238"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4264"/> <source><p>The symbol cannot be made entirely from glyphs in the specified font, but, using other fonts in the system, it can. Either un-check the <i>Only use symbols (glyphs) from chosen font</i> option or try and choose another font that does have the needed glyphs.</p><p><i>You need not close this table to try another font, changing it on the main preferences dialogue will update this table after a slight delay.</i></p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4392"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4418"/> <source>Map symbol usage - %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4502"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4528"/> <source>yyyy-MM-dd#HH-mm-ss (e.g., 1970-01-01#00-00-00.html)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4503"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4529"/> <source>yyyy-MM-ddTHH-mm-ss (e.g., 1970-01-01T00-00-00.html)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4504"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4530"/> <source>yyyy-MM-dd (concatenate daily logs in, e.g. 1970-01-01.html)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4505"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4531"/> <source>yyyy-MM (concatenate month logs in, e.g. 1970-01.html)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4508"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4534"/> <source>yyyy-MM-dd#HH-mm-ss (e.g., 1970-01-01#00-00-00.txt)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4509"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4535"/> <source>yyyy-MM-ddTHH-mm-ss (e.g., 1970-01-01T00-00-00.txt)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4510"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4536"/> <source>yyyy-MM-dd (concatenate daily logs in, e.g. 1970-01-01.txt)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="4511"/> + <location filename="../src/dlgProfilePreferences.cpp" line="4537"/> <source>yyyy-MM (concatenate month logs in, e.g. 1970-01.txt)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="5032"/> + <location filename="../src/dlgProfilePreferences.cpp" line="5058"/> <source>New: undo the game's own wrapping</source> <extracomment>Title of a balloon pointing out a newly added feature</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="5034"/> + <location filename="../src/dlgProfilePreferences.cpp" line="5060"/> <source>Games that wrap their own lines make triggers fiddly. Mudlet can now undo that wrapping, so triggers always see whole lines.</source> <extracomment>Body of the balloon, anchored to the option that rejoins lines the game server wrapped itself so that triggers match whole lines</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="5086"/> + <location filename="../src/dlgProfilePreferences.cpp" line="5112"/> <source>Deleting map - please wait...</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgProfilePreferences.cpp" line="5095"/> + <location filename="../src/dlgProfilePreferences.cpp" line="5121"/> <source>Deleted map.</source> <translation type="unfinished"></translation> </message> @@ -10794,361 +10921,361 @@ Format for showing a room weight with its usage count. %1 is the weight value (e <context> <name>dlgTriggerEditor</name> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="797"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8504"/> - <location filename="../src/dlgTriggerEditor.h" line="586"/> + <location filename="../src/dlgTriggerEditor.cpp" line="803"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8598"/> + <location filename="../src/dlgTriggerEditor.h" line="598"/> <source>Triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="798"/> - <location filename="../src/dlgTriggerEditor.cpp" line="799"/> + <location filename="../src/dlgTriggerEditor.cpp" line="804"/> + <location filename="../src/dlgTriggerEditor.cpp" line="805"/> <source>Show Triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="827"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8532"/> - <location filename="../src/dlgTriggerEditor.h" line="592"/> + <location filename="../src/dlgTriggerEditor.cpp" line="833"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8626"/> + <location filename="../src/dlgTriggerEditor.h" line="604"/> <source>Buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="828"/> - <location filename="../src/dlgTriggerEditor.cpp" line="829"/> + <location filename="../src/dlgTriggerEditor.cpp" line="834"/> + <location filename="../src/dlgTriggerEditor.cpp" line="835"/> <source>Show Buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="802"/> - <location filename="../src/dlgTriggerEditor.h" line="587"/> + <location filename="../src/dlgTriggerEditor.cpp" line="808"/> + <location filename="../src/dlgTriggerEditor.h" line="599"/> <source>Aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="803"/> - <location filename="../src/dlgTriggerEditor.cpp" line="804"/> + <location filename="../src/dlgTriggerEditor.cpp" line="809"/> + <location filename="../src/dlgTriggerEditor.cpp" line="810"/> <source>Show Aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="812"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8511"/> - <location filename="../src/dlgTriggerEditor.h" line="589"/> + <location filename="../src/dlgTriggerEditor.cpp" line="818"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8605"/> + <location filename="../src/dlgTriggerEditor.h" line="601"/> <source>Timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="813"/> - <location filename="../src/dlgTriggerEditor.cpp" line="814"/> + <location filename="../src/dlgTriggerEditor.cpp" line="819"/> + <location filename="../src/dlgTriggerEditor.cpp" line="820"/> <source>Show Timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="807"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8518"/> - <location filename="../src/dlgTriggerEditor.h" line="588"/> + <location filename="../src/dlgTriggerEditor.cpp" line="813"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8612"/> + <location filename="../src/dlgTriggerEditor.h" line="600"/> <source>Scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="808"/> - <location filename="../src/dlgTriggerEditor.cpp" line="809"/> + <location filename="../src/dlgTriggerEditor.cpp" line="814"/> + <location filename="../src/dlgTriggerEditor.cpp" line="815"/> <source>Show Scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="817"/> - <location filename="../src/dlgTriggerEditor.h" line="590"/> + <location filename="../src/dlgTriggerEditor.cpp" line="823"/> + <location filename="../src/dlgTriggerEditor.h" line="602"/> <source>Keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="818"/> - <location filename="../src/dlgTriggerEditor.cpp" line="819"/> + <location filename="../src/dlgTriggerEditor.cpp" line="824"/> + <location filename="../src/dlgTriggerEditor.cpp" line="825"/> <source>Show Keybindings</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="822"/> - <location filename="../src/dlgTriggerEditor.cpp" line="9035"/> - <location filename="../src/dlgTriggerEditor.h" line="591"/> + <location filename="../src/dlgTriggerEditor.cpp" line="828"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9129"/> + <location filename="../src/dlgTriggerEditor.h" line="603"/> <source>Variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="823"/> - <location filename="../src/dlgTriggerEditor.cpp" line="824"/> + <location filename="../src/dlgTriggerEditor.cpp" line="829"/> + <location filename="../src/dlgTriggerEditor.cpp" line="830"/> <source>Show Variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="850"/> + <location filename="../src/dlgTriggerEditor.cpp" line="856"/> <source>Activate</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="851"/> + <location filename="../src/dlgTriggerEditor.cpp" line="857"/> <source>Toggle Active or Non-Active Mode for Triggers, Scripts etc.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="869"/> + <location filename="../src/dlgTriggerEditor.cpp" line="875"/> <source>Delete Item</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="893"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13106"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13115"/> + <location filename="../src/dlgTriggerEditor.cpp" line="899"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13229"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13238"/> <source>Copy</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="897"/> - <location filename="../src/dlgTriggerEditor.cpp" line="898"/> + <location filename="../src/dlgTriggerEditor.cpp" line="903"/> + <location filename="../src/dlgTriggerEditor.cpp" line="904"/> <source>Copy the trigger/script/alias/etc</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="907"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13107"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13116"/> + <location filename="../src/dlgTriggerEditor.cpp" line="913"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13230"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13239"/> <source>Paste</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="911"/> - <location filename="../src/dlgTriggerEditor.cpp" line="912"/> + <location filename="../src/dlgTriggerEditor.cpp" line="917"/> + <location filename="../src/dlgTriggerEditor.cpp" line="918"/> <source>Paste triggers/scripts/aliases/etc from the clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="952"/> + <location filename="../src/dlgTriggerEditor.cpp" line="958"/> <source>Import</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="956"/> + <location filename="../src/dlgTriggerEditor.cpp" line="962"/> <source>Export</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="965"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12801"/> - <location filename="../src/dlgTriggerEditor.h" line="585"/> + <location filename="../src/dlgTriggerEditor.cpp" line="971"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12924"/> + <location filename="../src/dlgTriggerEditor.h" line="597"/> <source>Save Profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="981"/> + <location filename="../src/dlgTriggerEditor.cpp" line="987"/> <source>Save Profile As</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="838"/> - <location filename="../src/dlgTriggerEditor.h" line="594"/> + <location filename="../src/dlgTriggerEditor.cpp" line="844"/> + <location filename="../src/dlgTriggerEditor.h" line="606"/> <source>Statistics</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="325"/> + <location filename="../src/dlgTriggerEditor.cpp" line="327"/> <source>new folder</source> <extracomment>Accessible description for a newly created folder, shown after the folder name</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="327"/> + <location filename="../src/dlgTriggerEditor.cpp" line="329"/> <source>new item</source> <extracomment>Accessible description for a newly created item, shown after the item name</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="333"/> + <location filename="../src/dlgTriggerEditor.cpp" line="335"/> <source>%1 - Editor</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="721"/> + <location filename="../src/dlgTriggerEditor.cpp" line="723"/> <source>*** starting new session ***</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="843"/> - <location filename="../src/dlgTriggerEditor.h" line="595"/> + <location filename="../src/dlgTriggerEditor.cpp" line="849"/> + <location filename="../src/dlgTriggerEditor.h" line="607"/> <source>Debug</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="989"/> + <location filename="../src/dlgTriggerEditor.cpp" line="995"/> <source>Something went wrong loading your Mudlet profile and it could not be loaded. Try loading an older version in 'Connect - Options - Profile history'</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1016"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1022"/> <source>Editor Toolbar - %1 - Actions</source> <extracomment>This is the toolbar that is initially placed at the top of the editor.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1057"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1063"/> <source>Editor Toolbar - %1 - Items</source> <extracomment>This is the toolbar that is initially placed at the left side of the editor.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1067"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1073"/> <source>Restore Actions toolbar</source> <extracomment>This will restore that toolbar in the editor window, after a user has hidden it or moved it to another docking location or floated it elsewhere.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1070"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1076"/> <source>Restore Items toolbar</source> <extracomment>This will restore that toolbar in the editor window, after a user has hidden it or moved it to another docking location or floated it elsewhere.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1234"/> - <location filename="../src/dlgTriggerEditor.cpp" line="1237"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1241"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1244"/> <source>Search Options</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1241"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1248"/> <source>Case sensitive</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>start of line</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="4915"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4936"/> <source>New trigger group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="4915"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4936"/> <source>New trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5021"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5042"/> <source>New timer group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5021"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5042"/> <source>New timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5176"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5197"/> <source>New key group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5176"/> - <location filename="../src/dlgTriggerEditor.cpp" line="6955"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7028"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5197"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7044"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7117"/> <source>New key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5265"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5286"/> <source>New alias group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5265"/> - <location filename="../src/dlgTriggerEditor.cpp" line="6149"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5286"/> + <location filename="../src/dlgTriggerEditor.cpp" line="6168"/> <source>New alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5360"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5381"/> <source>New menu</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5360"/> - <location filename="../src/dlgTriggerEditor.cpp" line="5389"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5381"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5408"/> <source>New button</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5389"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5408"/> <source>New toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5464"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5483"/> <source>New script group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5464"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5483"/> <source>New script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="6165"/> + <location filename="../src/dlgTriggerEditor.cpp" line="6367"/> <source>Alias <em>%1</em> has an infinite loop - substitution matches its own pattern. Please fix it - this alias isn't good as it'll call itself forever.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="6590"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8369"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13592"/> + <location filename="../src/dlgTriggerEditor.cpp" line="6672"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8463"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13718"/> <source>While loading the profile, this script had an error that has since been fixed, possibly by another script. The error was:%2%3</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="6913"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8117"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7002"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8206"/> <source>Checked variables will be saved and loaded with your profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7140"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7229"/> <source>match on the prompt line</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7144"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7233"/> <source>match on the prompt line (disabled)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7145"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7234"/> <source>A Go-Ahead (GA) signal from the game is required to make this feature work</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7580"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7582"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7669"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7671"/> <source>fault</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7432"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7552"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12702"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7521"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7641"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12825"/> <source>Foreground color ignored</source> <extracomment>Color trigger ignored foreground color button, ensure all three instances have the same text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="125"/> + <location filename="../src/dlgTriggerEditor.cpp" line="127"/> <source>How to add a new alias from the input line</source> <extracomment>Name of a selectable option for the Alias intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="128"/> - <location filename="../src/dlgTriggerEditor.cpp" line="165"/> + <location filename="../src/dlgTriggerEditor.cpp" line="130"/> + <location filename="../src/dlgTriggerEditor.cpp" line="167"/> <source>There are a <a href='https://forums.mudlet.org/viewtopic.php?f=6&t=22609'>couple</a> of <a href='https://forums.mudlet.org/viewtopic.php?f=6&t=16462'>packages</a> that can help you.</source> <extracomment>Help contents of a selectable option for the Alias intro ---------- @@ -11156,319 +11283,319 @@ Help contents of a selectable option for the Trigger intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="130"/> + <location filename="../src/dlgTriggerEditor.cpp" line="132"/> <source>Alias can also be defined from the input line in the main profile window like this:</source> <extracomment>Part of the Alias intro - This introductory text will be followed by a Lua code example for a trigger.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="133"/> + <location filename="../src/dlgTriggerEditor.cpp" line="135"/> <source>My greetings</source> <extracomment>Part of the Alias intro, code example for an alias - This is the name of the alias which reacts on the player typing "hi" by saying "Greetings, traveller!" in game.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="135"/> + <location filename="../src/dlgTriggerEditor.cpp" line="137"/> <source>hi</source> <extracomment>Part of the Alias intro, code example for an alias - This is the text input from the player which will be reacted on by saying "Greetings, traveller!" in game.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="137"/> + <location filename="../src/dlgTriggerEditor.cpp" line="139"/> <source>say Greetings, traveller!</source> <extracomment>Part of the Alias intro, code example for an alias - This is the command that Mudlet will send to the game after the player typed "hi".</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="139"/> + <location filename="../src/dlgTriggerEditor.cpp" line="141"/> <source>We said hi!</source> <extracomment>Part of the Alias intro, code example for an alias - This is the confirmation text shown to the player after they typed "hi" and we said "Greetings, traveller!" in game.</extracomment> <translation type="unfinished"></translation> </message> - <message> - <location filename="../src/dlgTriggerEditor.cpp" line="142"/> - <location filename="../src/dlgTriggerEditor.cpp" line="177"/> - <location filename="../src/dlgTriggerEditor.cpp" line="202"/> - <location filename="../src/dlgTriggerEditor.cpp" line="227"/> - <location filename="../src/dlgTriggerEditor.cpp" line="248"/> - <location filename="../src/dlgTriggerEditor.cpp" line="270"/> - <location filename="../src/dlgTriggerEditor.cpp" line="296"/> - <source>Where to find more information</source> - <translation type="unfinished"></translation> - </message> <message> <location filename="../src/dlgTriggerEditor.cpp" line="144"/> <location filename="../src/dlgTriggerEditor.cpp" line="179"/> <location filename="../src/dlgTriggerEditor.cpp" line="204"/> + <location filename="../src/dlgTriggerEditor.cpp" line="229"/> + <location filename="../src/dlgTriggerEditor.cpp" line="250"/> <location filename="../src/dlgTriggerEditor.cpp" line="272"/> - <source>Watch a <a href='%1'>video demonstration</a> of the basic functionality.</source> + <location filename="../src/dlgTriggerEditor.cpp" line="298"/> + <source>Where to find more information</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/dlgTriggerEditor.cpp" line="146"/> - <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Aliases'>Introduction to Aliases</a> for a detailed overview.</source> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgTriggerEditor.cpp" line="147"/> - <location filename="../src/dlgTriggerEditor.cpp" line="182"/> - <location filename="../src/dlgTriggerEditor.cpp" line="207"/> - <location filename="../src/dlgTriggerEditor.cpp" line="230"/> - <location filename="../src/dlgTriggerEditor.cpp" line="251"/> - <location filename="../src/dlgTriggerEditor.cpp" line="275"/> - <location filename="../src/dlgTriggerEditor.cpp" line="299"/> - <source>Do you maybe have any other suggestions, questions or doubts?</source> + <location filename="../src/dlgTriggerEditor.cpp" line="181"/> + <location filename="../src/dlgTriggerEditor.cpp" line="206"/> + <location filename="../src/dlgTriggerEditor.cpp" line="274"/> + <source>Watch a <a href='%1'>video demonstration</a> of the basic functionality.</source> <translation type="unfinished"></translation> </message> <message> <location filename="../src/dlgTriggerEditor.cpp" line="148"/> - <location filename="../src/dlgTriggerEditor.cpp" line="183"/> - <location filename="../src/dlgTriggerEditor.cpp" line="208"/> - <location filename="../src/dlgTriggerEditor.cpp" line="231"/> - <location filename="../src/dlgTriggerEditor.cpp" line="252"/> - <location filename="../src/dlgTriggerEditor.cpp" line="276"/> - <location filename="../src/dlgTriggerEditor.cpp" line="300"/> + <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Aliases'>Introduction to Aliases</a> for a detailed overview.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgTriggerEditor.cpp" line="149"/> + <location filename="../src/dlgTriggerEditor.cpp" line="184"/> + <location filename="../src/dlgTriggerEditor.cpp" line="209"/> + <location filename="../src/dlgTriggerEditor.cpp" line="232"/> + <location filename="../src/dlgTriggerEditor.cpp" line="253"/> + <location filename="../src/dlgTriggerEditor.cpp" line="277"/> + <location filename="../src/dlgTriggerEditor.cpp" line="301"/> + <source>Do you maybe have any other suggestions, questions or doubts?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgTriggerEditor.cpp" line="150"/> + <location filename="../src/dlgTriggerEditor.cpp" line="185"/> + <location filename="../src/dlgTriggerEditor.cpp" line="210"/> + <location filename="../src/dlgTriggerEditor.cpp" line="233"/> + <location filename="../src/dlgTriggerEditor.cpp" line="254"/> + <location filename="../src/dlgTriggerEditor.cpp" line="278"/> + <location filename="../src/dlgTriggerEditor.cpp" line="302"/> <source>Join our community on <a href='https://www.mudlet.org/chat'>Discord</a> or in <a href='https://forums.mudlet.org/'>Mudlet forums</a> - See you there!</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="162"/> + <location filename="../src/dlgTriggerEditor.cpp" line="164"/> <source>How to add a new trigger from the input line</source> <extracomment>Name of a selectable option for the Trigger intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="167"/> + <location filename="../src/dlgTriggerEditor.cpp" line="169"/> <source>Triggers can also be defined from the input line in the main profile window like this:</source> <extracomment>Part of the Trigger intro - This introductory text will be followed by a Lua code example for a trigger.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="170"/> + <location filename="../src/dlgTriggerEditor.cpp" line="172"/> <source>My drink trigger</source> <extracomment>Part of the Trigger intro, code example for a trigger - This is the name of the trigger which reacts on "You are thirsty" with "drink water".</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="172"/> + <location filename="../src/dlgTriggerEditor.cpp" line="174"/> <source>You are thirsty.</source> <extracomment>Part of the Trigger intro, code example for a trigger - This is the text from game which will be triggered on, and reacted to with "drink water".</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="174"/> + <location filename="../src/dlgTriggerEditor.cpp" line="176"/> <source>drink water</source> <extracomment>Part of the Trigger intro, code example for a trigger - This is the command sent to game after we triggered on text "You are thirsty." from game.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="181"/> + <location filename="../src/dlgTriggerEditor.cpp" line="183"/> <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Triggers'>Introduction to Triggers</a> for a detailed overview.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="206"/> + <location filename="../src/dlgTriggerEditor.cpp" line="208"/> <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Scripts'>Introduction to Scripts</a> for a detailed overview.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="222"/> + <location filename="../src/dlgTriggerEditor.cpp" line="224"/> <source>How to add a new timer from the input line</source> <extracomment>Name of a selectable option for the Timer intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="229"/> + <location filename="../src/dlgTriggerEditor.cpp" line="231"/> <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Timers'>Introduction to Timers</a> for a detailed overview.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="239"/> + <location filename="../src/dlgTriggerEditor.cpp" line="241"/> <source><ol><li>Add a new group to create a <strong>button bar</strong>.</li><li>Add groups as <strong>menus</strong> or sub-menus.</li><li>Add items as <strong>buttons</strong> to a bar or menu.</li><li>Define a <strong>command</strong> or script to execute when pressed.</li><li><strong>Activate</strong> the item. </li></ol><p><strong>Note:</strong> Deactivated items are hidden, including all items they contain.</p><p><strong>Click-down buttons:</strong> Can define separate commands for press/release. Use getButtonState() to check state.</p></source> <extracomment>Help contents of a selectable option for the Button intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="250"/> + <location filename="../src/dlgTriggerEditor.cpp" line="252"/> <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Buttons'>Introduction to Buttons</a> for a detailed overview.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="265"/> + <location filename="../src/dlgTriggerEditor.cpp" line="267"/> <source>How to add a new keybinding from the input line</source> <extracomment>Name of a selectable option for the Keys intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="274"/> + <location filename="../src/dlgTriggerEditor.cpp" line="276"/> <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Keybindings'>Introduction to Keybindings</a> for a detailed overview.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="291"/> + <location filename="../src/dlgTriggerEditor.cpp" line="293"/> <source>How to add a new variable from the input line</source> <extracomment>Name of a selectable option for the Variable intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="298"/> + <location filename="../src/dlgTriggerEditor.cpp" line="300"/> <source>Read the <a href='http://wiki.mudlet.org/w/Manual:Introduction#Variables'>Introduction to Variables</a> for a detailed overview.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="329"/> + <location filename="../src/dlgTriggerEditor.cpp" line="331"/> <source>package item</source> <extracomment>Accessible description indicating an item belongs to a package, shown after the item name. Keep short, as it's appended to other descriptions like "activated, package item"</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="458"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13102"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13111"/> + <location filename="../src/dlgTriggerEditor.cpp" line="460"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13225"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13234"/> <source>Undo</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="471"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13103"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13112"/> + <location filename="../src/dlgTriggerEditor.cpp" line="473"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13226"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13235"/> <source>Redo</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="485"/> - <location filename="../src/dlgTriggerEditor.cpp" line="1563"/> + <location filename="../src/dlgTriggerEditor.cpp" line="487"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1580"/> <source>Undo: %1 (%2)</source> <extracomment>Tooltip for undo action. %1 is the action being undone (e.g., "Activate trigger "foo""), %2 is the keyboard shortcut</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="490"/> - <location filename="../src/dlgTriggerEditor.cpp" line="1574"/> + <location filename="../src/dlgTriggerEditor.cpp" line="492"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1591"/> <source>Undo (%1)</source> <extracomment>Tooltip for undo action when no specific action. %1 is the keyboard shortcut</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="499"/> - <location filename="../src/dlgTriggerEditor.cpp" line="1568"/> + <location filename="../src/dlgTriggerEditor.cpp" line="501"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1585"/> <source>Redo: %1 (%2)</source> <extracomment>Tooltip for redo action. %1 is the action being redone (e.g., "Activate trigger "foo""), %2 is the keyboard shortcut</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="504"/> - <location filename="../src/dlgTriggerEditor.cpp" line="1577"/> + <location filename="../src/dlgTriggerEditor.cpp" line="506"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1594"/> <source>Redo (%1)</source> <extracomment>Tooltip for redo action when no specific action. %1 is the keyboard shortcut</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="847"/> + <location filename="../src/dlgTriggerEditor.cpp" line="853"/> <source>Show/Hide Debug Console (%1) -> system will be <b><i>slower</i></b>.</source> <extracomment>%1 is a keyboard shortcut, e.g. 'Ctrl+0' on Windows/Linux or '⌘0' on macOS</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="862"/> + <location filename="../src/dlgTriggerEditor.cpp" line="868"/> <source>Add Item</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="960"/> + <location filename="../src/dlgTriggerEditor.cpp" line="966"/> <source>Create Module</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="962"/> + <location filename="../src/dlgTriggerEditor.cpp" line="968"/> <source><p>Create a module from selected items</p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="967"/> + <location filename="../src/dlgTriggerEditor.cpp" line="973"/> <source><p>Saves your profile. (%1)</p><p>Saves your entire profile (triggers, aliases, scripts, timers, buttons and keys, but not the map or script-specific settings) to your computer disk, so in case of a computer or program crash, all changes you have done will be retained.</p><p>It also makes a backup of your profile, you can load an older version of it when connecting.</p><p>Should there be any modules that are marked to be "<i>synced</i>" this will also cause them to be saved and reloaded into other profiles if they too are active.</p></source> <extracomment>%1 is a keyboard shortcut, e.g. 'Ctrl+Shift+S' on Windows/Linux or '⌘⇧S' on macOS</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1253"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1260"/> <source>Whole word</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1255"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1262"/> <source>Only match whole words</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1723"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1740"/> <source>Text to find (anywhere in the game output)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1725"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1742"/> <source>Text to find (as a regular expression pattern)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1727"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1744"/> <source>Text to find (from beginning of the line)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1729"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1746"/> <source>Exact line to match</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1731"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1748"/> <source>Lua code to run (return true to match)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="3918"/> + <location filename="../src/dlgTriggerEditor.cpp" line="3939"/> <source><p>Unable to activate "<tt>%1</tt>": %2</p> <p><i>You will need to reactivate this after the problem has been corrected.</i></p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="4025"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4046"/> <source>move items</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="4218"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4239"/> <source><p><b>Unable to activate "<tt>%1</tt>": %2.</b></p> <p><i>You will need to reactivate this after the problem has been corrected.</i></p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="4366"/> - <location filename="../src/dlgTriggerEditor.cpp" line="4495"/> - <location filename="../src/dlgTriggerEditor.cpp" line="4658"/> - <location filename="../src/dlgTriggerEditor.cpp" line="4830"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4387"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4516"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4679"/> + <location filename="../src/dlgTriggerEditor.cpp" line="4851"/> <source><p><b>Unable to activate "<tt>%1</tt>"; %2.</b></p> <p><i>You will need to reactivate this after the problem has been corrected.</i></p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5126"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5147"/> <source>table_variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5126"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5147"/> <source>variable_name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="5757"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7658"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7739"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7822"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8264"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8384"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8472"/> + <location filename="../src/dlgTriggerEditor.cpp" line="5776"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7747"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7828"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7911"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8358"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8478"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8566"/> <source>This item is part of a package. To best preserve your changes, copy this item before editing as package upgrades may overwrite modifications.</source> <extracomment>Package item warning shown in trigger editor when editing package items. Should only be announced to screen readers once per item, not repeatedly on every edit. ---------- @@ -11476,1131 +11603,1135 @@ Package item warning banner shown in trigger editor when selecting package items <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7436"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7556"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12705"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7525"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7645"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12828"/> <source>Default foreground color</source> <extracomment>Color trigger default foreground color button, ensure all three instances have the same text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7440"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7560"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12708"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7529"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7649"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12831"/> <source>Foreground color [ANSI %1]</source> <extracomment>Color trigger ANSI foreground color button, ensure all three instances have the same text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7446"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7566"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12765"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7535"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7655"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12888"/> <source>Background color ignored</source> <extracomment>Color trigger ignored background color button, ensure all three instances have the same text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7450"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7570"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12768"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7539"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7659"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12891"/> <source>Default background color</source> <extracomment>Color trigger default background color button, ensure all three instances have the same text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7454"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7574"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12771"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7543"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7663"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12894"/> <source>Background color [ANSI %1]</source> <extracomment>Color trigger ANSI background color button, ensure all three instances have the same text</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7635"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7639"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12562"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12606"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13264"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13266"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7724"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7728"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12685"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12729"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13387"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13389"/> <source>keep</source> <extracomment>Keep the existing colour on matches to highlight. Use shortest word possible so it fits on the button</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="7664"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7745"/> - <location filename="../src/dlgTriggerEditor.cpp" line="7828"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8270"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8390"/> - <location filename="../src/dlgTriggerEditor.cpp" line="8478"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7753"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7834"/> + <location filename="../src/dlgTriggerEditor.cpp" line="7917"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8364"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8484"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8572"/> <source>Package item. Copy before editing to preserve changes.</source> <extracomment>First-time educational message for screen reader users about package items</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="8186"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12525"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8278"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12648"/> <source>Command:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="8226"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8320"/> <source>Menu properties</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="8236"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8330"/> <source>Button properties</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="8244"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8338"/> <source>Command (down);</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="8525"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8619"/> <source>Aliases - Input Triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="8539"/> + <location filename="../src/dlgTriggerEditor.cpp" line="8633"/> <source>Key Bindings</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9759"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9868"/> <source>Add Trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9760"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9869"/> <source>Add new trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9761"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9870"/> <source>Add Trigger Group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9762"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9871"/> <source>Add new group of triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9763"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9872"/> <source>Delete Trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9764"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9873"/> <source>Delete the selected trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9765"/> - <location filename="../src/dlgTriggerEditor.h" line="578"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9874"/> + <location filename="../src/dlgTriggerEditor.h" line="590"/> <source>Save Trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9770"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9879"/> <source>Add Timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9771"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9880"/> <source>Add new timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9772"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9881"/> <source>Add Timer Group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9773"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9882"/> <source>Add new group of timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9774"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9883"/> <source>Delete Timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9775"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9884"/> <source>Delete the selected timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9776"/> - <location filename="../src/dlgTriggerEditor.h" line="579"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9885"/> + <location filename="../src/dlgTriggerEditor.h" line="591"/> <source>Save Timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9781"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9890"/> <source>Add Alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9782"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9891"/> <source>Add new alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9783"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9892"/> <source>Add Alias Group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9784"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9893"/> <source>Add new group of aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9785"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9894"/> <source>Delete Alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9786"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9895"/> <source>Delete the selected alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9787"/> - <location filename="../src/dlgTriggerEditor.h" line="580"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9896"/> + <location filename="../src/dlgTriggerEditor.h" line="592"/> <source>Save Alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9792"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9901"/> <source>Add Script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9793"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9902"/> <source>Add new script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9794"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9903"/> <source>Add Script Group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9795"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9904"/> <source>Add new group of scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9796"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9905"/> <source>Delete Script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9797"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9906"/> <source>Delete the selected script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9798"/> - <location filename="../src/dlgTriggerEditor.h" line="581"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9907"/> + <location filename="../src/dlgTriggerEditor.h" line="593"/> <source>Save Script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9803"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9912"/> <source>Add Button</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9804"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9913"/> <source>Add new button</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9805"/> - <source>Add Button Group</source> + <location filename="../src/dlgTriggerEditor.cpp" line="9914"/> + <source>Add Toolbar or Menu</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9806"/> - <source>Add new group of buttons</source> + <location filename="../src/dlgTriggerEditor.cpp" line="9915"/> + <source>Add a Toolbar (top level) or Menu (lower levels) to contain menus or buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9807"/> - <source>Delete Button</source> + <location filename="../src/dlgTriggerEditor.cpp" line="9916"/> + <source>Delete Button, Menu or Toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9808"/> - <source>Delete the selected button</source> + <location filename="../src/dlgTriggerEditor.cpp" line="9917"/> + <source>Delete the selected button, menu or toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9809"/> - <location filename="../src/dlgTriggerEditor.h" line="582"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9918"/> + <source>Save item</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgTriggerEditor.cpp" line="9920"/> + <source>Apply button/menu/toolbar changes (does not save to disk).</source> + <extracomment>Status tip for saving button changes</extracomment> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../src/dlgTriggerEditor.h" line="594"/> <source>Save Button</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9814"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9923"/> <source>Add Key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9815"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9924"/> <source>Add new key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9816"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9925"/> <source>Add Key Group</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9817"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9926"/> <source>Add new group of keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9818"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9927"/> <source>Delete Key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9819"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9928"/> <source>Delete the selected key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9820"/> - <location filename="../src/dlgTriggerEditor.h" line="583"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9929"/> + <location filename="../src/dlgTriggerEditor.h" line="595"/> <source>Save Key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9825"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9934"/> <source>Add Variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9826"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9935"/> <source>Add new variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9827"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9936"/> <source>Add Lua table</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9828"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9937"/> <source>Add new Lua table</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9829"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9938"/> <source>Delete Variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9830"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9939"/> <source>Delete the selected variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9831"/> - <location filename="../src/dlgTriggerEditor.h" line="584"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9940"/> + <location filename="../src/dlgTriggerEditor.h" line="596"/> <source>Save Variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="10623"/> + <location filename="../src/dlgTriggerEditor.cpp" line="10738"/> <source>Central Debug Console</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="10905"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10909"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10929"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10933"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10953"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10957"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10977"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10981"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11001"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11005"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11025"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11030"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11043"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11060"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11107"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11124"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11163"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11180"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11219"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11236"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11275"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11292"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11331"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11348"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11020"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11024"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11044"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11048"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11068"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11072"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11092"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11096"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11116"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11120"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11140"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11145"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11158"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11175"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11222"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11239"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11278"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11295"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11334"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11351"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11390"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11407"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11446"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11463"/> <source>Export Package:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="10905"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10909"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10929"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10933"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10953"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10957"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10977"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10981"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11001"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11005"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11025"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11030"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11043"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11107"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11163"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11219"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11275"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11331"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11020"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11024"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11044"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11048"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11068"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11072"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11092"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11096"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11116"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11120"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11140"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11145"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11158"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11222"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11278"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11334"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11390"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11446"/> <source>You have to choose an item for export first. Please select a tree item and then click on export again.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="10914"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10938"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10962"/> - <location filename="../src/dlgTriggerEditor.cpp" line="10986"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11010"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11035"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11029"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11053"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11077"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11101"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11125"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11150"/> <source>Package %1 saved</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11060"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11175"/> <source>No valid triggers found to export.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11068"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11131"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11187"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11243"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11299"/> - <location filename="../src/dlgTriggerEditor.cpp" line="11355"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11183"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11246"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11302"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11358"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11414"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11470"/> <source>Copied %1 to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11072"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11187"/> <source>Copied %1 triggers to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11124"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11239"/> <source>No valid timers found to export.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11134"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11249"/> <source>Copied %1 timers to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11180"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11295"/> <source>No valid aliases found to export.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11190"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11305"/> <source>Copied %1 aliases to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11236"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11351"/> <source>No valid actions found to export.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11246"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11361"/> <source>Copied %1 actions to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11292"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11407"/> <source>No valid scripts found to export.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11302"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11417"/> <source>Copied %1 scripts to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11348"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11463"/> <source>No valid keys found to export.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11358"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11473"/> <source>Copied %1 keys to clipboard</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11393"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11508"/> <source>Mudlet packages (*.xml)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11393"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11508"/> <source>Export Item</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11410"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11525"/> <source>export package:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11410"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11525"/> <source>Cannot write file %1: %2.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11708"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11823"/> <source>Pasted %1 items successfully</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="11728"/> + <location filename="../src/dlgTriggerEditor.cpp" line="11843"/> <source>paste</source> <extracomment>Undo/redo text for pasting items</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12216"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12331"/> <source>Import Mudlet Package</source> <extracomment>Trigger editor - import packages from file dialog (multi-select enabled) Trigger editor - file filter for supported package types (mpackage, zip, xml)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12216"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12331"/> <source>Mudlet Packages (*.mpackage *.zip *.xml)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12259"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12374"/> <source>Failed to import: %1</source> <extracomment>Trigger editor - status message shown when some packages failed to import. %1 is a comma-separated list of package names</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12341"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12464"/> <source>Couldn't save profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12341"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12464"/> <source>Sorry, couldn't save your profile - got the following error: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12351"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12474"/> <source>Backup Profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12351"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12474"/> <source>trigger files (*.trigger *.xml)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12550"/> - <location filename="../src/dlgTriggerEditor.cpp" line="12594"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12673"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12717"/> <source>Keep color</source> <extracomment>Button in the color picker that preserves the existing text color on trigger matches</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12628"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12751"/> <source>Audio files(*.aac *.mp3 *.mp4a *.oga *.ogg *.pcm *.wav *.wma);;Advanced Audio Coding-stream(*.aac);;MPEG-2 Audio Layer 3(*.mp3);;MPEG-4 Audio(*.mp4a);;Ogg Vorbis(*.oga *.ogg);;PCM Audio(*.pcm);;Wave(*.wav);;Windows Media Audio(*.wma);;All files(*.*)</source> <extracomment>This the list of file extensions that are considered for sounds from triggers, the terms inside of the '('...')' and the ";;" are used programmatically and should not be changed.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="14257"/> + <location filename="../src/dlgTriggerEditor.cpp" line="14399"/> <source>Banner hidden. <a href='undo' style='color: inherit; text-decoration: underline;'>Undo</a> | <a href='hide-permanently' style='color: inherit; text-decoration: underline;'>Hide permanently</a></source> <extracomment>Toast notification shown when user dismisses an editor tip banner. Allows them to undo or permanently hide the tips for this editor view type.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12521"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12644"/> <source>Command (down):</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9767"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9876"/> <source>Apply trigger changes (does not save to disk).</source> <extracomment>Status tip for saving trigger changes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9778"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9887"/> <source>Apply timer changes (does not save to disk).</source> <extracomment>Status tip for saving timer changes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9789"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9898"/> <source>Apply alias changes (does not save to disk).</source> <extracomment>Status tip for saving alias changes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9800"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9909"/> <source>Apply script changes (does not save to disk).</source> <extracomment>Status tip for saving script changes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9811"/> - <source>Apply button changes (does not save to disk).</source> - <extracomment>Status tip for saving button changes</extracomment> - <translation type="unfinished"></translation> - </message> - <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9822"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9931"/> <source>Apply key changes (does not save to disk).</source> <extracomment>Status tip for saving key changes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="9833"/> + <location filename="../src/dlgTriggerEditor.cpp" line="9942"/> <source>Apply variable changes (does not save to disk).</source> <extracomment>Status tip for saving variable changes</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12543"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12666"/> <source>Select foreground color to apply to matches</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12587"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12710"/> <source>Select background color to apply to matches</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12625"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12748"/> <source>Choose sound file</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12681"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12804"/> <source>Select foreground trigger color for item %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12745"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12868"/> <source>Select background trigger color for item %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="12794"/> + <location filename="../src/dlgTriggerEditor.cpp" line="12917"/> <source>Saving…</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="13099"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13222"/> <source>Format All</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="13105"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13114"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13228"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13237"/> <source>Cut</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="13109"/> - <location filename="../src/dlgTriggerEditor.cpp" line="13118"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13232"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13241"/> <source>Select All</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="13280"/> + <location filename="../src/dlgTriggerEditor.cpp" line="13403"/> <source>Sound file to play when the trigger fires.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>substring</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="116"/> + <location filename="../src/dlgTriggerEditor.cpp" line="118"/> <source>Alias react on user input.</source> <extracomment>Headline for the Alias intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="118"/> + <location filename="../src/dlgTriggerEditor.cpp" line="120"/> <source>How to add a new alias now</source> <extracomment>Name of a selectable option for the Alias intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="120"/> + <location filename="../src/dlgTriggerEditor.cpp" line="122"/> <source><ol><li>Click on the 'Add Item' icon above.</li><li>Define an input <strong>pattern</strong> either literally or with a Perl regular expression.</li><li>Define a 'substitution' <strong>command</strong> to send to the game in clear text <strong>instead of the alias pattern</strong>, or write a script for more complicated needs.</li><li><strong>Activate</strong> the alias.</li></ol></source> <extracomment>Help contents of a selectable option for the Alias intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="152"/> + <location filename="../src/dlgTriggerEditor.cpp" line="154"/> <source>Triggers react on game output.</source> <extracomment>Headline for the Trigger intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="154"/> + <location filename="../src/dlgTriggerEditor.cpp" line="156"/> <source>How to add a new trigger now</source> <extracomment>Name of a selectable option for the Trigger intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="156"/> + <location filename="../src/dlgTriggerEditor.cpp" line="158"/> <source><ol><li>Click on the 'Add Item' icon above.</li><li>Define a <strong>pattern</strong> that you want to trigger on.</li><li>Select the appropriate pattern <strong>type</strong>.</li><li>Define a clear text <strong>command</strong> that you want to send to the game if the trigger finds the pattern in the text from the game, or write a script for more complicated needs..</li><li><strong>Activate</strong> the trigger.</li></ol></source> <extracomment>Help contents of a selectable option for the Trigger intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="187"/> + <location filename="../src/dlgTriggerEditor.cpp" line="189"/> <source>Scripts organize code and can react to events.</source> <extracomment>Headline for the Script intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="189"/> + <location filename="../src/dlgTriggerEditor.cpp" line="191"/> <source>How to add a new script now</source> <extracomment>Name of a selectable option for the Script intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="191"/> + <location filename="../src/dlgTriggerEditor.cpp" line="193"/> <source><ol><li>Click on the 'Add Item' icon above.</li><li>Enter a script in the box below. You can for example define <strong>functions</strong> to be called by other triggers, aliases, etc.</li><li>If you write lua <strong>commands</strong> without defining a function, they will be run on Mudlet startup and each time you open the script for editing.</li><li><strong>Activate</strong> the script.</li></ol><p><strong>Note:</strong> Scripts are run automatically when viewed, even if they are deactivated.</p></source> <extracomment>Help contents of a selectable option for the Script intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="197"/> + <location filename="../src/dlgTriggerEditor.cpp" line="199"/> <source>How to have a script react to events</source> <extracomment>Name of a selectable option for the Script intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="199"/> + <location filename="../src/dlgTriggerEditor.cpp" line="201"/> <source><p>You can register a list of <strong>events</strong> with the + and - symbols. If one of these events take place, the function with the same name as the script item itself will be called.</p><p><strong>Note:</strong> Events can also be added to a script from the command line in the main profile window like this:</p><p><code>lua registerAnonymousEventHandler(&quot;nameOfTheMudletEvent&quot;, &quot;nameOfYourFunctionToBeCalled&quot;)</code></p></source> <extracomment>Help contents of a selectable option for the Script intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="212"/> + <location filename="../src/dlgTriggerEditor.cpp" line="214"/> <source>Timers react after a timespan once or regularly.</source> <extracomment>Headline for the Timer intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="214"/> + <location filename="../src/dlgTriggerEditor.cpp" line="216"/> <source>How to add a new timer now</source> <extracomment>Name of a selectable option for the Timer intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="216"/> + <location filename="../src/dlgTriggerEditor.cpp" line="218"/> <source><ol><li>Click on the 'Add Item' icon above.</li><li>Define the <strong>timespan</strong> after which the timer should react in a this format: hours : minutes : seconds.</li><li>Define a clear text <strong>command</strong> that you want to send to the game when the time has passed, or write a script for more complicated needs.</li><li><strong>Activate</strong> the timer.</li></ol><p><strong>Note:</strong> If you want the trigger to react only once and not regularly, use the Lua tempTimer() function instead.</p></source> <extracomment>Help contents of a selectable option for the Timer intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="224"/> + <location filename="../src/dlgTriggerEditor.cpp" line="226"/> <source><p>Timers can also be defined from the input line in the main profile window like this:</p><p><code>lua tempTimer(3, function() echo(&quot;hello! &quot;) end)</code></p><p>This will greet you exactly 3 seconds after it was made.</p></source> <extracomment>Help contents of a selectable option for the Timer intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="235"/> + <location filename="../src/dlgTriggerEditor.cpp" line="237"/> <source>Buttons react on mouse clicks.</source> <extracomment>Headline for the Button intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="237"/> + <location filename="../src/dlgTriggerEditor.cpp" line="239"/> <source>How to add a new button now</source> <extracomment>Name of a selectable option for the Button intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="256"/> + <location filename="../src/dlgTriggerEditor.cpp" line="258"/> <source>Keys react on keyboard presses.</source> <extracomment>Headline for the Keys intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="258"/> + <location filename="../src/dlgTriggerEditor.cpp" line="260"/> <source>How to add a new keybinding now</source> <extracomment>Name of a selectable option for the Keys intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="260"/> + <location filename="../src/dlgTriggerEditor.cpp" line="262"/> <source><ol><li>Click on the 'Add Item' icon above.</li><li>Click on <strong>'grab key'</strong> and then press your key combination, e.g. including modifier keys like Control, Shift, etc.</li><li>Define a clear text <strong>command</strong> that you want to send to the game if the button is pressed, or write a script for more complicated needs.</li><li><strong>Activate</strong> the new key binding.</li></ol></source> <extracomment>Help contents of a selectable option for the Keys intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="267"/> + <location filename="../src/dlgTriggerEditor.cpp" line="269"/> <source><p>Keys can be defined from the input line in the main profile window like this:</p><p><code>lua permKey(&quot;my jump key&quot;, &quot;&quot;, mudlet.key.F8, [[send(&quot;jump&quot;]]) end)</code></p><p>Pressing F8 will make you jump.</p></source> <extracomment>Help contents of a selectable option for the Keys intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="280"/> + <location filename="../src/dlgTriggerEditor.cpp" line="282"/> <source>Variables store information.</source> <extracomment>Headline for the Variable intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="282"/> + <location filename="../src/dlgTriggerEditor.cpp" line="284"/> <source>How to add a new variable now</source> <extracomment>Name of a selectable option for the Variable intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="284"/> + <location filename="../src/dlgTriggerEditor.cpp" line="286"/> <source><ol><li>Click on the 'Add Item' icon above. To add a table instead click 'Add Group'.</li><li>Select type of variable value (can be a string, integer, boolean)</li><li>Enter the value you want to store in this variable.</li><li>If you want to keep the variable in your next Mudlet sessions, check the checkbox in the list of variables to the left.</li><li>To remove a variable manually, set it to 'nil' or click on the 'Delete' icon above.</li></ol><p><strong>Note:</strong> Variables created here won't be saved when Mudlet shuts down unless you check their checkbox in the list of variables to the left. You could also create scripts with the variables instead.</p></source> <extracomment>Help contents of a selectable option for the Variable intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="293"/> + <location filename="../src/dlgTriggerEditor.cpp" line="295"/> <source><p>Variables and tables can also be defined from the input line in the main profile window like this:</p><p><code>lua foo = &quot;bar&quot;</code></p><p>This will create a string called 'foo' with 'bar' as its value.</p></source> <extracomment>Help contents of a selectable option for the Variable intro</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="305"/> + <location filename="../src/dlgTriggerEditor.cpp" line="307"/> <source>activated</source> <extracomment>Item is currently on, short enough to be spoken</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="307"/> + <location filename="../src/dlgTriggerEditor.cpp" line="309"/> <source>deactivated</source> <extracomment>Item is currently off, short enough to be spoken</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="309"/> + <location filename="../src/dlgTriggerEditor.cpp" line="311"/> <source>activated folder</source> <extracomment>Folder is currently turned on</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="311"/> + <location filename="../src/dlgTriggerEditor.cpp" line="313"/> <source>deactivated folder</source> <extracomment>Folder is currently turned off</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="313"/> + <location filename="../src/dlgTriggerEditor.cpp" line="315"/> <source>deactivated due to error</source> <extracomment>Item is currently inactive because of errors, short enough to be spoken</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="315"/> + <location filename="../src/dlgTriggerEditor.cpp" line="317"/> <source>%1 in a deactivated group</source> <extracomment>Item is currently turned on individually, but is member of an inactive group</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="317"/> + <location filename="../src/dlgTriggerEditor.cpp" line="319"/> <source>activated filter chain</source> <extracomment>A trigger that unlocks other triggers is currently turned on, short enough to be spoken</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="319"/> + <location filename="../src/dlgTriggerEditor.cpp" line="321"/> <source>deactivated filter chain</source> <extracomment>A trigger that unlocks other triggers is currently turned off, short enough to be spoken</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="321"/> + <location filename="../src/dlgTriggerEditor.cpp" line="323"/> <source>activated offset timer</source> <extracomment>A timer that starts after another timer is currently turned on</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="323"/> + <location filename="../src/dlgTriggerEditor.cpp" line="325"/> <source>deactivated offset timer</source> <extracomment>A timer that starts after another timer is currently turned off</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="400"/> + <location filename="../src/dlgTriggerEditor.cpp" line="402"/> <source>-- add your Lua code here</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="833"/> - <location filename="../src/dlgTriggerEditor.h" line="593"/> + <location filename="../src/dlgTriggerEditor.cpp" line="839"/> + <location filename="../src/dlgTriggerEditor.h" line="605"/> <source>Errors</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="834"/> + <location filename="../src/dlgTriggerEditor.cpp" line="840"/> <source>Show/Hide the errors console in the bottom right of this editor.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="835"/> + <location filename="../src/dlgTriggerEditor.cpp" line="841"/> <source>Show/Hide errors console</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="839"/> + <location filename="../src/dlgTriggerEditor.cpp" line="845"/> <source>Generate a statistics summary display on the main profile console.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="840"/> + <location filename="../src/dlgTriggerEditor.cpp" line="846"/> <source>Generate statistics</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="844"/> + <location filename="../src/dlgTriggerEditor.cpp" line="850"/> <source>Show/Hide the separate Central Debug Console - when being displayed the system will be slower.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="979"/> + <location filename="../src/dlgTriggerEditor.cpp" line="985"/> <source>Save profile (triggers, aliases, scripts, timers, buttons, keys - not the map) and synchronize modules.</source> <extracomment>Status tip for saving profile</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1243"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1250"/> <source>Match case precisely</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1247"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1254"/> <source>Include variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1249"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1256"/> <source>Search variables (slower)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1300"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1308"/> <source>Type</source> <extracomment>Heading for the first column of the search results</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1304"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1312"/> <source>Where</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1306"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1314"/> <source>What</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>perl regex</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>exact match</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>lua function</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>line spacer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>color trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1384"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1392"/> <source>prompt</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2794"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2803"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2828"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2843"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2815"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2824"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2849"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2864"/> <source>Trigger</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="1302"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2425"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2475"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2509"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2593"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2681"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2735"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2794"/> + <location filename="../src/dlgTriggerEditor.cpp" line="1310"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2446"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2496"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2530"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2614"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2702"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2756"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2815"/> <source>Name</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2484"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2489"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2518"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2523"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2602"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2607"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2744"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2749"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2803"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2808"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2505"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2510"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2539"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2544"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2623"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2628"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2765"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2770"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2824"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2829"/> <source>Command</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2828"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2833"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2849"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2854"/> <source>Pattern {%1}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2559"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2564"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2580"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2585"/> <source>Lua code (%1:%2)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2735"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2744"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2760"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2773"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2756"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2765"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2781"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2794"/> <source>Alias</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2760"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2765"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2781"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2786"/> <source>Pattern</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2681"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2699"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2714"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2702"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2720"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2735"/> <source>Script</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2699"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2704"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2720"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2725"/> <source>Event Handler</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2593"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2602"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2619"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2645"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2660"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2614"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2623"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2640"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2666"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2681"/> <source>Button</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="877"/> + <location filename="../src/dlgTriggerEditor.cpp" line="883"/> <source>Add Group (%1)</source> <extracomment>%1 is a keyboard shortcut, e.g. 'Ctrl+Shift+N' on Windows/Linux or '⌘⇧N' on macOS</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="887"/> + <location filename="../src/dlgTriggerEditor.cpp" line="893"/> <source><p>Saves the selected item. (%1)</p><p>Saving causes any changes to the item to take effect. It will not save to disk, so changes will be lost in case of a computer/program crash (but Save Profile to the right will be secure.)</p></source> <extracomment>%1 is a keyboard shortcut, e.g. 'Ctrl+S' on Windows/Linux or '⌘S' on macOS</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2602"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2607"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2623"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2628"/> <source>Command {Down}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2619"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2624"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2640"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2645"/> <source>Command {Up}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2645"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2650"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2666"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2671"/> <source>Stylesheet {L: %1 C: %2}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2475"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2484"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2497"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2496"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2505"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2518"/> <source>Timer</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2509"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2518"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2531"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2530"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2539"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2552"/> <source>Key</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2425"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2439"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2446"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2460"/> <source>Variable</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.cpp" line="2439"/> - <location filename="../src/dlgTriggerEditor.cpp" line="2445"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2460"/> + <location filename="../src/dlgTriggerEditor.cpp" line="2466"/> <source>Value</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/dlgTriggerEditor.h" line="577"/> + <location filename="../src/dlgTriggerEditor.h" line="589"/> <source>Save Item</source> <translation type="unfinished"></translation> </message> @@ -12759,77 +12890,77 @@ Package item warning banner shown in trigger editor when selecting package items <context> <name>main</name> <message> - <location filename="../src/main.cpp" line="448"/> + <location filename="../src/main.cpp" line="450"/> <source>Warning: %1 </source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="459"/> + <location filename="../src/main.cpp" line="461"/> <source> -h, --help displays this message.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="460"/> + <location filename="../src/main.cpp" line="462"/> <source> -v, --version displays version information.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="462"/> + <location filename="../src/main.cpp" line="464"/> <source> -p, --profile=<profile> additional profile to open, may be repeated.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="465"/> + <location filename="../src/main.cpp" line="467"/> <source> -o, --only=<predefined> make Mudlet only show the specific predefined game, may be repeated.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="468"/> + <location filename="../src/main.cpp" line="470"/> <source> -f, --fullscreen start Mudlet in fullscreen mode.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="469"/> + <location filename="../src/main.cpp" line="471"/> <source> --steammode adjusts Mudlet settings to match Steam's requirements.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="472"/> + <location filename="../src/main.cpp" line="474"/> <source>There are other inherited options that arise from the Qt Libraries which are less likely to be useful for normal use of this application:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="476"/> + <location filename="../src/main.cpp" line="478"/> <source> --dograb ignore any implicit or explicit -nograb. --dograb wins over --nograb even when --nograb is last on the command line.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="481"/> + <location filename="../src/main.cpp" line="483"/> <source> --nograb the application should never grab the mouse or the keyboard. This option is set by default when Mudlet is running in the gdb debugger under Linux.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="486"/> + <location filename="../src/main.cpp" line="488"/> <source> --nograb the application should never grab the mouse or the keyboard.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="490"/> + <location filename="../src/main.cpp" line="492"/> <source> --reverse sets the application's layout direction to right to left.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="491"/> + <location filename="../src/main.cpp" line="493"/> <source> --style=style sets the application GUI style. Possible values depend on your system configuration. If Qt was compiled with additional styles or has additional styles as plugins @@ -12840,12 +12971,12 @@ less likely to be useful for normal use of this application:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="499"/> + <location filename="../src/main.cpp" line="501"/> <source> --style style is the same as listed above.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="500"/> + <location filename="../src/main.cpp" line="502"/> <source> --stylesheet=stylesheet sets the application styleSheet. The value must be a path to a file that contains the Style Sheet. Note: Relative URLs in the Style Sheet file @@ -12853,12 +12984,12 @@ less likely to be useful for normal use of this application:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="506"/> + <location filename="../src/main.cpp" line="508"/> <source> --stylesheet stylesheet is the same as listed above.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="509"/> + <location filename="../src/main.cpp" line="511"/> <source> --sync forces the X server to perform each X client request immediately and not use buffer optimization. It makes the program easier to debug and often much slower. The --sync @@ -12866,14 +12997,14 @@ less likely to be useful for normal use of this application:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="515"/> + <location filename="../src/main.cpp" line="517"/> <source> --widgetcount prints debug message at the end about number of widgets left undestroyed and maximum number of widgets existing at the same time.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="519"/> + <location filename="../src/main.cpp" line="521"/> <source> --qmljsdebugger=1234[,block] activates the QML/JS debugger with a specified port. The number is the port value and block is optional and will make the application wait until a @@ -12881,71 +13012,71 @@ less likely to be useful for normal use of this application:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="524"/> + <location filename="../src/main.cpp" line="526"/> <source>Arguments:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="525"/> + <location filename="../src/main.cpp" line="527"/> <source> [FILE] File to install as a package</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="526"/> + <location filename="../src/main.cpp" line="528"/> <source>Report bugs to: https://github.com/Mudlet/Mudlet/issues</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="453"/> + <location filename="../src/main.cpp" line="455"/> <source>Usage: %1 [OPTION...] [FILE] </source> <comment>%1 is the name of the executable as it is on this OS.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="458"/> + <location filename="../src/main.cpp" line="460"/> <source>Options:</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="461"/> + <location filename="../src/main.cpp" line="463"/> <source> -s, --splashscreen show splashscreen on startup.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="527"/> + <location filename="../src/main.cpp" line="529"/> <source>Project home page: http://www.mudlet.org/</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="535"/> + <location filename="../src/main.cpp" line="537"/> <source>%1 %2%3 (with debug symbols, without optimisations)</source> <comment>%1 is the name of the application like mudlet or Mudlet.exe, %2 is the version number like 3.20 and %3 is a build suffix like -dev</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="542"/> + <location filename="../src/main.cpp" line="544"/> <source>Qt libraries %1 (compilation) %2 (runtime)</source> <comment>%1 and %2 are version numbers</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="544"/> + <location filename="../src/main.cpp" line="546"/> <source>Copyright © 2008-2026 Mudlet developers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="545"/> + <location filename="../src/main.cpp" line="547"/> <source>Licence GPLv2+: GNU GPL version 2 or later - http://gnu.org/licenses/gpl.html</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="546"/> + <location filename="../src/main.cpp" line="548"/> <source>This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/main.cpp" line="652"/> + <location filename="../src/main.cpp" line="654"/> <source>Version: %1</source> <translation type="unfinished"></translation> </message> @@ -13624,1018 +13755,1018 @@ There is NO WARRANTY, to the extent permitted by law.</source> <context> <name>mudlet</name> <message> - <location filename="../src/mudlet.cpp" line="992"/> + <location filename="../src/mudlet.cpp" line="1001"/> <source>Afrikaans</source> <extracomment>In the translation source texts the language is the leading term, with, generally, the (primary) country(ies) in the brackets, with a trailing language disabiguation after a '-' Chinese is an exception!</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="993"/> + <location filename="../src/mudlet.cpp" line="1002"/> <source>Afrikaans (South Africa)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="994"/> + <location filename="../src/mudlet.cpp" line="1003"/> <source>Aragonese</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="995"/> + <location filename="../src/mudlet.cpp" line="1004"/> <source>Aragonese (Spain)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="996"/> + <location filename="../src/mudlet.cpp" line="1005"/> <source>Arabic</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="997"/> + <location filename="../src/mudlet.cpp" line="1006"/> <source>Arabic (United Arab Emirates)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="998"/> + <location filename="../src/mudlet.cpp" line="1007"/> <source>Arabic (Bahrain)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="999"/> + <location filename="../src/mudlet.cpp" line="1008"/> <source>Arabic (Algeria)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1001"/> + <location filename="../src/mudlet.cpp" line="1010"/> <source>Arabic (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1002"/> + <location filename="../src/mudlet.cpp" line="1011"/> <source>Arabic (Iraq)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1003"/> + <location filename="../src/mudlet.cpp" line="1012"/> <source>Arabic (Jordan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1004"/> + <location filename="../src/mudlet.cpp" line="1013"/> <source>Arabic (Kuwait)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1005"/> + <location filename="../src/mudlet.cpp" line="1014"/> <source>Arabic (Lebanon)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1006"/> + <location filename="../src/mudlet.cpp" line="1015"/> <source>Arabic (Libya)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1007"/> + <location filename="../src/mudlet.cpp" line="1016"/> <source>Arabic (Morocco)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1008"/> + <location filename="../src/mudlet.cpp" line="1017"/> <source>Arabic (Oman)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1009"/> + <location filename="../src/mudlet.cpp" line="1018"/> <source>Arabic (Qatar)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1010"/> + <location filename="../src/mudlet.cpp" line="1019"/> <source>Arabic (Saudi Arabia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1011"/> + <location filename="../src/mudlet.cpp" line="1020"/> <source>Arabic (Sudan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1012"/> + <location filename="../src/mudlet.cpp" line="1021"/> <source>Arabic (Syria)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1013"/> + <location filename="../src/mudlet.cpp" line="1022"/> <source>Arabic (Tunisia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1014"/> + <location filename="../src/mudlet.cpp" line="1023"/> <source>Arabic (Yemen)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1015"/> + <location filename="../src/mudlet.cpp" line="1024"/> <source>Belarusian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1016"/> + <location filename="../src/mudlet.cpp" line="1025"/> <source>Belarusian (Belarus)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1017"/> + <location filename="../src/mudlet.cpp" line="1026"/> <source>Belarusian (Russia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1018"/> + <location filename="../src/mudlet.cpp" line="1027"/> <source>Bulgarian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1019"/> + <location filename="../src/mudlet.cpp" line="1028"/> <source>Bulgarian (Bulgaria)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1020"/> + <location filename="../src/mudlet.cpp" line="1029"/> <source>Bangla</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1021"/> + <location filename="../src/mudlet.cpp" line="1030"/> <source>Bangla (Bangladesh)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1022"/> + <location filename="../src/mudlet.cpp" line="1031"/> <source>Bangla (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1023"/> + <location filename="../src/mudlet.cpp" line="1032"/> <source>Tibetan</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1025"/> + <location filename="../src/mudlet.cpp" line="1034"/> <source>Tibetan (China)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1026"/> + <location filename="../src/mudlet.cpp" line="1035"/> <source>Tibetan (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1027"/> + <location filename="../src/mudlet.cpp" line="1036"/> <source>Breton</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1028"/> + <location filename="../src/mudlet.cpp" line="1037"/> <source>Breton (France)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1029"/> + <location filename="../src/mudlet.cpp" line="1038"/> <source>Bosnian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1030"/> + <location filename="../src/mudlet.cpp" line="1039"/> <source>Bosnian (Bosnia/Herzegovina)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1031"/> + <location filename="../src/mudlet.cpp" line="1040"/> <source>Bosnian (Bosnia/Herzegovina - Cyrillic alphabet)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1032"/> + <location filename="../src/mudlet.cpp" line="1041"/> <source>Catalan</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1033"/> + <location filename="../src/mudlet.cpp" line="1042"/> <source>Catalan (Spain)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1034"/> + <location filename="../src/mudlet.cpp" line="1043"/> <source>Catalan (Spain - Valencian)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1035"/> + <location filename="../src/mudlet.cpp" line="1044"/> <source>Central Kurdish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1036"/> + <location filename="../src/mudlet.cpp" line="1045"/> <source>Central Kurdish (Iraq)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1037"/> + <location filename="../src/mudlet.cpp" line="1046"/> <source>Czech</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1038"/> + <location filename="../src/mudlet.cpp" line="1047"/> <source>Czech (Czechia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1041"/> + <location filename="../src/mudlet.cpp" line="1050"/> <source>Danish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1042"/> + <location filename="../src/mudlet.cpp" line="1051"/> <source>Danish (Denmark)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1043"/> + <location filename="../src/mudlet.cpp" line="1052"/> <source>German</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1044"/> + <location filename="../src/mudlet.cpp" line="1053"/> <source>German (Austria)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1045"/> + <location filename="../src/mudlet.cpp" line="1054"/> <source>German (Austria, revised by F M Baumann)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1046"/> + <location filename="../src/mudlet.cpp" line="1055"/> <source>German (Belgium)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1047"/> + <location filename="../src/mudlet.cpp" line="1056"/> <source>German (Switzerland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1048"/> + <location filename="../src/mudlet.cpp" line="1057"/> <source>German (Switzerland, revised by F M Baumann)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1049"/> + <location filename="../src/mudlet.cpp" line="1058"/> <source>German (Germany/Belgium/Luxemburg)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1050"/> + <location filename="../src/mudlet.cpp" line="1059"/> <source>German (Germany/Belgium/Luxemburg, revised by F M Baumann)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1051"/> + <location filename="../src/mudlet.cpp" line="1060"/> <source>German (Liechtenstein)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1052"/> + <location filename="../src/mudlet.cpp" line="1061"/> <source>German (Luxembourg)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1055"/> + <location filename="../src/mudlet.cpp" line="1064"/> <source>Greek</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1056"/> + <location filename="../src/mudlet.cpp" line="1065"/> <source>Greek (Greece)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1057"/> + <location filename="../src/mudlet.cpp" line="1066"/> <source>English</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1058"/> + <location filename="../src/mudlet.cpp" line="1067"/> <source>English (Antigua/Barbuda)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1059"/> + <location filename="../src/mudlet.cpp" line="1068"/> <source>English (Australia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1061"/> + <location filename="../src/mudlet.cpp" line="1070"/> <source>English (Bahamas)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1062"/> + <location filename="../src/mudlet.cpp" line="1071"/> <source>English (Botswana)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1063"/> + <location filename="../src/mudlet.cpp" line="1072"/> <source>English (Belize)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1000"/> + <location filename="../src/mudlet.cpp" line="1009"/> <source>Arabic (Egypt)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="310"/> - <location filename="../src/mudlet.cpp" line="312"/> - <location filename="../src/mudlet.cpp" line="725"/> + <location filename="../src/mudlet.cpp" line="314"/> + <location filename="../src/mudlet.cpp" line="316"/> + <location filename="../src/mudlet.cpp" line="729"/> <source>Close profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="315"/> - <location filename="../src/mudlet.cpp" line="317"/> + <location filename="../src/mudlet.cpp" line="319"/> + <location filename="../src/mudlet.cpp" line="321"/> <source>Close Mudlet</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="371"/> + <location filename="../src/mudlet.cpp" line="375"/> <source>Mute</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="378"/> - <location filename="../src/mudlet.cpp" line="380"/> - <location filename="../src/mudlet.cpp" line="721"/> - <location filename="../src/mudlet.cpp" line="5097"/> - <location filename="../src/mudlet.cpp" line="5100"/> + <location filename="../src/mudlet.cpp" line="382"/> + <location filename="../src/mudlet.cpp" line="384"/> + <location filename="../src/mudlet.cpp" line="725"/> + <location filename="../src/mudlet.cpp" line="5238"/> + <location filename="../src/mudlet.cpp" line="5241"/> <source>Mute all media</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="384"/> - <location filename="../src/mudlet.cpp" line="386"/> - <location filename="../src/mudlet.cpp" line="5132"/> + <location filename="../src/mudlet.cpp" line="388"/> + <location filename="../src/mudlet.cpp" line="390"/> + <location filename="../src/mudlet.cpp" line="5273"/> <source>Mute sounds from Mudlet (triggers, scripts, etc.)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="413"/> + <location filename="../src/mudlet.cpp" line="417"/> <source>Mudlet chat</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="414"/> + <location filename="../src/mudlet.cpp" line="418"/> <source>Open a link to the Mudlet server on Discord</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="452"/> + <location filename="../src/mudlet.cpp" line="456"/> <source>Show Main Toolbar</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="508"/> + <location filename="../src/mudlet.cpp" line="512"/> <source>Report issue</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="513"/> + <location filename="../src/mudlet.cpp" line="517"/> <source>Report bugs in the public test build to help us improve Mudlet.</source> <extracomment>Tooltip for Report Issue button in public test builds</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="522"/> - <location filename="../src/mudlet.cpp" line="5915"/> + <location filename="../src/mudlet.cpp" line="526"/> + <location filename="../src/mudlet.cpp" line="6056"/> <source>About Mudlet version, creators, and license.</source> <extracomment>Tooltip for About Mudlet sub-menu item and main toolbar button (or menu item if an update has changed that control to have a popup menu instead) (Used in multiple places - please ensure all have the same translation).</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="532"/> + <location filename="../src/mudlet.cpp" line="536"/> <source>Full Screen</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="713"/> + <location filename="../src/mudlet.cpp" line="717"/> <source>Script editor</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="714"/> + <location filename="../src/mudlet.cpp" line="718"/> <source>Show Map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="715"/> + <location filename="../src/mudlet.cpp" line="719"/> <source>Compact input line</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="716"/> + <location filename="../src/mudlet.cpp" line="720"/> <source>Preferences</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="718"/> + <location filename="../src/mudlet.cpp" line="722"/> <source>Package manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="719"/> + <location filename="../src/mudlet.cpp" line="723"/> <source>Module manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="722"/> + <location filename="../src/mudlet.cpp" line="726"/> <source>Play</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="726"/> + <location filename="../src/mudlet.cpp" line="730"/> <source>Toggle Time Stamps</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="727"/> + <location filename="../src/mudlet.cpp" line="731"/> <source>Toggle Replay</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="728"/> + <location filename="../src/mudlet.cpp" line="732"/> <source>Toggle Logging</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="729"/> + <location filename="../src/mudlet.cpp" line="733"/> <source>Toggle Emergency Stop</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="730"/> + <location filename="../src/mudlet.cpp" line="734"/> <source>Next profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="731"/> + <location filename="../src/mudlet.cpp" line="735"/> <source>Previous profile</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="734"/> + <location filename="../src/mudlet.cpp" line="738"/> <source>Switch to profile %1</source> <extracomment>Name of the keyboard shortcut that switches to the numbered profile tab, %1 is that number (1 to 9)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1024"/> + <location filename="../src/mudlet.cpp" line="1033"/> <source>Tibetan (Bhutan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1039"/> + <location filename="../src/mudlet.cpp" line="1048"/> <source>Welsh</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1040"/> + <location filename="../src/mudlet.cpp" line="1049"/> <source>Welsh (United Kingdom {Wales})</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1053"/> + <location filename="../src/mudlet.cpp" line="1062"/> <source>Dzongkha</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1054"/> + <location filename="../src/mudlet.cpp" line="1063"/> <source>Dzongkha (Bhutan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1060"/> + <location filename="../src/mudlet.cpp" line="1069"/> <source>English (Australia, Large)</source> <comment>This dictionary contains larger vocabulary.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1064"/> + <location filename="../src/mudlet.cpp" line="1073"/> <source>English (Canada)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1065"/> + <location filename="../src/mudlet.cpp" line="1074"/> <source>English (Canada, Large)</source> <comment>This dictionary contains larger vocabulary.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1066"/> + <location filename="../src/mudlet.cpp" line="1075"/> <source>English (Denmark)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1067"/> + <location filename="../src/mudlet.cpp" line="1076"/> <source>English (United Kingdom)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1068"/> + <location filename="../src/mudlet.cpp" line="1077"/> <source>English (United Kingdom, Large)</source> <comment>This dictionary contains larger vocabulary.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1069"/> + <location filename="../src/mudlet.cpp" line="1078"/> <source>English (United Kingdom - 'ise' not 'ize')</source> <comment>This dictionary prefers the British 'ise' form over the American 'ize' one.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1070"/> + <location filename="../src/mudlet.cpp" line="1079"/> <source>English (Ghana)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1071"/> + <location filename="../src/mudlet.cpp" line="1080"/> <source>English (Hong Kong SAR China)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1072"/> + <location filename="../src/mudlet.cpp" line="1081"/> <source>English (Ireland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1073"/> + <location filename="../src/mudlet.cpp" line="1082"/> <source>English (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1074"/> + <location filename="../src/mudlet.cpp" line="1083"/> <source>English (Jamaica)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1075"/> + <location filename="../src/mudlet.cpp" line="1084"/> <source>English (Namibia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1076"/> + <location filename="../src/mudlet.cpp" line="1085"/> <source>English (Nigeria)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1077"/> + <location filename="../src/mudlet.cpp" line="1086"/> <source>English (New Zealand)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1078"/> + <location filename="../src/mudlet.cpp" line="1087"/> <source>English (Philippines)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1079"/> + <location filename="../src/mudlet.cpp" line="1088"/> <source>English (Singapore)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1080"/> + <location filename="../src/mudlet.cpp" line="1089"/> <source>English (Trinidad/Tobago)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1081"/> + <location filename="../src/mudlet.cpp" line="1090"/> <source>English (United States)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1082"/> + <location filename="../src/mudlet.cpp" line="1091"/> <source>English (United States, Large)</source> <comment>This dictionary contains larger vocabulary.</comment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1083"/> + <location filename="../src/mudlet.cpp" line="1092"/> <source>English (South Africa)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1084"/> + <location filename="../src/mudlet.cpp" line="1093"/> <source>English (Zimbabwe)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1085"/> + <location filename="../src/mudlet.cpp" line="1094"/> <source>Esperanto</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1086"/> + <location filename="../src/mudlet.cpp" line="1095"/> <source>Spanish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1087"/> + <location filename="../src/mudlet.cpp" line="1096"/> <source>Spanish (Argentina)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1088"/> + <location filename="../src/mudlet.cpp" line="1097"/> <source>Spanish (Bolivia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1089"/> + <location filename="../src/mudlet.cpp" line="1098"/> <source>Spanish (Chile)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1090"/> + <location filename="../src/mudlet.cpp" line="1099"/> <source>Spanish (Colombia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1091"/> + <location filename="../src/mudlet.cpp" line="1100"/> <source>Spanish (Costa Rica)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1092"/> + <location filename="../src/mudlet.cpp" line="1101"/> <source>Spanish (Cuba)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1093"/> + <location filename="../src/mudlet.cpp" line="1102"/> <source>Spanish (Dominican Republic)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1094"/> + <location filename="../src/mudlet.cpp" line="1103"/> <source>Spanish (Ecuador)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1095"/> + <location filename="../src/mudlet.cpp" line="1104"/> <source>Spanish (Spain)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1096"/> + <location filename="../src/mudlet.cpp" line="1105"/> <source>Spanish (Guatemala)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1097"/> + <location filename="../src/mudlet.cpp" line="1106"/> <source>Spanish (Honduras)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1098"/> + <location filename="../src/mudlet.cpp" line="1107"/> <source>Spanish (Mexico)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1099"/> + <location filename="../src/mudlet.cpp" line="1108"/> <source>Spanish (Nicaragua)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1100"/> + <location filename="../src/mudlet.cpp" line="1109"/> <source>Spanish (Panama)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1101"/> + <location filename="../src/mudlet.cpp" line="1110"/> <source>Spanish (Peru)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1102"/> + <location filename="../src/mudlet.cpp" line="1111"/> <source>Spanish (Puerto Rico)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1103"/> + <location filename="../src/mudlet.cpp" line="1112"/> <source>Spanish (Paraguay)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1104"/> + <location filename="../src/mudlet.cpp" line="1113"/> <source>Spanish (El Savador)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1105"/> + <location filename="../src/mudlet.cpp" line="1114"/> <source>Spanish (United States)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1106"/> + <location filename="../src/mudlet.cpp" line="1115"/> <source>Spanish (Uruguay)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1107"/> + <location filename="../src/mudlet.cpp" line="1116"/> <source>Spanish (Venezuela)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1108"/> + <location filename="../src/mudlet.cpp" line="1117"/> <source>Estonian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1109"/> + <location filename="../src/mudlet.cpp" line="1118"/> <source>Estonian (Estonia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1110"/> + <location filename="../src/mudlet.cpp" line="1119"/> <source>Basque</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1111"/> + <location filename="../src/mudlet.cpp" line="1120"/> <source>Basque (Spain)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1112"/> + <location filename="../src/mudlet.cpp" line="1121"/> <source>Basque (France)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1113"/> - <location filename="../src/mudlet.cpp" line="1114"/> + <location filename="../src/mudlet.cpp" line="1122"/> + <location filename="../src/mudlet.cpp" line="1123"/> <source>Finnish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1115"/> + <location filename="../src/mudlet.cpp" line="1124"/> <source>Faroese</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1116"/> + <location filename="../src/mudlet.cpp" line="1125"/> <source>Faroese (Faroe Islands)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1117"/> - <location filename="../src/mudlet.cpp" line="1121"/> + <location filename="../src/mudlet.cpp" line="1126"/> + <location filename="../src/mudlet.cpp" line="1130"/> <source>French</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1122"/> + <location filename="../src/mudlet.cpp" line="1131"/> <source>French (Belgium)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1123"/> + <location filename="../src/mudlet.cpp" line="1132"/> <source>French (Catalan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1124"/> + <location filename="../src/mudlet.cpp" line="1133"/> <source>French (Switzerland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1125"/> + <location filename="../src/mudlet.cpp" line="1134"/> <source>French (France)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1126"/> + <location filename="../src/mudlet.cpp" line="1135"/> <source>French (Luxemburg)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1127"/> + <location filename="../src/mudlet.cpp" line="1136"/> <source>French (Monaco)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1128"/> + <location filename="../src/mudlet.cpp" line="1137"/> <source>Irish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1129"/> + <location filename="../src/mudlet.cpp" line="1138"/> <source>Gaelic</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1130"/> + <location filename="../src/mudlet.cpp" line="1139"/> <source>Gaelic (United Kingdom {Scots})</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1131"/> + <location filename="../src/mudlet.cpp" line="1140"/> <source>Galician</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1132"/> + <location filename="../src/mudlet.cpp" line="1141"/> <source>Galician (Spain)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1133"/> - <location filename="../src/mudlet.cpp" line="1138"/> + <location filename="../src/mudlet.cpp" line="1142"/> + <location filename="../src/mudlet.cpp" line="1147"/> <source>Guarani</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1134"/> - <location filename="../src/mudlet.cpp" line="1139"/> + <location filename="../src/mudlet.cpp" line="1143"/> + <location filename="../src/mudlet.cpp" line="1148"/> <source>Guarani (Paraguay)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1135"/> + <location filename="../src/mudlet.cpp" line="1144"/> <source>Gujarati</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1136"/> + <location filename="../src/mudlet.cpp" line="1145"/> <source>Gujarati (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1140"/> + <location filename="../src/mudlet.cpp" line="1149"/> <source>Hebrew</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1141"/> + <location filename="../src/mudlet.cpp" line="1150"/> <source>Hebrew (Israel)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1142"/> + <location filename="../src/mudlet.cpp" line="1151"/> <source>Hindi</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1143"/> + <location filename="../src/mudlet.cpp" line="1152"/> <source>Hindi (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1144"/> + <location filename="../src/mudlet.cpp" line="1153"/> <source>Croatian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1145"/> + <location filename="../src/mudlet.cpp" line="1154"/> <source>Croatian (Croatia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1146"/> + <location filename="../src/mudlet.cpp" line="1155"/> <source>Hungarian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1147"/> + <location filename="../src/mudlet.cpp" line="1156"/> <source>Hungarian (Hungary)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1148"/> + <location filename="../src/mudlet.cpp" line="1157"/> <source>Armenian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1149"/> + <location filename="../src/mudlet.cpp" line="1158"/> <source>Armenian (Armenia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1150"/> + <location filename="../src/mudlet.cpp" line="1159"/> <source>Indonesian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1151"/> + <location filename="../src/mudlet.cpp" line="1160"/> <source>Indonesian (Indonesia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1179"/> + <location filename="../src/mudlet.cpp" line="1188"/> <source>Mongolian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1180"/> + <location filename="../src/mudlet.cpp" line="1189"/> <source>Mongolian (Mongolia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1245"/> + <location filename="../src/mudlet.cpp" line="1254"/> <source>Tagalog</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1348"/> - <location filename="../src/mudlet.cpp" line="1350"/> + <location filename="../src/mudlet.cpp" line="1357"/> + <location filename="../src/mudlet.cpp" line="1359"/> <source>Medievia {Custom codec for that MUD}</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1374"/> + <location filename="../src/mudlet.cpp" line="1383"/> <source>hh:mm:ss.zzz </source> <extracomment>This represents the format of the timestamps shown alongside the texts in a console and might require translation for a few locales; the content is as per QDateTime::toString(...) and needs to follow the rules for that function as well as being suitable for the translation locale.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1378"/> + <location filename="../src/mudlet.cpp" line="1387"/> <source>------------ </source> <extracomment>This represents the format of the timestamps shown for lines that do not have a timestamp in a console that is showing them. If localised this should be set to the same format and length as the smTimeStampFormat:</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1854"/> + <location filename="../src/mudlet.cpp" line="1863"/> <source>%1 (Main Window)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1881"/> + <location filename="../src/mudlet.cpp" line="1890"/> <source>%1 (Detached)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="2244"/> + <location filename="../src/mudlet.cpp" line="2375"/> <source>Switch games with the keyboard</source> <extracomment>Title of a balloon pointing out the newly added profile tab switching shortcuts</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="2246"/> + <location filename="../src/mudlet.cpp" line="2377"/> <source>Press %1 to cycle through your open games, or %2 to %3 to jump straight to one. You can change these keys in the preferences.</source> <extracomment>%1, %2 and %3 are keyboard shortcuts, e.g. Ctrl+Tab, Ctrl+1 and Ctrl+9 (Control-Tab, Command-1 and Command-9 on macOS)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4172"/> + <location filename="../src/mudlet.cpp" line="4311"/> <source>Map - %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4963"/> + <location filename="../src/mudlet.cpp" line="5102"/> <source>[ CHAT ] - Auto-starting MMCP Server on port %1.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5097"/> - <location filename="../src/mudlet.cpp" line="5100"/> + <location filename="../src/mudlet.cpp" line="5238"/> + <location filename="../src/mudlet.cpp" line="5241"/> <source>Unmute all media</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5117"/> + <location filename="../src/mudlet.cpp" line="5258"/> <source>[ INFO ] - Mudlet and game sounds are muted. Use "%1" to unmute.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5118"/> + <location filename="../src/mudlet.cpp" line="5259"/> <source>[ INFO ] - Mudlet and game sounds are unmuted. Use "%1" to mute.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5186"/> + <location filename="../src/mudlet.cpp" line="5327"/> <source>[ INFO ] - Compact input line set. Press "%1" to show bottom-right buttons again.</source> <extracomment>Here %1 will be replaced with the keyboard shortcut, default is ALT+L.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5306"/> + <location filename="../src/mudlet.cpp" line="5447"/> <source>Detach Tab "%1"</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5328"/> + <location filename="../src/mudlet.cpp" line="5469"/> <source>Show Connection Indicators on Tabs</source> <translation type="unfinished"></translation> </message> <message numerus="yes"> - <location filename="../src/mudlet.cpp" line="5927"/> + <location filename="../src/mudlet.cpp" line="6068"/> <source><p>About Mudlet</p><p><i>%n update(s) is/are now available!</i><p></source> <extracomment>This is the tooltip text for the 'About' Mudlet main toolbar button when it has been changed by adding a menu which now contains the original 'About Mudlet' action and a new one to access the manual update process</extracomment> <translation type="unfinished"> @@ -14643,7 +14774,7 @@ There is NO WARRANTY, to the extent permitted by law.</source> </translation> </message> <message numerus="yes"> - <location filename="../src/mudlet.cpp" line="5945"/> + <location filename="../src/mudlet.cpp" line="6086"/> <source>Review %n update(s)...</source> <extracomment>Review update(s) menu item, %n is the count of how many updates are available</extracomment> <translation type="unfinished"> @@ -14651,7 +14782,7 @@ There is NO WARRANTY, to the extent permitted by law.</source> </translation> </message> <message numerus="yes"> - <location filename="../src/mudlet.cpp" line="5947"/> + <location filename="../src/mudlet.cpp" line="6088"/> <source>Review the update(s) available...</source> <extracomment>Tool-tip for review update(s) menu item, given that the count of how many updates are available is already shown in the menu, the %n parameter that is that number need not be used here</extracomment> <translation type="unfinished"> @@ -14659,853 +14790,853 @@ There is NO WARRANTY, to the extent permitted by law.</source> </translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1154"/> + <location filename="../src/mudlet.cpp" line="1163"/> <source>Icelandic</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="390"/> - <location filename="../src/mudlet.cpp" line="392"/> - <location filename="../src/mudlet.cpp" line="5137"/> + <location filename="../src/mudlet.cpp" line="394"/> + <location filename="../src/mudlet.cpp" line="396"/> + <location filename="../src/mudlet.cpp" line="5278"/> <source>Mute sounds from the game (MCMP, MSP)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1155"/> + <location filename="../src/mudlet.cpp" line="1164"/> <source>Icelandic (Iceland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1156"/> + <location filename="../src/mudlet.cpp" line="1165"/> <source>Italian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1157"/> + <location filename="../src/mudlet.cpp" line="1166"/> <source>Italian (Switzerland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1158"/> + <location filename="../src/mudlet.cpp" line="1167"/> <source>Italian (Italy)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1159"/> + <location filename="../src/mudlet.cpp" line="1168"/> <source>Kazakh</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1160"/> + <location filename="../src/mudlet.cpp" line="1169"/> <source>Kazakh (Kazakhstan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1161"/> + <location filename="../src/mudlet.cpp" line="1170"/> <source>Kurmanji</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1162"/> + <location filename="../src/mudlet.cpp" line="1171"/> <source>Kurmanji {Latin-alphabet Kurdish}</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1163"/> + <location filename="../src/mudlet.cpp" line="1172"/> <source>Korean</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1164"/> + <location filename="../src/mudlet.cpp" line="1173"/> <source>Korean (South Korea)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1165"/> + <location filename="../src/mudlet.cpp" line="1174"/> <source>Kurdish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1166"/> + <location filename="../src/mudlet.cpp" line="1175"/> <source>Kurdish (Syria)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1167"/> + <location filename="../src/mudlet.cpp" line="1176"/> <source>Kurdish (Turkey)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1168"/> + <location filename="../src/mudlet.cpp" line="1177"/> <source>Latin</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1169"/> + <location filename="../src/mudlet.cpp" line="1178"/> <source>Luxembourgish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1170"/> + <location filename="../src/mudlet.cpp" line="1179"/> <source>Luxembourgish (Luxembourg)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1171"/> + <location filename="../src/mudlet.cpp" line="1180"/> <source>Lao</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1172"/> + <location filename="../src/mudlet.cpp" line="1181"/> <source>Lao (Laos)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1173"/> + <location filename="../src/mudlet.cpp" line="1182"/> <source>Lithuanian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1174"/> + <location filename="../src/mudlet.cpp" line="1183"/> <source>Lithuanian (Lithuania)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1175"/> + <location filename="../src/mudlet.cpp" line="1184"/> <source>Latvian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1176"/> + <location filename="../src/mudlet.cpp" line="1185"/> <source>Latvian (Latvia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1177"/> + <location filename="../src/mudlet.cpp" line="1186"/> <source>Malayalam</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1178"/> + <location filename="../src/mudlet.cpp" line="1187"/> <source>Malayalam (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1181"/> + <location filename="../src/mudlet.cpp" line="1190"/> <source>Norwegian Bokmål</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1182"/> + <location filename="../src/mudlet.cpp" line="1191"/> <source>Norwegian Bokmål (Norway)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1183"/> + <location filename="../src/mudlet.cpp" line="1192"/> <source>Nepali</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1184"/> + <location filename="../src/mudlet.cpp" line="1193"/> <source>Nepali (Nepal)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1185"/> + <location filename="../src/mudlet.cpp" line="1194"/> <source>Dutch</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1186"/> + <location filename="../src/mudlet.cpp" line="1195"/> <source>Dutch (Netherlands Antilles)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1187"/> + <location filename="../src/mudlet.cpp" line="1196"/> <source>Dutch (Aruba)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1188"/> + <location filename="../src/mudlet.cpp" line="1197"/> <source>Dutch (Belgium)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1189"/> + <location filename="../src/mudlet.cpp" line="1198"/> <source>Dutch (Netherlands)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1190"/> + <location filename="../src/mudlet.cpp" line="1199"/> <source>Dutch (Suriname)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1191"/> + <location filename="../src/mudlet.cpp" line="1200"/> <source>Norwegian Nynorsk</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1192"/> + <location filename="../src/mudlet.cpp" line="1201"/> <source>Norwegian Nynorsk (Norway)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1193"/> + <location filename="../src/mudlet.cpp" line="1202"/> <source>Occitan</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1194"/> + <location filename="../src/mudlet.cpp" line="1203"/> <source>Occitan (France)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1195"/> + <location filename="../src/mudlet.cpp" line="1204"/> <source>Polish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1196"/> + <location filename="../src/mudlet.cpp" line="1205"/> <source>Polish (Poland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1197"/> + <location filename="../src/mudlet.cpp" line="1206"/> <source>Portuguese</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1198"/> + <location filename="../src/mudlet.cpp" line="1207"/> <source>Portuguese (Brazil)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1199"/> + <location filename="../src/mudlet.cpp" line="1208"/> <source>Portuguese (Portugal)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1200"/> + <location filename="../src/mudlet.cpp" line="1209"/> <source>Romanian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1201"/> + <location filename="../src/mudlet.cpp" line="1210"/> <source>Romanian (Romania)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1202"/> + <location filename="../src/mudlet.cpp" line="1211"/> <source>Russian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1203"/> + <location filename="../src/mudlet.cpp" line="1212"/> <source>Russian (Russia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1204"/> + <location filename="../src/mudlet.cpp" line="1213"/> <source>Northern Sami</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1205"/> + <location filename="../src/mudlet.cpp" line="1214"/> <source>Northern Sami (Finland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1206"/> + <location filename="../src/mudlet.cpp" line="1215"/> <source>Northern Sami (Norway)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1207"/> + <location filename="../src/mudlet.cpp" line="1216"/> <source>Northern Sami (Sweden)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1212"/> + <location filename="../src/mudlet.cpp" line="1221"/> <source>Sinhala</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1213"/> + <location filename="../src/mudlet.cpp" line="1222"/> <source>Sinhala (Sri Lanka)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1214"/> + <location filename="../src/mudlet.cpp" line="1223"/> <source>Slovak</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1215"/> + <location filename="../src/mudlet.cpp" line="1224"/> <source>Slovak (Slovakia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1216"/> + <location filename="../src/mudlet.cpp" line="1225"/> <source>Slovenian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1217"/> + <location filename="../src/mudlet.cpp" line="1226"/> <source>Slovenian (Slovenia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1218"/> + <location filename="../src/mudlet.cpp" line="1227"/> <source>Somali</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1219"/> + <location filename="../src/mudlet.cpp" line="1228"/> <source>Somali (Somalia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1220"/> + <location filename="../src/mudlet.cpp" line="1229"/> <source>Albanian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1221"/> + <location filename="../src/mudlet.cpp" line="1230"/> <source>Albanian (Albania)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1222"/> + <location filename="../src/mudlet.cpp" line="1231"/> <source>Serbian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1223"/> + <location filename="../src/mudlet.cpp" line="1232"/> <source>Serbian (Montenegro)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1224"/> + <location filename="../src/mudlet.cpp" line="1233"/> <source>Serbian (Serbia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1225"/> + <location filename="../src/mudlet.cpp" line="1234"/> <source>Serbian (Serbia - Latin-alphabet)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1226"/> + <location filename="../src/mudlet.cpp" line="1235"/> <source>Serbian (former state of Yugoslavia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1227"/> + <location filename="../src/mudlet.cpp" line="1236"/> <source>Swati</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1228"/> + <location filename="../src/mudlet.cpp" line="1237"/> <source>Swati (Swaziland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1229"/> + <location filename="../src/mudlet.cpp" line="1238"/> <source>Swati (South Africa)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1230"/> + <location filename="../src/mudlet.cpp" line="1239"/> <source>Swedish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1231"/> + <location filename="../src/mudlet.cpp" line="1240"/> <source>Swedish (Sweden)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1232"/> + <location filename="../src/mudlet.cpp" line="1241"/> <source>Swedish (Finland)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1233"/> + <location filename="../src/mudlet.cpp" line="1242"/> <source>Swahili</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1234"/> + <location filename="../src/mudlet.cpp" line="1243"/> <source>Swahili (Kenya)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1235"/> + <location filename="../src/mudlet.cpp" line="1244"/> <source>Swahili (Tanzania)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1249"/> + <location filename="../src/mudlet.cpp" line="1258"/> <source>Turkish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1236"/> + <location filename="../src/mudlet.cpp" line="1245"/> <source>Telugu</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1237"/> + <location filename="../src/mudlet.cpp" line="1246"/> <source>Telugu (India)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1238"/> + <location filename="../src/mudlet.cpp" line="1247"/> <source>Thai</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1239"/> + <location filename="../src/mudlet.cpp" line="1248"/> <source>Thai (Thailand)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1240"/> + <location filename="../src/mudlet.cpp" line="1249"/> <source>Tigrinya</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1241"/> + <location filename="../src/mudlet.cpp" line="1250"/> <source>Tigrinya (Eritrea)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1242"/> + <location filename="../src/mudlet.cpp" line="1251"/> <source>Tigrinya (Ethiopia)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1243"/> + <location filename="../src/mudlet.cpp" line="1252"/> <source>Turkmen</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1244"/> + <location filename="../src/mudlet.cpp" line="1253"/> <source>Turkmen (Turkmenistan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1246"/> + <location filename="../src/mudlet.cpp" line="1255"/> <source>Tswana</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1247"/> + <location filename="../src/mudlet.cpp" line="1256"/> <source>Tswana (Botswana)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1248"/> + <location filename="../src/mudlet.cpp" line="1257"/> <source>Tswana (South Africa)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1251"/> + <location filename="../src/mudlet.cpp" line="1260"/> <source>Tsonga</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1252"/> + <location filename="../src/mudlet.cpp" line="1261"/> <source>Tsonga (South Africa)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1253"/> + <location filename="../src/mudlet.cpp" line="1262"/> <source>Ukrainian</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1254"/> + <location filename="../src/mudlet.cpp" line="1263"/> <source>Ukrainian (Ukraine)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1255"/> + <location filename="../src/mudlet.cpp" line="1264"/> <source>Uzbek</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1256"/> + <location filename="../src/mudlet.cpp" line="1265"/> <source>Uzbek (Uzbekistan)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1257"/> + <location filename="../src/mudlet.cpp" line="1266"/> <source>Venda</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1258"/> + <location filename="../src/mudlet.cpp" line="1267"/> <source>Vietnamese</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1259"/> + <location filename="../src/mudlet.cpp" line="1268"/> <source>Vietnamese (Vietnam)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1266"/> + <location filename="../src/mudlet.cpp" line="1275"/> <source>Walloon</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1267"/> + <location filename="../src/mudlet.cpp" line="1276"/> <source>Xhosa</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1268"/> + <location filename="../src/mudlet.cpp" line="1277"/> <source>Yiddish</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1269"/> + <location filename="../src/mudlet.cpp" line="1278"/> <source>Chinese</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1270"/> + <location filename="../src/mudlet.cpp" line="1279"/> <source>Chinese (China - simplified)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1271"/> + <location filename="../src/mudlet.cpp" line="1280"/> <source>Chinese (Taiwan - traditional)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1272"/> + <location filename="../src/mudlet.cpp" line="1281"/> <source>Zulu</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1276"/> + <location filename="../src/mudlet.cpp" line="1285"/> <source>ASCII (Basic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1278"/> + <location filename="../src/mudlet.cpp" line="1287"/> <source>UTF-8 (Recommended)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1280"/> + <location filename="../src/mudlet.cpp" line="1289"/> <source>EUC-KR (Korean)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1282"/> + <location filename="../src/mudlet.cpp" line="1291"/> <source>GBK (Chinese)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1284"/> + <location filename="../src/mudlet.cpp" line="1293"/> <source>GB18030 (Chinese)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1286"/> + <location filename="../src/mudlet.cpp" line="1295"/> <source>Big5-ETen (Taiwan)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1288"/> + <location filename="../src/mudlet.cpp" line="1297"/> <source>Big5-HKSCS (Hong Kong)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1290"/> + <location filename="../src/mudlet.cpp" line="1299"/> <source>ISO 8859-1 (Western European)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1292"/> + <location filename="../src/mudlet.cpp" line="1301"/> <source>ISO 8859-2 (Central European)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1294"/> + <location filename="../src/mudlet.cpp" line="1303"/> <source>ISO 8859-3 (South European)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1296"/> + <location filename="../src/mudlet.cpp" line="1305"/> <source>ISO 8859-4 (Baltic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1298"/> + <location filename="../src/mudlet.cpp" line="1307"/> <source>ISO 8859-5 (Cyrillic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1300"/> + <location filename="../src/mudlet.cpp" line="1309"/> <source>ISO 8859-6 (Arabic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1302"/> + <location filename="../src/mudlet.cpp" line="1311"/> <source>ISO 8859-7 (Greek)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1304"/> + <location filename="../src/mudlet.cpp" line="1313"/> <source>ISO 8859-8 (Hebrew Visual)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1306"/> + <location filename="../src/mudlet.cpp" line="1315"/> <source>ISO 8859-9 (Turkish)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1308"/> + <location filename="../src/mudlet.cpp" line="1317"/> <source>ISO 8859-10 (Nordic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1310"/> + <location filename="../src/mudlet.cpp" line="1319"/> <source>ISO 8859-11 (Latin/Thai)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1312"/> + <location filename="../src/mudlet.cpp" line="1321"/> <source>ISO 8859-13 (Baltic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1314"/> + <location filename="../src/mudlet.cpp" line="1323"/> <source>ISO 8859-14 (Celtic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1316"/> + <location filename="../src/mudlet.cpp" line="1325"/> <source>ISO 8859-15 (Western)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1318"/> + <location filename="../src/mudlet.cpp" line="1327"/> <source>ISO 8859-16 (Romanian)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1320"/> - <location filename="../src/mudlet.cpp" line="1322"/> + <location filename="../src/mudlet.cpp" line="1329"/> + <location filename="../src/mudlet.cpp" line="1331"/> <source>CP437 (OEM Font)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1324"/> - <location filename="../src/mudlet.cpp" line="1326"/> + <location filename="../src/mudlet.cpp" line="1333"/> + <location filename="../src/mudlet.cpp" line="1335"/> <source>CP667 (Mazovia)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1328"/> - <location filename="../src/mudlet.cpp" line="1330"/> + <location filename="../src/mudlet.cpp" line="1337"/> + <location filename="../src/mudlet.cpp" line="1339"/> <source>CP737 (DOS Greek)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1332"/> + <location filename="../src/mudlet.cpp" line="1341"/> <source>CP850 (Western Europe)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1334"/> + <location filename="../src/mudlet.cpp" line="1343"/> <source>CP866 (Cyrillic/Russian)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1336"/> - <location filename="../src/mudlet.cpp" line="1338"/> + <location filename="../src/mudlet.cpp" line="1345"/> + <location filename="../src/mudlet.cpp" line="1347"/> <source>CP869 (DOS Greek 2)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1340"/> + <location filename="../src/mudlet.cpp" line="1349"/> <source>CP1161 (Latin/Thai)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1342"/> + <location filename="../src/mudlet.cpp" line="1351"/> <source>KOI8-R (Cyrillic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1344"/> + <location filename="../src/mudlet.cpp" line="1353"/> <source>KOI8-U (Cyrillic/Ukrainian)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1346"/> + <location filename="../src/mudlet.cpp" line="1355"/> <source>MACINTOSH</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1352"/> + <location filename="../src/mudlet.cpp" line="1361"/> <source>WINDOWS-1250 (Central European)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1354"/> + <location filename="../src/mudlet.cpp" line="1363"/> <source>WINDOWS-1251 (Cyrillic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1356"/> + <location filename="../src/mudlet.cpp" line="1365"/> <source>WINDOWS-1252 (Western)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1358"/> + <location filename="../src/mudlet.cpp" line="1367"/> <source>WINDOWS-1253 (Greek)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1360"/> + <location filename="../src/mudlet.cpp" line="1369"/> <source>WINDOWS-1254 (Turkish)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1362"/> + <location filename="../src/mudlet.cpp" line="1371"/> <source>WINDOWS-1255 (Hebrew)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1364"/> + <location filename="../src/mudlet.cpp" line="1373"/> <source>WINDOWS-1256 (Arabic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1366"/> + <location filename="../src/mudlet.cpp" line="1375"/> <source>WINDOWS-1257 (Baltic)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1368"/> + <location filename="../src/mudlet.cpp" line="1377"/> <source>WINDOWS-1258 (Vietnamese)</source> <extracomment>Keep the English translation intact, so if a user accidentally changes to a language they don't understand, they can change back e.g. ISO 8859-2 (Центральная Европа/Central European)</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5880"/> + <location filename="../src/mudlet.cpp" line="6021"/> <source>Update check failed. Error: %1 </source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="6035"/> + <location filename="../src/mudlet.cpp" line="6184"/> <source>Could not open profile file: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="6044"/> + <location filename="../src/mudlet.cpp" line="6193"/> <source>[ ERROR ] - Something went wrong loading your Mudlet profile and it could not be loaded. Try loading an older version in 'Connect - Options - Profile history' or double-check that %1 looks correct.</source> <extracomment>%1 is the path and file name (i.e. the location) of the problem fil</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5120"/> + <location filename="../src/mudlet.cpp" line="5261"/> <source>[ INFO ] - Mudlet and game sounds are muted.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5120"/> + <location filename="../src/mudlet.cpp" line="5261"/> <source>[ INFO ] - Mudlet and game sounds are unmuted.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5132"/> + <location filename="../src/mudlet.cpp" line="5273"/> <source>Unmute sounds from Mudlet (Triggers, Scripts, etc.)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5137"/> + <location filename="../src/mudlet.cpp" line="5278"/> <source>Unmute sounds from the game (MCMP, MSP)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5357"/> + <location filename="../src/mudlet.cpp" line="5498"/> <source>Cannot load a replay as one is already in progress in this or another profile.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5376"/> + <location filename="../src/mudlet.cpp" line="5517"/> <source>Replay each step with a shorter time interval between steps.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5382"/> + <location filename="../src/mudlet.cpp" line="5523"/> <source>Replay each step with a longer time interval between steps.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="7215"/> + <location filename="../src/mudlet.cpp" line="7364"/> <source>Hide tray icon</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="7220"/> + <location filename="../src/mudlet.cpp" line="7369"/> <source>Quit Mudlet</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="249"/> - <location filename="../src/mudlet.cpp" line="5317"/> + <location filename="../src/mudlet.cpp" line="253"/> + <location filename="../src/mudlet.cpp" line="5458"/> <source>Main Toolbar</source> <extracomment>Name of the main toolbar shown in Qt's built-in toolbar toggle menus and right-click context menus ---------- @@ -15513,304 +15644,304 @@ Toggle action in the tab bar context menu to show/hide the main toolbar</extraco <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="295"/> - <location filename="../src/mudlet.cpp" line="302"/> - <location filename="../src/mudlet.cpp" line="304"/> + <location filename="../src/mudlet.cpp" line="299"/> + <location filename="../src/mudlet.cpp" line="306"/> + <location filename="../src/mudlet.cpp" line="308"/> <source>Connect</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="307"/> - <location filename="../src/mudlet.cpp" line="723"/> + <location filename="../src/mudlet.cpp" line="311"/> + <location filename="../src/mudlet.cpp" line="727"/> <source>Disconnect</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="408"/> + <location filename="../src/mudlet.cpp" line="412"/> <source>Open Discord</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="326"/> + <location filename="../src/mudlet.cpp" line="330"/> <source>Triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="213"/> + <location filename="../src/mudlet.cpp" line="217"/> <source>hh:mm:ss</source> <extracomment>Formatting string for elapsed time display in replay playback - see QDateTime::toString(const QString&) for the gory details...!</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="327"/> + <location filename="../src/mudlet.cpp" line="331"/> <source>Show and edit triggers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="334"/> + <location filename="../src/mudlet.cpp" line="338"/> <source>Aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="335"/> + <location filename="../src/mudlet.cpp" line="339"/> <source>Show and edit aliases</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="340"/> + <location filename="../src/mudlet.cpp" line="344"/> <source>Timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="341"/> + <location filename="../src/mudlet.cpp" line="345"/> <source>Show and edit timers</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="346"/> + <location filename="../src/mudlet.cpp" line="350"/> <source>Buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="347"/> + <location filename="../src/mudlet.cpp" line="351"/> <source>Show and edit easy buttons</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="352"/> + <location filename="../src/mudlet.cpp" line="356"/> <source>Scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="353"/> + <location filename="../src/mudlet.cpp" line="357"/> <source>Show and edit scripts</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="358"/> + <location filename="../src/mudlet.cpp" line="362"/> <source>Keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="359"/> + <location filename="../src/mudlet.cpp" line="363"/> <source>Show and edit keys</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="364"/> + <location filename="../src/mudlet.cpp" line="368"/> <source>Variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="365"/> + <location filename="../src/mudlet.cpp" line="369"/> <source>Show and edit Lua variables</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="424"/> + <location filename="../src/mudlet.cpp" line="428"/> <source>Map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="425"/> + <location filename="../src/mudlet.cpp" line="429"/> <source>Show/hide the map</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="430"/> + <location filename="../src/mudlet.cpp" line="434"/> <source>Manual</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="431"/> + <location filename="../src/mudlet.cpp" line="435"/> <source>Browse reference material and documentation</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="436"/> + <location filename="../src/mudlet.cpp" line="440"/> <source>Settings</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="437"/> + <location filename="../src/mudlet.cpp" line="441"/> <source>See and edit profile preferences</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="445"/> - <location filename="../src/mudlet.cpp" line="717"/> + <location filename="../src/mudlet.cpp" line="449"/> + <location filename="../src/mudlet.cpp" line="721"/> <source>Notepad</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="446"/> + <location filename="../src/mudlet.cpp" line="450"/> <source>Open a notepad that you can store your notes in</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="458"/> - <location filename="../src/mudlet.cpp" line="467"/> + <location filename="../src/mudlet.cpp" line="462"/> + <location filename="../src/mudlet.cpp" line="471"/> <source>Packages</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="465"/> + <location filename="../src/mudlet.cpp" line="469"/> <source>Package Manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="470"/> + <location filename="../src/mudlet.cpp" line="474"/> <source>Module Manager</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="474"/> + <location filename="../src/mudlet.cpp" line="478"/> <source>Package Exporter</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="484"/> + <location filename="../src/mudlet.cpp" line="488"/> <source>Replay</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="489"/> - <location filename="../src/mudlet.cpp" line="724"/> + <location filename="../src/mudlet.cpp" line="493"/> + <location filename="../src/mudlet.cpp" line="728"/> <source>Reconnect</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="490"/> + <location filename="../src/mudlet.cpp" line="494"/> <source>Disconnects you from the game and connects once again</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="495"/> - <location filename="../src/mudlet.cpp" line="720"/> + <location filename="../src/mudlet.cpp" line="499"/> + <location filename="../src/mudlet.cpp" line="724"/> <source>MultiView</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="497"/> + <location filename="../src/mudlet.cpp" line="501"/> <source>Splits the Mudlet screen to show multiple profiles at once; disabled when less than two are loaded.</source> <extracomment>Same text is used in 2 places.</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="520"/> - <location filename="../src/mudlet.cpp" line="5932"/> + <location filename="../src/mudlet.cpp" line="524"/> + <location filename="../src/mudlet.cpp" line="6073"/> <source>About</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1153"/> + <location filename="../src/mudlet.cpp" line="1162"/> <source>Interlingue</source> <extracomment>, formerly known as Occidental, and not to be mistaken for Interlingua</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1209"/> + <location filename="../src/mudlet.cpp" line="1218"/> <source>Shtokavian</source> <extracomment>This code seems to be the identifier for the prestige dialect for several languages used in the region of the former Yugoslavia state without a state indication</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1211"/> + <location filename="../src/mudlet.cpp" line="1220"/> <source>Shtokavian (former state of Yugoslavia)</source> <extracomment>This code seems to be the identifier for the prestige dialect for several languages used in the region of the former Yugoslavia state with a (withdrawn from ISO 3166) state indication</extracomment> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1250"/> + <location filename="../src/mudlet.cpp" line="1259"/> <source>Turkish (Turkey)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1260"/> - <location filename="../src/mudlet.cpp" line="1264"/> + <location filename="../src/mudlet.cpp" line="1269"/> + <location filename="../src/mudlet.cpp" line="1273"/> <source>Vietnamese (DauCu variant - old-style diacritics)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="1261"/> - <location filename="../src/mudlet.cpp" line="1265"/> + <location filename="../src/mudlet.cpp" line="1270"/> + <location filename="../src/mudlet.cpp" line="1274"/> <source>Vietnamese (DauMoi variant - new-style diacritics)</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="2475"/> - <location filename="../src/mudlet.cpp" line="2583"/> - <location filename="../src/mudlet.cpp" line="5452"/> + <location filename="../src/mudlet.cpp" line="2614"/> + <location filename="../src/mudlet.cpp" line="2722"/> + <location filename="../src/mudlet.cpp" line="5593"/> <source>Load a Mudlet replay.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4658"/> + <location filename="../src/mudlet.cpp" line="4797"/> <source>Central Debug Console</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="533"/> - <location filename="../src/mudlet.cpp" line="741"/> + <location filename="../src/mudlet.cpp" line="537"/> + <location filename="../src/mudlet.cpp" line="745"/> <source>Toggle Full Screen View</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="2392"/> - <location filename="../src/mudlet.cpp" line="2480"/> + <location filename="../src/mudlet.cpp" line="2531"/> + <location filename="../src/mudlet.cpp" line="2619"/> <source><p>Load a Mudlet replay.</p><p><i>Disabled until a profile is loaded.</i></p></source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4353"/> + <location filename="../src/mudlet.cpp" line="4492"/> <source>%1 - notes</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4456"/> + <location filename="../src/mudlet.cpp" line="4595"/> <source>Select Replay</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4456"/> + <location filename="../src/mudlet.cpp" line="4595"/> <source>*.dat</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="4943"/> + <location filename="../src/mudlet.cpp" line="5082"/> <source>[ OK ] - Profile "%1" loaded in offline mode.</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5374"/> + <location filename="../src/mudlet.cpp" line="5515"/> <source>Faster</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5380"/> + <location filename="../src/mudlet.cpp" line="5521"/> <source>Slower</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5392"/> - <location filename="../src/mudlet.cpp" line="5460"/> - <location filename="../src/mudlet.cpp" line="5469"/> + <location filename="../src/mudlet.cpp" line="5533"/> + <location filename="../src/mudlet.cpp" line="5601"/> + <location filename="../src/mudlet.cpp" line="5610"/> <source>Speed: X%1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5399"/> - <location filename="../src/mudlet.cpp" line="5415"/> + <location filename="../src/mudlet.cpp" line="5540"/> + <location filename="../src/mudlet.cpp" line="5556"/> <source>Time: %1</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="5976"/> + <location filename="../src/mudlet.cpp" line="6113"/> <source>Update installed - restart to apply</source> <translation type="unfinished"></translation> </message> <message> - <location filename="../src/mudlet.cpp" line="6095"/> + <location filename="../src/mudlet.cpp" line="6244"/> <source>[ WARN ] - Cannot perform replay, another one may already be in progress, try again when it has finished.</source> <translation type="unfinished"></translation>