diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55163ad..ccfa3db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: runs-on: ubuntu-latest permissions: + contents: read pull-requests: read outputs: @@ -27,9 +28,10 @@ jobs: ci: ${{ steps.filter.outputs.ci }} steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 id: filter - if: github.event_name == 'pull_request' with: filters: | src: @@ -77,7 +79,7 @@ jobs: test: needs: [changes, clang-format] - runs-on: windows-latest + runs-on: windows-2022 if: >- always() && @@ -92,7 +94,6 @@ jobs: permissions: contents: read - actions: write steps: - name: Checkout @@ -130,7 +131,7 @@ jobs: build: needs: [changes, clang-format] - runs-on: windows-latest + runs-on: windows-2022 if: >- always() && @@ -144,7 +145,6 @@ jobs: permissions: contents: read - actions: write steps: - name: Checkout @@ -187,7 +187,7 @@ jobs: clang-tidy: needs: [changes, clang-format, build, test] - runs-on: windows-latest + runs-on: windows-2022 if: >- always() && @@ -204,7 +204,6 @@ jobs: permissions: contents: read - actions: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 684839a..f178aa2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,14 @@ on: pull_request: paths: - 'scripts/**' + - 'README.md' + - 'docs/**/*.md' + - '.vale.ini' + - '.vale/styles/**' + - 'dprint.json' + - 'lefthook.yml' + - '.github/workflows/lint.yml' + workflow_dispatch: permissions: {} @@ -23,3 +31,28 @@ jobs: uses: azohra/shell-linter@v0.8.0 with: path: "scripts/*.sh" + + markdown: + runs-on: ubuntu-latest + + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - uses: actions/checkout@v6 + + - name: Check markdown formatting + uses: dprint/check@v2.3 + with: + args: README.md docs/**/*.md + + - name: Annotate markdown prose + uses: vale-cli/vale-action@v2 + with: + files: "." + vale_flags: "--glob=README.md --glob=docs/**/*.md" + reporter: github-pr-check + filter_mode: nofilter + fail_on_error: true diff --git a/.github/workflows/nexus-upload.yml b/.github/workflows/nexus-upload.yml index 17316db..2ab10ff 100644 --- a/.github/workflows/nexus-upload.yml +++ b/.github/workflows/nexus-upload.yml @@ -105,7 +105,7 @@ jobs: printf '%s\n' "$DELIM" >> "$GITHUB_OUTPUT" - name: Upload to Nexus Mods - uses: Nexus-Mods/upload-action@v1.0.0-beta.5 + uses: Nexus-Mods/upload-action@v1.0.0-beta.7 with: api_key: ${{ secrets.NEXUSMODS_API_KEY }} file_group_id: ${{ vars.NEXUSMODS_FILE_GROUP_ID }} @@ -114,3 +114,6 @@ jobs: description: ${{ steps.notes.outputs.notes }} version: ${{ steps.version.outputs.value }} archive_existing_file: true + primary_mod_manager_download: true + allow_mod_manager_download: true + show_requirements_pop_up: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ceedbe9..bfaf00e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,11 +9,10 @@ permissions: {} jobs: release: - runs-on: windows-latest + runs-on: windows-2022 permissions: contents: write - actions: write steps: - name: Checkout diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000..61947d5 --- /dev/null +++ b/.vale.ini @@ -0,0 +1,8 @@ +StylesPath = .vale/styles +MinAlertLevel = warning +Vocab = Project +IgnoredScopes = code, tt + +[*.md] +BasedOnStyles = Project +TokenIgnores = (\bhttps?://\S+\b), (\[/?url(?:=[^\]]+)?\]), (\b[A-Za-z0-9_.-]+\.(?:dll|ini|exe|zip|7z|md|yml|yaml|json)\b), (\b[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+\b) diff --git a/.vale/styles/Project/Acronyms.yml b/.vale/styles/Project/Acronyms.yml new file mode 100644 index 0000000..a5c4818 --- /dev/null +++ b/.vale/styles/Project/Acronyms.yml @@ -0,0 +1,7 @@ +extends: substitution +message: "Use '%s' instead of '%s'." +level: warning +ignorecase: false +swap: + pr: PR + ci: CI diff --git a/.vale/styles/Project/AvoidVague.yml b/.vale/styles/Project/AvoidVague.yml new file mode 100644 index 0000000..b3d49e0 --- /dev/null +++ b/.vale/styles/Project/AvoidVague.yml @@ -0,0 +1,10 @@ +extends: existence +message: "Avoid vague wording like '%s'." +level: warning +ignorecase: true +tokens: + - just + - simply + - obviously + - easy + - easily diff --git a/.vale/styles/Project/Capitalization.yml b/.vale/styles/Project/Capitalization.yml new file mode 100644 index 0000000..4b5fc0c --- /dev/null +++ b/.vale/styles/Project/Capitalization.yml @@ -0,0 +1,7 @@ +extends: substitution +message: "Use '%s' instead of '%s'." +level: warning +ignorecase: false +swap: + github actions: GitHub Actions + skyrim special edition: Skyrim Special Edition diff --git a/.vale/styles/Project/LinkText.yml b/.vale/styles/Project/LinkText.yml new file mode 100644 index 0000000..8808740 --- /dev/null +++ b/.vale/styles/Project/LinkText.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Use descriptive link text instead of '%s'." +level: warning +ignorecase: true +tokens: + - click here + - this link diff --git a/.vale/styles/config/vocabularies/Project/accept.txt b/.vale/styles/config/vocabularies/Project/accept.txt new file mode 100644 index 0000000..ddecb93 --- /dev/null +++ b/.vale/styles/config/vocabularies/Project/accept.txt @@ -0,0 +1,15 @@ +ExampleMod +SKSE +SKSE64 +CommonLibSSE-NG +Lefthook +NexusMods +GitHub +vcpkg +CMake +clangd +clang-format +clang-tidy +dprint +Vale +xwin diff --git a/.vale/styles/config/vocabularies/Project/reject.txt b/.vale/styles/config/vocabularies/Project/reject.txt new file mode 100644 index 0000000..3b299a4 --- /dev/null +++ b/.vale/styles/config/vocabularies/Project/reject.txt @@ -0,0 +1 @@ +skse diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e874fb..7eeb8cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -135,9 +135,30 @@ else() set(_default_deploy "${CMAKE_BINARY_DIR}/deploy/${PROJECT_NAME}") endif() - set(DEPLOY_DIR - "${_default_deploy}" - CACHE PATH "Deploy destination (set SKYRIM_MODS_FOLDER or SKYRIM_FOLDER in .env)") + set(DEPLOY_DIR_OVERRIDE + "" + CACHE + PATH + "Override deploy destination (leave empty to derive from SKYRIM_MODS_FOLDER or SKYRIM_FOLDER in the environment, optionally sourced from .env)" + ) + + # DEPLOY_DIR was previously written to the cache by the project and is no longer supported. + # Clear it so stale cached values don't shadow the env-derived path. Users who want a + # fixed deploy path should use -DDEPLOY_DIR_OVERRIDE=... instead. + if(DEFINED CACHE{DEPLOY_DIR}) + message(WARNING "DEPLOY_DIR is no longer a cache variable and has been removed. " + "To pin a fixed deploy path, set -DDEPLOY_DIR_OVERRIDE=... instead.") + unset(DEPLOY_DIR CACHE) + endif() + + # Always recompute so environment changes take effect on every configure. + # DEPLOY_DIR_OVERRIDE (if set) wins over the env-derived default. + unset(DEPLOY_DIR CACHE) + if(NOT DEPLOY_DIR_OVERRIDE STREQUAL "") + set(DEPLOY_DIR "${DEPLOY_DIR_OVERRIDE}") + else() + set(DEPLOY_DIR "${_default_deploy}") + endif() install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION "${DEPLOY_DIR}/SKSE/Plugins") diff --git a/README.md b/README.md index eff3297..e64952f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Supports building on **Linux** (cross-compilation via `clang-cl` + [xwin](https: --- + ## Requirements - [SKSE64](https://skse.silverlock.org/) @@ -16,11 +17,13 @@ Supports building on **Linux** (cross-compilation via `clang-cl` + [xwin](https: ## Installation **Mod manager (recommended):** + 1. Install the requirements above. 2. Install ExampleMod via your mod manager. 3. Launch Skyrim via SKSE. **Manual:** + 1. Install the requirements above. 2. Copy `ExampleMod.dll` to `Data\SKSE\Plugins\`. 3. Launch Skyrim via SKSE. @@ -29,6 +32,7 @@ Supports building on **Linux** (cross-compilation via `clang-cl` + [xwin](https: - Compatible with Skyrim SE and AE. - No ESP/ESL required. + --- @@ -48,11 +52,13 @@ When loaded by Skyrim the plugin: ### Prerequisites #### All platforms + - [Git](https://git-scm.com/) - [CMake](https://cmake.org/download/) 3.21+ - [vcpkg](https://vcpkg.io/en/getting-started) — set `VCPKG_ROOT` in your environment #### Linux + - LLVM/Clang (provides `clang-cl`, `lld-link`, `llvm-lib`, `llvm-rc`, `llvm-mt`) - [xwin](https://github.com/Jake-Shadle/xwin) — downloads the real Windows SDK and MSVC CRT headers/libs - [Ninja](https://ninja-build.org/) @@ -70,13 +76,16 @@ xwin splat --output ~/.xwin > **Note:** On first configure, `cmake/toolchains/clang-cl-cross.cmake` creates > TitleCase symlinks inside your xwin installation, e.g.: +> > ``` > ~/.xwin/sdk/lib/um/x86_64/Advapi32.lib -> advapi32.lib > ``` +> > lld-link is case-sensitive but CommonLibSSE-NG references libs with mixed-case names. > The originals are untouched. #### Windows + - [Visual Studio 2022](https://visualstudio.microsoft.com/) with **Desktop development with C++** --- @@ -111,6 +120,7 @@ cd your-mod ``` This will: + - Replace mod name and author placeholders across all files - Initialise git submodules (CommonLibSSE-NG + vcpkg) - Bootstrap vcpkg @@ -148,6 +158,7 @@ source .env && cmake --workflow --preset deploy ``` This configures, builds, and copies `ExampleMod.dll` + `ExampleMod.pdb` directly into: + ``` $SKYRIM_MODS_FOLDER/ExampleMod/SKSE/Plugins/ ``` @@ -155,6 +166,7 @@ $SKYRIM_MODS_FOLDER/ExampleMod/SKSE/Plugins/ Vortex will detect the new mod folder automatically. Enable it in Vortex, then launch Skyrim. On subsequent builds (no config change needed): + ```bash ./scripts/deploy.sh ``` @@ -251,20 +263,21 @@ python3 scripts/generate-nexus-page.py | pbcopy # macOS ### CI -| Workflow | Trigger | What it does | -|---|---|---| -| `setup.yml` | First push in a repo created from this template | Renames placeholders using the repo name, then self-deletes | -| `ci.yml` | PRs to `main` touching `src/`, `test/`, `cmake/`, `vcpkg.json`, `CMakeLists.txt`, `CMakePresets.json` | `clang-format` (ubuntu) → `test` + `build` (windows, parallel) → `clang-tidy` (windows) | -| `release.yml` | Push of a `v*` tag | Builds, packages via `scripts/package.sh`, publishes a GitHub Release with zip + PDB | -| `nexus-upload.yml` | Release published or manual `workflow_dispatch` | Downloads release zip, generates cliff release notes, uploads to Nexus Mods | -| `lint.yml` | PRs touching `scripts/` | Runs shellcheck on shell scripts | -| `pr-title.yml` | PR opened/edited/reopened/synchronize | Checks PR title follows Conventional Commits (`feat`, `fix`, `chore`, `refactor`) | +| Workflow | Trigger | What it does | +| ------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `setup.yml` | First push in a repo created from this template | Renames placeholders using the repo name, then self-deletes | +| `ci.yml` | PRs to `main` touching `src/`, `test/`, `cmake/`, `vcpkg.json`, `CMakeLists.txt`, `CMakePresets.json` | `clang-format` (ubuntu) → `test` + `build` (windows, parallel) → `clang-tidy` (windows) | +| `release.yml` | Push of a `v*` tag | Builds, packages via `scripts/package.sh`, publishes a GitHub Release with zip + PDB | +| `nexus-upload.yml` | Release published or manual `workflow_dispatch` | Downloads release zip, generates cliff release notes, uploads to Nexus Mods | +| `lint.yml` | PRs touching `scripts/`, `README.md`, or `docs/` | Runs shellcheck on shell scripts, plus dprint formatting and Vale prose checks on Markdown | +| `pr-title.yml` | PR opened/edited/reopened/synchronize | Checks PR title follows Conventional Commits (`feat`, `fix`, `chore`, `refactor`) | #### Nexus Mods Upload `nexus-upload.yml` triggers automatically when a GitHub Release is published, or can be run manually via **workflow_dispatch** with a version input. **Prerequisites (one-time setup):** + 1. Upload your first file manually via the [Nexus Mods web UI](https://www.nexusmods.com) — this creates the file group. 2. Note the `file_group_id` from the URL or mod manager. 3. Add to your repository: @@ -276,4 +289,3 @@ python3 scripts/generate-nexus-page.py | pbcopy # macOS ## License MIT — see [LICENSE](LICENSE). - diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..110030e --- /dev/null +++ b/dprint.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://dprint.dev/schemas/v0.json", + "includes": [ + "README.md", + "docs/**/*.md" + ], + "excludes": [ + "lib/**", + "build/**", + "vcpkg_installed/**" + ], + "plugins": [ + "https://plugins.dprint.dev/markdown-0.22.1.wasm" + ], + "markdown": { + "textWrap": "maintain" + } +} diff --git a/lefthook.yml b/lefthook.yml index 8c0b7dc..b915495 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -20,6 +20,8 @@ pre-commit: - "src/*.h" - "src/**/*.cpp" - "src/**/*.h" + - "test/*.cpp" + - "test/**/*.cpp" run: clang-format -i {staged_files} stage_fixed: true cmake-format: @@ -45,3 +47,15 @@ pre-commit: glob: "scripts/*.sh" run: shellcheck {staged_files} fail_text: "shellcheck found issues" + dprint-markdown: + glob: + - "README.md" + - "docs/**/*.md" + run: dprint fmt {staged_files} + stage_fixed: true + vale-markdown: + glob: + - "README.md" + - "docs/**/*.md" + run: vale {staged_files} + fail_text: "vale found markdown prose issues" diff --git a/scripts/banner.py b/scripts/banner.py index bed3aa9..183c608 100755 --- a/scripts/banner.py +++ b/scripts/banner.py @@ -3,7 +3,6 @@ banner.py — Generate docs/assets/banner.png. Requires: Pillow (python-pillow / pip install Pillow) - numpy (python-numpy / pip install numpy) Font: Cinzel Regular — downloaded automatically from Google Fonts on first run and cached in $XDG_CACHE_HOME// (default: ~/.cache//). @@ -25,11 +24,10 @@ import argparse import hashlib import os +import sys import urllib.request from pathlib import Path -import numpy as np - from PIL import Image, ImageDraw, ImageFont # --------------------------------------------------------------------------- @@ -92,12 +90,18 @@ def centered_x(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFon def radial_vignette(img: Image.Image) -> Image.Image: - """Blend a dark radial vignette over the image.""" - w, h = img.size - y_n, x_n = np.ogrid[-1:1:h * 1j, -1:1:w * 1j] - r = np.sqrt(x_n ** 2 + y_n ** 2) / 1.6 - alpha = (1 - np.clip(r, 0, 1) ** 1.4) * 220 - mask = Image.fromarray(alpha.astype(np.uint8), mode="L") + """Blend a dark radial vignette over the image (Pillow-only, no numpy).""" + cx, cy = img.width / 2, img.height / 2 + mask = Image.new("L", img.size, 0) + mask_draw = ImageDraw.Draw(mask) + steps = 80 + for i in range(steps, 0, -1): + t = i / steps + alpha = int((1 - t ** 1.4) * 220) + rx = int(cx * t * 1.6) + ry = int(cy * t * 1.6) + box = [int(cx - rx), int(cy - ry), int(cx + rx), int(cy + ry)] + mask_draw.ellipse(box, fill=alpha) vignette = Image.new("RGB", img.size, BG_DARK) return Image.composite(img, vignette, mask) @@ -140,14 +144,23 @@ def main() -> None: base = args.font_size # Shrink font until title fits within the canvas (leaving DIVIDER_X_PAD on each side). + # Stops at 30pt minimum; very long titles may still overflow at that size. max_title_w = W - DIVIDER_X_PAD * 2 _tmp_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) - while base >= 30: - title_font = ImageFont.truetype(str(font_path), base) - _bb = _tmp_draw.textbbox((0, 0), args.title, font=title_font) + title_font = ImageFont.truetype(str(font_path), base) + while base > 30: + _bb = _tmp_draw.textbbox((0, 0), args.title, font=title_font) if (_bb[2] - _bb[0]) <= max_title_w: break - base -= 2 + base = max(base - 2, 30) + title_font = ImageFont.truetype(str(font_path), base) + + _bb = _tmp_draw.textbbox((0, 0), args.title, font=title_font) + if (_bb[2] - _bb[0]) > max_title_w: + print( + f"warning: title still exceeds canvas width at minimum font size {base}pt — it will be clipped.", + file=sys.stderr, + ) subtitle_font = ImageFont.truetype(str(font_path), base // 3) tags_font = ImageFont.truetype(str(font_path), base // 5) diff --git a/scripts/generate-nexus-page.py b/scripts/generate-nexus-page.py index 272a33d..754fa59 100755 --- a/scripts/generate-nexus-page.py +++ b/scripts/generate-nexus-page.py @@ -57,6 +57,37 @@ def md_to_bbcode(md: str) -> str: i += 1 continue + # Fenced code block (```lang or plain ```) + if line.startswith("```"): + fence_start = i + i += 1 + code_lines: list[str] = [] + while i < len(lines) and not lines[i].startswith("```"): + code_lines.append(lines[i]) + i += 1 + if i >= len(lines): + sys.exit(f"error: unterminated fenced code block opened at line {fence_start + 1}") + i += 1 # skip closing ``` + output.append("[code]") + output.extend(code_lines) + output.append("[/code]") + continue + + # Markdown pipe table — render data rows as a list (header and separator skipped) + if line.startswith("|"): + table_lines: list[str] = [] + while i < len(lines) and lines[i].startswith("|"): + table_lines.append(lines[i]) + i += 1 + output.append("[list]") + for row in table_lines[2:]: # skip header row [0] and separator row [1] + # Split on unescaped pipes only, then unescape `\|` within each cell. + raw = re.split(r"(? str: def convert_inline(text: str) -> str: # [text](url) → [url=url]text[/url] # Note: link text containing ] or URLs containing unbalanced ) are not supported. - text = re.sub(r"\[([^\]]+)\]\(([^)]*(?:\([^)]*\)[^)]*)*)\)", r"[url=\2]\1[/url]", text) + text = re.sub(r"\[([^\]]+)\]\(([^()]*(?:\([^)]*\)[^()]*)*)\)", r"[url=\2]\1[/url]", text) # **text** → [b]text[/b] text = re.sub(r"\*\*(.+?)\*\*", r"[b]\1[/b]", text) # `code` → [font=Courier New]code[/font] diff --git a/src/Utils.h b/src/Utils.h index a57bc4b..e32294a 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -1,8 +1,14 @@ #pragma once +#include +#include +#include #include #include +#include +// Pure-logic helpers with no RE::/SKSE:: usage so they can be unit-tested by the +// `test-windows` preset (which builds without CommonLibSSE). See test/ExampleTests.cpp. namespace ExampleMod { @@ -11,4 +17,45 @@ namespace ExampleMod return std::format("{}.{}.{}", major, minor, patch); } + // Returns a view of `s` with leading and trailing ASCII whitespace removed. + // The result views into `s`'s buffer, so the caller must keep that buffer alive + // for as long as the returned view is used (do not pass a temporary std::string). + [[nodiscard]] inline std::string_view TrimWhitespace(std::string_view s) + { + const auto first = s.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) { + return {}; + } + return s.substr(first, s.find_last_not_of(" \t\r\n") - first + 1); + } + + [[nodiscard]] inline constexpr unsigned char AsciiToLower(unsigned char c) noexcept + { + return (c >= 'A' && c <= 'Z') ? static_cast(c + ('a' - 'A')) : c; + } + + // Case-insensitive ASCII string comparison (useful for config keys/values). + [[nodiscard]] inline bool CaseInsensitiveEqual(std::string_view a, std::string_view b) + { + return std::ranges::equal( + a, b, + [](unsigned char x, unsigned char y) { return AsciiToLower(x) == AsciiToLower(y); }); + } + + // Sanitises a (typically config-sourced) float: returns `defaultVal` when `value` is + // non-finite or below `minVal`, clamps to `maxVal` when above, otherwise returns `value`. + // Precondition: minVal <= defaultVal <= maxVal — these are caller-supplied bounds + // (normally constants), checked by assert in debug builds. + [[nodiscard]] inline float ClampOrDefault(float value, float defaultVal, float minVal, float maxVal) + { + assert(minVal <= defaultVal && defaultVal <= maxVal); + if (!std::isfinite(value) || value < minVal) { + return defaultVal; + } + if (value > maxVal) { + return maxVal; + } + return value; + } + } // namespace ExampleMod diff --git a/test/ExampleTests.cpp b/test/ExampleTests.cpp index 5ae4cea..6701580 100644 --- a/test/ExampleTests.cpp +++ b/test/ExampleTests.cpp @@ -1,10 +1,82 @@ #include +#include + #include "Utils.h" +using ExampleMod::ClampOrDefault; +using ExampleMod::TrimWhitespace; + TEST_CASE("FormatVersion produces correct string", "[utils]") { CHECK(ExampleMod::FormatVersion(1, 2, 3) == "1.2.3"); CHECK(ExampleMod::FormatVersion(0, 0, 1) == "0.0.1"); CHECK(ExampleMod::FormatVersion(10, 0, 0) == "10.0.0"); } + +TEST_CASE("AsciiToLower lowercases A-Z only", "[utils]") +{ + using ExampleMod::AsciiToLower; + + CHECK(AsciiToLower('A') == 'a'); + CHECK(AsciiToLower('Z') == 'z'); + CHECK(AsciiToLower('M') == 'm'); + CHECK(AsciiToLower('a') == 'a'); + CHECK(AsciiToLower('z') == 'z'); + CHECK(AsciiToLower('0') == '0'); + CHECK(AsciiToLower('!') == '!'); + CHECK(AsciiToLower(static_cast(0xFF)) == static_cast(0xFF)); +} + +TEST_CASE("CaseInsensitiveEqual matches ASCII case-insensitively", "[utils]") +{ + using ExampleMod::CaseInsensitiveEqual; + + CHECK(CaseInsensitiveEqual("None", "none")); + CHECK(CaseInsensitiveEqual("NONE", "None")); + CHECK(CaseInsensitiveEqual("MCM", "mcm")); + CHECK(CaseInsensitiveEqual("Map", "MAP")); + CHECK(CaseInsensitiveEqual("", "")); + CHECK_FALSE(CaseInsensitiveEqual("Map", "Mcm")); + CHECK_FALSE(CaseInsensitiveEqual("None", "")); + CHECK_FALSE(CaseInsensitiveEqual("abc", "abcd")); +} + +TEST_CASE("TrimWhitespace removes leading and trailing whitespace", "[utils]") +{ + CHECK(TrimWhitespace(" hello ") == "hello"); + CHECK(TrimWhitespace("\t hello\t") == "hello"); + CHECK(TrimWhitespace("hello") == "hello"); + CHECK(TrimWhitespace(" ") == ""); + CHECK(TrimWhitespace("") == ""); + CHECK(TrimWhitespace(" a b c ") == "a b c"); + CHECK(TrimWhitespace("hello\r\n") == "hello"); + CHECK(TrimWhitespace("\r\n hello \r\n") == "hello"); +} + +TEST_CASE("ClampOrDefault clamps and validates values", "[utils]") +{ + constexpr float kDefault = 0.5F; + constexpr float kMin = 0.1F; + constexpr float kMax = 5.0F; + + // In-range values pass through unchanged + CHECK(ClampOrDefault(1.0F, kDefault, kMin, kMax) == 1.0F); + CHECK(ClampOrDefault(2.5F, kDefault, kMin, kMax) == 2.5F); + CHECK(ClampOrDefault(5.0F, kDefault, kMin, kMax) == 5.0F); + CHECK(ClampOrDefault(0.1F, kDefault, kMin, kMax) == 0.1F); + + // Below minimum (or non-finite) falls back to the default + CHECK(ClampOrDefault(0.05F, kDefault, kMin, kMax) == kDefault); + CHECK(ClampOrDefault(-1.0F, kDefault, kMin, kMax) == kDefault); + CHECK(ClampOrDefault(0.0F, kDefault, kMin, kMax) == kDefault); + + // Above maximum clamps to max + CHECK(ClampOrDefault(5.1F, kDefault, kMin, kMax) == kMax); + CHECK(ClampOrDefault(100.0F, kDefault, kMin, kMax) == kMax); + + // Non-finite values fall back to the default + CHECK(ClampOrDefault(std::numeric_limits::quiet_NaN(), kDefault, kMin, kMax) == kDefault); + CHECK(ClampOrDefault(std::numeric_limits::infinity(), kDefault, kMin, kMax) == kDefault); + CHECK(ClampOrDefault(-std::numeric_limits::infinity(), kDefault, kMin, kMax) == kDefault); +}