diff --git a/.github/workflows/pr-build-test.yml b/.github/workflows/pr-build-test.yml
new file mode 100644
index 00000000..f4dc4975
--- /dev/null
+++ b/.github/workflows/pr-build-test.yml
@@ -0,0 +1,241 @@
+name: PR - Build / Test
+
+# triggers the workflow to run on pull request or manually.
+# DESIGN CHOICES: could add daily auto trigger,
+# trigger on github release, trigger on issue open, etc.
+on:
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+
+# do not give actions write permissions
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-22.04 # run job on ubuntu linux compiler
+
+ steps:
+
+ # pulls repo to be built by workflow
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ # install compilers, build system, coverage tool, and python
+ - name: Install dependencies
+ run: |
+ sudo apt-get update
+
+ sudo apt-get install -y \
+ build-essential \
+ cmake \
+ lcov \
+ python3 \
+ ninja-build \
+ libasound2-dev \
+ libx11-dev \
+ libxinerama-dev \
+ libxext-dev \
+ libfreetype6-dev \
+ libwebkit2gtk-4.0-dev \
+ libglu1-mesa-dev \
+ libcurl4-openssl-dev
+
+ # configure project into a build directory,
+ # set to debug mode, and set coverage flags
+ - name: Configure CMake (with coverage)
+ run: cmake -S . -B build -G Ninja -DHARP_BUILD_TESTS=ON -DHARP_ENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
+
+ # compile library and tests
+ - name: Build HARP application
+ run: cmake --build build --target HARP --parallel 2
+
+ - name: Build unit tests
+ run: cmake --build build --target harp_tests --parallel 2
+
+ - name: Capture coverage baseline
+ shell: bash
+ run: |
+ lcov \
+ --capture \
+ --initial \
+ --directory build \
+ --output-file coverage-base.info \
+ --rc geninfo_unexecuted_blocks=1
+
+ # runs all tests registered by add_test()
+ - name: Run tests
+ id: tests
+ shell: bash
+ run: |
+ set -o pipefail
+ ctest --test-dir build --output-on-failure 2>&1 | tee test-results.txt
+
+ # report which tests failed and what lines
+ - name: Add test failure details
+ if: failure() && steps.tests.outcome == 'failure'
+ shell: bash
+ run: |
+ {
+ echo "## β Failed Tests"
+ echo
+ echo '```text'
+ grep -E "FAILED|Failure|error:|Errors while running CTest" test-results.txt || cat test-results.txt
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ # uploads ctest result as an artifact to be downloaded.
+ # runs even on test failure.
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ctest-results
+ path: |
+ build/Testing
+ test-results.txt
+ if-no-files-found: warn
+
+ # write test results into summary UI
+ - name: Add test results to summary
+ if: always()
+ shell: bash
+ run: |
+ {
+ echo "## π§ͺ Test Results"
+ echo
+ echo '```text'
+ cat test-results.txt 2>/dev/null || echo "Tests did not run."
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ # capture coverage, filter noise, generate html report
+ - name: Generate coverage report
+ if: always()
+ shell: bash
+ run: |
+ mkdir -p coverage
+
+ lcov \
+ --capture \
+ --directory build \
+ --output-file coverage-tests.info \
+ --rc geninfo_unexecuted_blocks=1
+
+ lcov \
+ --add-tracefile coverage-base.info \
+ --add-tracefile coverage-tests.info \
+ --output-file coverage-all.info
+
+ lcov \
+ --extract coverage-all.info \
+ "$GITHUB_WORKSPACE/src/*" \
+ --output-file coverage.info
+
+ lcov \
+ --remove coverage.info \
+ "$GITHUB_WORKSPACE/tests/*" \
+ "$GITHUB_WORKSPACE/JUCE/*" \
+ "*/_deps/*" \
+ "/usr/*" \
+ --output-file coverage.info
+
+ if grep -q '^SF:' coverage.info; then
+ lcov --list coverage.info
+
+ genhtml coverage.info \
+ --output-directory coverage \
+ --title "HARP Unit Test Coverage" \
+ --legend
+ else
+ echo "No HARP source files were found in the coverage data."
+
+ cat > coverage/index.html <<'EOF'
+
+
+
+ HARP coverage unavailable
+ No instrumented HARP source files appeared in the coverage data.
+
+
+ EOF
+ fi
+
+ # update html coverage dashboard
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: |
+ coverage
+ coverage.info
+ if-no-files-found: warn
+
+ # AI-generated markdown test summary
+
+ # parse coverage.info to compute coverage %.
+ # writes .md file containing percentage & progress bars
+ - name: Generate Markdown Coverage Dashboard
+ if: always()
+ shell: bash
+ env:
+ TEST_OUTCOME: ${{ steps.tests.outcome }}
+ run: |
+ python3 <<'EOF'
+ import os
+
+ covered = 0
+ total = 0
+
+ if os.path.exists("coverage.info"):
+ with open("coverage.info", encoding="utf-8") as report:
+ for raw_line in report:
+ line = raw_line.strip()
+
+ if line.startswith("LF:"):
+ total += int(line.split(":", 1)[1])
+ elif line.startswith("LH:"):
+ covered += int(line.split(":", 1)[1])
+
+ percentage = round((covered / total) * 100, 1) if total else 0.0
+ bar_length = 40
+ filled = round(bar_length * percentage / 100)
+ bar = "β" * filled + "β" * (bar_length - filled)
+
+ test_outcome = os.environ.get("TEST_OUTCOME", "unknown")
+
+ status = {
+ "success": "π’ Passed",
+ "failure": "π΄ Failed",
+ "cancelled": "βͺ Cancelled",
+ "skipped": "βͺ Skipped",
+ }.get(test_outcome, "βͺ Not available")
+
+ with open("coverage-dashboard.md", "w", encoding="utf-8") as output:
+ output.write("# π Build Quality Dashboard\n\n")
+ output.write("## π§ͺ Tests\n\n")
+ output.write(f"- Status: {status}\n\n")
+ output.write("## π HARP Source Coverage\n\n")
+ output.write(f"- Covered lines: **{covered} / {total}**\n")
+ output.write(f"- Line coverage: **{percentage:.1f}%**\n\n")
+ output.write(f"`{bar}`\n\n")
+
+ if total == 0:
+ output.write(
+ "> No HARP source coverage data was generated. "
+ "Check whether the tests exercised instrumented code.\n\n"
+ )
+
+ output.write("_Dashboard generated automatically._\n")
+ EOF
+
+ # put .md dashboard into actions summary
+ - name: Add Dashboard to Summary
+ if: always()
+ run: cat coverage-dashboard.md >> $GITHUB_STEP_SUMMARY
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c2efdf26..c2f090e2 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -272,3 +272,15 @@ if (APPLE)
set(CMAKE_SKIP_RPATH "NO" CACHE INTERNAL "")
endif(APPLE)
+
+# --------------------------------------------------
+# Unit Testing
+# --------------------------------------------------
+
+option(HARP_BUILD_TESTS "Build HARP unit tests" OFF)
+option(HARP_ENABLE_COVERAGE "Enable coverage instrumentation" OFF)
+
+if(HARP_BUILD_TESTS)
+ enable_testing()
+ add_subdirectory(tests)
+endif()
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
new file mode 100644
index 00000000..338d527a
--- /dev/null
+++ b/tests/CMakeLists.txt
@@ -0,0 +1,51 @@
+include(FetchContent)
+
+FetchContent_Declare(
+ googletest
+ URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
+)
+
+FetchContent_MakeAvailable(googletest)
+
+add_executable(harp_tests
+ test_main.cpp
+ test_harp_logger.cpp
+ utils/controls_tests.cpp
+ utils/settings_tests.cpp
+ clients/client_tests.cpp
+ clients/gradio_client_tests.cpp
+)
+
+target_link_libraries(harp_tests PRIVATE
+ GTest::gtest
+ juce::juce_core
+ juce::juce_data_structures
+ juce::juce_events
+ juce::juce_gui_basics
+)
+
+target_compile_definitions(harp_tests PRIVATE
+ JUCE_WEB_BROWSER=0
+ JUCE_USE_CURL=1
+ JUCE_LOAD_CURL_SYMBOLS_LAZILY=1
+)
+
+if(HARP_ENABLE_COVERAGE)
+ if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
+ target_compile_options(harp_tests PRIVATE
+ -O0
+ -g
+ --coverage
+ )
+
+ target_link_options(harp_tests PRIVATE
+ --coverage
+ )
+ else()
+ message(WARNING
+ "HARP_ENABLE_COVERAGE is enabled, but coverage is only configured for GCC and Clang."
+ )
+ endif()
+endif()
+
+add_test(NAME harp_tests COMMAND harp_tests)
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 00000000..62397100
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,646 @@
+# HARP Unit Testing Framework
+
+## Overview
+
+HARP serves as the application layer responsible for interpreting model interactions, maintaining application state, and coordinating communication between external model wrappers and the user-facing graphical interface. While integration testing verifies that these components communicate correctly, successful communication alone does not guarantee that HARP's internal logic is functioning correctly. Errors in parsing, state management, validation, or decision-making can still produce an incorrect user experience despite successful wrapper execution.
+
+This testing framework provides a structured methodology for validating HARP-owned logic independently of wrapper communication. Rather than maximizing code coverage, the framework focuses on verifying the critical invariants that govern HARP's behavior. Each selected unit test is designed to provide meaningful evidence that a specific aspect of HARP's internal logic behaves correctly under both normal and edge-case conditions.
+
+The framework complements, rather than replaces, integration testing. Together, unit tests and integration tests provide confidence that both HARP's internal logic and its communication with external components operate as intended.
+
+## Why This Framework Exists
+
+Because HARP serves as an intermediary application layer between external wrappers and the graphical interface, no single testing strategy can completely validate its correctness.
+
+### Integration Testing
+
+Integration testing verifies communication between external wrappers, HARP, and the graphical user interface. These tests answer questions such as:
+
+- Are messages exchanged correctly between components?
+- Does the application successfully complete end-to-end workflows?
+- Are user interface updates triggered at the appropriate times?
+
+These tests provide confidence that the complete system functions as an integrated application.
+
+### Unit Testing
+
+This framework focuses on validating HARP's internal logic independently of wrapper communication. These tests answer questions such as:
+
+- Is external configuration interpreted correctly?
+- Is application state updated consistently?
+- Is invalid input rejected appropriately?
+- Are internal workflow invariants preserved?
+
+By isolating HARP-owned logic from external communication, unit tests can verify deterministic behavior that may not be directly observable during end-to-end execution.
+
+### Relationship Between the Two
+
+Neither testing strategy is sufficient on its own. Successful integration tests do not guarantee that HARP's internal logic is correct, and successful unit tests do not guarantee that external components communicate correctly. Together, the two approaches provide confidence that both HARP's internal behavior and its interactions with the surrounding system operate as intended.
+
+## Scope
+
+This framework is intentionally limited to validating logic whose correctness is HARP's responsibility. The goal is not to maximize code coverage, but to maximize confidence in the correctness of HARP-owned behavior.
+
+HARP's implementation can be viewed as three architectural responsibilities:
+
+### Decision Logic
+
+Decision logic determines how HARP interprets, validates, and transforms information before it is presented to the user or communicated to external components.
+
+Examples include:
+
+- Parsing configuration objects
+- Validating model paths
+- Selecting endpoints
+- Transforming payloads
+- Mapping internal representations
+
+Decision logic is deterministic and is therefore a primary target for unit testing.
+
+### State Management
+
+State management maintains information that must remain consistent throughout the lifetime of the application.
+
+Examples include:
+
+- Application settings
+- API key management
+- Internal configuration state
+
+These behaviors are verified using both function-centric and workflow-centric unit tests to ensure that state remains consistent across multiple operations.
+
+### Communication
+
+Communication is responsible for exchanging information between HARP and external systems.
+
+Examples include:
+
+- Wrapper interfaces
+- HTTP communication
+- GUI event propagation
+- Framework callbacks
+
+Although this code is essential to the application, its correctness depends heavily on external libraries, frameworks, and runtime environments. As a result, communication behavior is primarily validated through integration testing rather than isolated unit tests.
+
+### Included in Scope
+
+This framework focuses on testing:
+
+- HARP-owned parsing logic
+- HARP-owned validation logic
+- HARP-owned decision making
+- HARP-owned state management
+- Deterministic workflow behavior
+
+### Excluded from Scope
+
+This framework intentionally does not directly test:
+
+- C++ Standard Library behavior
+- JUCE framework behavior
+- Third-party library behavior
+- Wrapper implementations
+- GUI rendering
+- Network transport
+- End-to-end wrapper communication
+
+These components fall outside HARP's responsibility for correctness. This framework therefore focuses on validating the HARP-owned logic that interprets, coordinates, and manages their use.
+
+## Core Testing Philosophy
+
+This framework is guided by the following principles. Every test added to the HARP unit test suite should be consistent with these principles.
+
+### 1. Test HARP-Owned Logic
+
+Unit tests should validate only logic whose correctness is HARP's responsibility. External libraries, frameworks, and wrapper implementations are trusted to validate their own behavior unless HARP extends or transforms that behavior.
+
+### 2. Test Invariants, Not Implementations
+
+Tests should protect the observable properties that HARP guarantees rather than specific implementation details. This allows internal implementations to evolve while preserving expected behavior.
+
+### 3. Prioritize Confidence Over Coverage
+
+The objective of this framework is not to maximize code coverage, but to maximize confidence in HARP's correctness. A small number of high-value behavioral tests provides stronger evidence of correctness than a large number of low-value implementation tests.
+
+### 4. Prefer Behavioral and Workflow Testing
+
+Whenever possible, tests should verify complete behaviors rather than isolated implementation details. Function-centric tests are appropriate for deterministic parsing and validation logic, while workflow-centric tests are preferred for state management and multi-step behaviors.
+
+### 5. Respect Testing Boundaries
+
+Framework behavior, standard library functionality, network communication, and wrapper communication should not be unit tested unless HARP introduces additional logic that changes or extends their behavior.
+
+### 6. Every Test Must Increase Confidence
+
+Every included test should provide meaningful additional confidence in HARP's correctness. Tests that merely duplicate guarantees already provided by another test or by trusted external libraries should be excluded.
+
+## HARP Testing Architecture
+
+From a testing perspective, HARP can be viewed as three primary areas of responsibility:
+
+```text
+ External Wrappers
+ β
+ βΌ
+
+ +----------------------------------+
+ | HARP |
+ |----------------------------------|
+ | |
+ | Decision Logic β Unit |
+ | State Management β Unit |
+ |----------------------------------|
+ | Communication Integration |
+ +----------------------------------+
+
+ β
+ βΌ
+
+ Graphical Interface
+```
+
+
+## Testing Methodology
+
+This framework follows a structured methodology for determining whether a function should be unit tested. Rather than beginning with implementation, every candidate function is evaluated using a consistent audit process that identifies its role within HARP and determines whether unit testing provides meaningful additional confidence.
+
+### Step 1 β Identify HARP-Owned Logic
+
+Determine whether the behavior being evaluated is HARP's responsibility.
+
+Questions to consider include:
+
+- Does HARP define or transform this behavior?
+- Is the behavior independent of external frameworks?
+- Would an error in this logic represent a defect in HARP?
+
+If the answer is **no**, the function is excluded from unit testing.
+
+---
+
+### Step 2 β Identify the Protected Invariant
+
+Determine the observable property that HARP guarantees to preserve. This property becomes the invariant protected by the test.
+
+Examples include:
+
+- Configuration is parsed correctly.
+- Invalid input is rejected.
+- Application state remains consistent.
+- Workflow behavior remains deterministic.
+
+Every selected test must protect at least one documented invariant.
+
+---
+
+### Step 3 β Evaluate Behavioral Importance
+
+Determine whether incorrect behavior would meaningfully affect HARP's correctness or the user's experience.
+
+A useful heuristic is:
+
+> If this behavior were incorrect, could integration testing still appear to succeed while the user experiences incorrect behavior?
+
+If the answer is **yes**, that is strong evidence that the behavior belongs in the unit test suite.
+
+Functions that merely forward data, wrap external libraries, or duplicate framework behavior generally provide little additional confidence when unit tested.
+
+---
+
+### Step 4 β Assign Priority
+
+Each selected function is assigned a priority level (P1βP4) based on the impact of failure.
+
+The priority determines implementation order and helps ensure that the highest-value behaviors are verified first.
+
+---
+
+### Step 5 β Select the Appropriate Test Strategy
+
+Choose the testing strategy that best validates the protected invariant.
+
+Examples include:
+
+- Function-centric tests for deterministic parsing and validation logic.
+- Workflow-centric tests for state management.
+- Boundary tests for edge cases.
+- Behavioral tests for observable application behavior.
+
+The selected strategy should maximize confidence while minimizing redundant validation.
+
+---
+
+### Step 6 β Document the Result
+
+Every evaluated function receives one of the following classifications:
+
+- **Selected** β Included in the unit test suite.
+- **Deferred** β Better validated through integration testing.
+- **Excluded** β Outside HARP's responsibility (for example, standard library or framework behavior).
+- **Rejected** β HARP-owned logic that does not provide meaningful additional confidence if unit tested.
+
+These classifications, together with the associated rationale, are recorded in the accompanying project documentation.
+
+This methodology ensures that every included test is selected intentionally, justified by documented rationale, and directly connected to HARP's correctness.
+
+## Invariant Levels
+
+Every selected unit test is associated with an invariant level.
+
+An invariant represents a property that HARP guarantees to preserve during execution. Rather than organizing tests by implementation details, this framework organizes them by the behavioral guarantees they protect.
+
+Invariant levels describe *what* a test protects. Test categories describe *how* that protection is verified.
+
+### Parsing
+
+Parsing invariants ensure that external information is interpreted correctly and transformed into valid HARP objects.
+
+**Primary objective:** Ensure external information is interpreted correctly.
+
+Examples include:
+
+- Configuration parsing
+- Dynamic object construction
+- Internal data representation
+
+---
+
+### State
+
+State invariants ensure that application data remains internally consistent throughout execution.
+
+**Primary objective:** Preserve internal consistency.
+
+Examples include:
+
+- Application settings
+- API key management
+- Cached configuration
+
+State invariants are often verified using workflow-centric tests because correctness depends on sequences of operations rather than isolated function calls.
+
+---
+
+### Validation
+
+Validation invariants ensure that invalid information is detected and prevented from entering the system.
+
+**Primary objective:** Prevent invalid information from entering the system.
+
+Examples include:
+
+- Model path validation
+- Request validation
+- Response validation
+
+Validation tests focus on both accepted inputs and rejected inputs, with particular attention given to edge cases and malformed data.
+
+---
+
+### Workflow
+
+Workflow invariants ensure that multiple operations cooperate correctly to preserve expected system behavior.
+
+**Primary objective:** Preserve correctness across multiple operations.
+
+Examples include:
+
+- Configuration lifecycle
+- API key lifecycle
+- Multi-step state transitions
+
+Workflow tests verify behaviors that cannot be adequately demonstrated through isolated function tests alone.
+
+---
+
+### Contract
+
+Contract invariants ensure that shared representations remain stable throughout the application.
+
+**Primary objective:** Maintain stable shared representations.
+
+Examples include:
+
+- Enumeration mappings
+- Shared identifiers
+- Stable internal representations
+
+Although contract logic is often simple, violations can silently affect multiple parts of the application because many components depend on the same shared contract.
+
+## Priority Levels
+
+Every function selected for evaluation is assigned a priority level before implementation begins.
+
+Priority levels are determined by the consequences that incorrect behavior would have on HARP's correctness. Priority is assigned to individual behaviors rather than entire source files. A single file may contain functions with different priorities depending on the invariants they preserve and the impact of failure.
+
+Priority levels establish implementation order and help ensure that testing effort is focused on the behaviors that provide the greatest increase in confidence.
+
+| Priority | Description |
+|----------|-------------|
+| **P1** | Failure compromises a core HARP invariant and may produce incorrect application behavior despite successful wrapper communication. These functions must be unit tested. |
+| **P2** | Failure affects important application behavior or user experience but is less likely to compromise overall correctness. These functions should generally be unit tested. |
+| **P3** | Failure has limited impact or is partially covered by higher-priority behaviors. These functions may be tested if they provide meaningful additional confidence. |
+| **P4** | Failure provides little additional confidence, duplicates existing guarantees, or primarily involves communication or framework behavior. These functions are generally excluded from unit testing. |
+
+### Priority Assignment
+
+Priority is determined by evaluating questions such as:
+
+- Does this function preserve a documented invariant?
+- Would incorrect behavior affect the user's experience?
+- Could integration testing succeed while this behavior remains incorrect?
+- Would excluding this test reduce confidence in HARP's correctness?
+
+Priority should always be assigned before implementation. The resulting priority and its rationale should be documented in `TEST_MATRIX.md`.
+
+## Test Categories
+
+Test categories describe how a protected invariant is verified. Unlike invariant levels, which describe the behavioral guarantee being protected, test categories describe the testing strategy used to verify that guarantee.
+
+Multiple test categories may be used to verify the same invariant when doing so provides meaningful additional confidence.
+
+The selected test category should be the simplest strategy capable of verifying the protected invariant. More complex testing strategies should only be used when simpler strategies cannot adequately demonstrate correctness.
+
+### Function-Centric Tests
+
+Function-centric tests verify deterministic behavior that can be evaluated independently of the surrounding system.
+
+**Primary objective:** Verify deterministic behavior in isolation.
+
+Typical examples include:
+
+- Parsing logic
+- Validation logic
+- Data transformation
+- Deterministic algorithms
+
+These tests are generally concise and verify a single behavioral expectation.
+
+---
+
+### Workflow-Centric Tests
+
+Workflow-centric tests verify behaviors that emerge through sequences of operations rather than isolated function calls.
+
+**Primary objective:** Verify correctness across multiple operations.
+
+Typical examples include:
+
+- Settings lifecycle
+- API key management
+- Multi-step state transitions
+- Configuration persistence
+
+These tests ensure that application state remains consistent throughout realistic usage scenarios.
+
+---
+
+### Boundary Tests
+
+Boundary tests verify behavior at the limits of expected input.
+
+**Primary objective:** Verify behavior at the limits of valid input.
+
+Typical examples include:
+
+- Empty inputs
+- Missing values
+- Maximum and minimum values
+- Very large inputs
+- Unicode input
+- Malformed structures
+
+Boundary testing provides confidence that deterministic logic remains correct under unusual but valid operating conditions.
+
+---
+
+### Validation Tests
+
+Validation tests verify that invalid or unsupported input is correctly detected and handled.
+
+**Primary objective:** Verify rejection of invalid input.
+
+Typical examples include:
+
+- Invalid model paths
+- Unsupported configuration
+- Malformed requests
+- Invalid response structures
+
+Validation tests should verify both accepted and rejected inputs whenever practical.
+
+---
+
+### Contract Tests
+
+Contract tests verify that shared representations remain stable throughout the application.
+
+**Primary objective:** Verify stability of shared representations.
+
+Typical examples include:
+
+- Enumeration mappings
+- Shared identifiers
+- Stable serialization formats
+
+Although contract logic is often simple, failures can affect multiple independent components because many parts of the application depend on the same shared representation.
+
+## Function Selection Process
+
+Before implementing a new unit test, every candidate function should undergo the following evaluation process.
+
+The purpose of this process is to ensure that every included test is intentional, justified, and contributes meaningful additional confidence in HARP's correctness.
+
+```text
+ Candidate Function
+ β
+ βΌ
+ Is the behavior HARP-owned?
+ β β
+ No Yes
+ β βΌ
+ Excluded Does it contain
+ business logic?
+ β β
+ No Yes
+ β βΌ
+ Rejected Is the
+ behavior
+ deterministic?
+ β β
+ No Yes
+ β βΌ
+ Deferred Is the
+ behavior
+ observable?
+ β β
+ No Yes
+ β βΌ
+ Reevaluate Identify
+ invariant
+ β
+ βΌ
+ Assign priority
+ β
+ βΌ
+ Select simplest
+ sufficient test
+ strategy
+ β
+ βΌ
+ Implement
+ β
+ βΌ
+ Update TEST_MATRIX.md
+ β
+ βΌ
+ Update AUDIT_NOTES.md if needed
+ β
+ βΌ
+ Update DECISIONS.md if methodology
+ changes
+```
+
+Classification decisions should be conservative. When uncertainty exists, prefer classifying a function as Deferred or Rejected until additional evidence justifies inclusion. The objective is not to maximize the number of tests, but to maximize the confidence provided by the tests that are included.
+
+### Step 1 β Determine Ownership
+
+Determine whether the behavior being evaluated is HARP's responsibility.
+
+If correctness belongs to an external framework, library, or wrapper implementation, the function should be classified as **Excluded**.
+
+---
+
+### Step 2 β Evaluate Business Logic
+
+Determine whether the function contributes meaningful HARP-owned behavior.
+
+Functions that merely forward data, wrap external APIs without modification, or duplicate framework behavior generally do not warrant direct unit tests.
+
+---
+
+### Step 3 β Evaluate Determinism
+
+Determine whether the behavior can be reproduced consistently under controlled conditions.
+
+Behavior that depends primarily on asynchronous communication, networking, or external runtime environments is generally better suited for integration testing and should be classified as **Deferred**.
+
+---
+
+### Step 4 β Evaluate Observability
+
+Determine whether incorrect behavior can be observed through deterministic outputs or preserved invariants.
+
+If correctness cannot be meaningfully observed, reconsider whether the function should be tested directly or whether the invariant is better verified elsewhere.
+
+---
+
+### Step 5 β Identify the Protected Invariant
+
+Determine the behavioral guarantee that the function preserves.
+
+Every selected test must protect at least one documented invariant.
+
+---
+
+### Step 6 β Assign Priority
+
+Assign a priority level (P1-P4) based on the consequences of failure.
+
+Priority should be assigned to the behavior being tested rather than the source file that contains it.
+
+---
+
+### Step 7 β Select the Simplest Sufficient Test Strategy
+
+Choose the simplest testing strategy capable of verifying the protected invariant.
+
+More complex testing strategies should only be used when simpler strategies cannot adequately demonstrate correctness.
+
+---
+
+### Step 8 β Record the Decision
+
+Every evaluated function should receive one of the following classifications:
+
+| Classification | Description |
+|---------------|-------------|
+| **Selected** | Included in the unit test suite. |
+| **Deferred** | Better validated through integration testing. |
+| **Excluded** | Outside HARP's responsibility for correctness. |
+| **Rejected** | HARP-owned logic that does not provide meaningful additional confidence if unit tested. |
+
+The rationale for every classification should be documented to maintain traceability between the audit process and the implemented test suite.
+
+## Documentation
+
+This testing framework is supported by four companion documents. Together, they provide the rationale, traceability, and engineering context for the unit test suite.
+
+| Document | Purpose |
+|----------|---------|
+| **README.md** | Defines the testing framework specification, methodology, and implementation guidelines. |
+| **DECISIONS.md** | Records architectural decisions that define how the testing framework is designed, implemented, and maintained. |
+| **AUDIT_NOTES.md** | Documents observations made during source code audits, including implementation discoveries, potential issues, confidence assessments, and function classifications. |
+| **TEST_MATRIX.md** | Provides traceability between audited source code and the implemented unit test suite, including priorities, invariant levels, test categories, and implementation status. |
+
+These documents serve different purposes and should be maintained independently. Changes to one document should not duplicate information maintained by another unless doing so improves clarity or traceability.
+
+Collectively, these documents ensure that implementation, engineering rationale, architectural decisions, and traceability remain synchronized as the testing framework evolves. Maintaining this separation of responsibilities helps prevent duplication while providing a complete record of how and why the framework was designed.
+
+## Repository Structure
+
+The testing framework is organized as follows:
+
+```text
+tests/
+β
+βββ README.md
+βββ DECISIONS.md
+βββ AUDIT_NOTES.md
+βββ TEST_MATRIX.md
+β
+βββ utils/
+βββ clients/
+βββ fixtures/
+βββ data/
+```
+
+### Directory Responsibilities
+
+- **utils/** β Unit tests for utility components and deterministic parsing logic.
+- **clients/** β Unit tests for client implementations and decision logic.
+- **fixtures/** β Shared helper objects, factories, mocks, and reusable testing utilities.
+- **data/** β Shared test data, sample configurations, and edge-case inputs used by multiple test suites.
+
+The `fixtures/` and `data/` directories should only be introduced when they reduce duplication or improve maintainability. They should not exist solely for organizational purposes.
+
+## Implementation Guidelines
+
+When implementing new unit tests:
+
+- Identify the protected invariant before writing any test code.
+- Test only HARP-owned logic.
+- Choose the simplest sufficient test strategy.
+- Prefer behavioral evidence over implementation details.
+- Assign a priority before implementation.
+- Document the rationale for every included test.
+- Avoid testing external libraries or framework behavior.
+- Reuse fixtures and shared test data whenever practical.
+- Favor readability and maintainability over maximizing the number of tests.
+- Keep tests deterministic and independent whenever possible.
+
+## Contributing New Tests
+
+Before adding a new unit test:
+
+1. Audit the candidate function using the documented Function Selection Process.
+2. Confirm that the behavior is HARP-owned.
+3. Identify the protected invariant.
+4. Assign an appropriate priority level.
+5. Select the simplest sufficient test strategy.
+6. Implement the test.
+7. Update `TEST_MATRIX.md` to maintain traceability.
+8. Update `AUDIT_NOTES.md` if implementation discoveries or potential issues are identified.
+9. Update `DECISIONS.md` if the testing methodology changes.
+
+Every contribution should improve confidence in HARP's correctness while remaining consistent with the principles defined by this framework.
diff --git a/tests/TEST_MATRIX.md b/tests/TEST_MATRIX.md
new file mode 100644
index 00000000..0775a03e
--- /dev/null
+++ b/tests/TEST_MATRIX.md
@@ -0,0 +1,44 @@
+# HARP Unit Test Matrix
+
+## Introduction
+
+This document provides traceability between audited HARP source code and the corresponding unit test suite. Every selected behavior is associated with a documented priority, invariant level, test category, and rationale.
+##
+Definitions for invariant levels, priority levels, test categories, and function classifications are maintained in `README.md`. Engineering observations and detailed audit rationale are maintained in `AUDIT_NOTES.md`.
+
+
+## Matrix
+ ___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________
+| File | Function / Behavior | Priority | Invariant Level | Test Category | Rationale |
+|------------------------------------------|------------------------------------------------|----------|-----------------|----------------------------------------|-----------------------------------------------------------------------------------------|
+| `src/utils/Controls.h` | `stringToBool()` | P1 | Parsing | Function-Centric, Boundary | Boolean parsing influences multiple component constructors. |
+| `src/utils/Controls.h` | `ModelComponentInfo(DynamicObject*)` | P1 | Parsing | Function-Centric | Parses shared component metadata inherited by derived controls. |
+| `src/utils/Controls.h` | `TrackComponentInfo(DynamicObject*)` | P1 | Parsing | Function-Centric, Boundary | Parses required/optional state for track inputs. |
+| `src/utils/Controls.h` | `TextBoxComponentInfo(DynamicObject*)` | P2 | Parsing | Function-Centric | Parses text control value state. |
+| `src/utils/Controls.h` | `NumberBoxComponentInfo(DynamicObject*)` | P2 | Parsing | Function-Centric | Parses numeric control state when expected properties are present. |
+| `src/utils/Controls.h` | `ToggleComponentInfo(DynamicObject*)` | P2 | Parsing | Function-Centric, Boundary | Parses boolean control state through HARP-owned boolean conversion. |
+| `src/utils/Controls.h` | `SliderComponentInfo(DynamicObject*)` | P2 | Parsing | Function-Centric | Parses slider bounds, step, and value. |
+| `src/utils/Controls.h` | `ComboBoxComponentInfo(DynamicObject*)` | P1 | Parsing | Function-Centric, Boundary | Parses choice structures and default selection behavior. |
+| `src/utils/Controls.h` | Listener state update methods | P3 | State | Function-Centric | Verify GUI listener methods update stored component state without testing rendering. |
+| `src/utils/Settings.h` | Settings initialization and null-safe access | P2 | State | Workflow-Centric | Ensures settings operations behave predictably before and after initialization. |
+| `src/utils/Settings.h` | String setting lifecycle | P2 | State | Workflow-Centric | Setting, retrieving, discovering, and removing string state should remain consistent. |
+| `src/utils/Settings.h` | Boolean setting lifecycle | P2 | State | Workflow-Centric | Boolean values should round-trip through HARP's settings abstraction. |
+| `src/utils/Settings.h` | Numeric default and retrieval behavior | P3 | State | Workflow-Centric | Numeric getters should return stored values or caller-provided defaults. |
+| `src/clients/Client.h` | `SharedAPIKeys::initializeAPIKeys()` | P1 | Workflow | Workflow-Centric | Restores persisted provider API keys into in-memory authentication state. |
+| `src/clients/Client.h` | `SharedAPIKeys::providerToSettingsKey()` | P2 | Contract | Contract | Defines the persistent settings key contract for provider API keys. |
+| `src/clients/Client.h` | `SharedAPIKeys::updateKey()` | P1 | Workflow | Workflow-Centric | Updates in-memory and persisted provider authentication state. |
+| `src/clients/Client.h` | `SharedAPIKeys::removeKey()` | P2 | Workflow | Workflow-Centric | Removes one provider key without affecting unrelated providers. |
+| `src/clients/Client.h` | `parseJSONString()` | P2 | Validation | Validation | Converts invalid JSON into structured HARP errors. |
+| `src/clients/Client.h` | `stringJSONToDict()` | P2 | Validation | Validation | Enforces dictionary-shaped JSON responses. |
+| `src/clients/Client.h` | `stringJSONToList()` | P2 | Validation | Validation | Enforces array-shaped JSON responses. |
+| `src/clients/Client.h` | `getRequiredDictProperty()` | P2 | Validation | Validation | Enforces required object-valued response fields. |
+| `src/clients/Client.h` | `getRequiredArrayProperty()` | P2 | Validation | Validation | Enforces required array-valued response fields. |
+| `src/clients/Client.h` | Default `uploadFile()` and `cancel()` behavior | P3 | Contract | Function-Centric | Defines default behavior for clients that do not override these methods. |
+| `src/clients/GradioClient.h` | `GradioClient::matchesPathSpec()` | P1 | Validation | Function-Centric, Boundary, Validation | Accepts supported model path formats and rejects unsupported formats. |
+| `src/clients/GradioClient.h` | `GradioClient::inferHostSlashModel()` | P1 | Parsing | Function-Centric | Converts supported model paths into canonical host/model identifiers. |
+| `src/clients/GradioClient.h` | `GradioClient::inferEndpointPath()` | P1 | Parsing | Function-Centric | Converts supported model paths into endpoint URLs. |
+| `src/clients/GradioClient.h` | `GradioClient::inferDocumentationPath()` | P2 | Parsing | Function-Centric | Converts supported model paths into documentation URLs, pending potential issue review. |
+| `src/clients/GradioClient.h` | `GradioClient::wrapPayloadElement()` | P2 | Parsing | Function-Centric | Adds Gradio file metadata while preserving non-file payloads. |
+| `src/clients/GradioClient.h` | Network-coupled response parsing methods | P2 | Validation | Validation | Requires refactoring or test seams to isolate from HTTP communication. |
+| `src/utils/Enums.h` | `enumToString()` | P4 | Contract | Contract | Generic wrapper around `magic_enum`; HARP-specific contracts are tested at use sites. |
+ ___________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________
diff --git a/tests/clients/client_tests.cpp b/tests/clients/client_tests.cpp
new file mode 100644
index 00000000..af42ce26
--- /dev/null
+++ b/tests/clients/client_tests.cpp
@@ -0,0 +1,342 @@
+// -----------------------------------------------------------------------------
+// Test rationale
+// -----------------------------------------------------------------------------
+// This file verifies deterministic, HARP-owned behavior in Client.h without
+// exercising external model APIs or network communication.
+//
+// The SettingsBackedTest fixture creates an isolated temporary PropertiesFile
+// so tests can verify API key persistence without touching real user settings.
+//
+// The MinimalClient class provides the smallest concrete Client implementation
+// needed to instantiate and exercise Client's base behavior. Its overrides are
+// intentionally simple because these tests target Client.h behavior, not a
+// provider-specific subclass.
+// -----------------------------------------------------------------------------
+
+#include
+
+#include
+#include
+
+#include "../../src/clients/Client.h"
+
+using namespace juce;
+
+namespace
+{
+// Provides an isolated settings store for tests that need to verify persistent
+// API key behavior without reading or writing real application settings.
+class SettingsBackedTest : public ::testing::Test
+{
+protected:
+ void SetUp() override
+ {
+ testDirectory = File::getSpecialLocation(File::tempDirectory)
+ .getChildFile("HARP_Client_Tests_" + Uuid().toString());
+ ASSERT_TRUE(testDirectory.createDirectory());
+
+ PropertiesFile::Options options;
+ options.applicationName = "HARPClientTest";
+ options.filenameSuffix = "settings";
+ options.folderName = testDirectory.getFullPathName();
+ options.osxLibrarySubFolder = "Application Support";
+ options.storageFormat = PropertiesFile::storeAsXML;
+
+ appProperties.setStorageParameters(options);
+ Settings::initialize(&appProperties);
+ }
+
+ void TearDown() override
+ {
+ Settings::initialize(nullptr);
+ appProperties.closeFiles();
+ testDirectory.deleteRecursively();
+ }
+
+ File testDirectory;
+ ApplicationProperties appProperties;
+};
+
+// Minimal concrete Client used to test base-class behavior without invoking a
+// provider-specific implementation or external communication.
+class MinimalClient : public Client
+{
+public:
+ MinimalClient() { provider = Provider::HuggingFace; }
+
+ String inferHostSlashModel(String modelPath) override { return modelPath; }
+ String inferEndpointPath(String modelPath) override { return modelPath; }
+ String inferDocumentationPath(String modelPath) override { return modelPath; }
+ OpResult queryControls(String, DynamicObject::Ptr&) override { return OpResult::ok(); }
+ var wrapPayloadElement(var payloadElement, bool = false, String = "") override { return payloadElement; }
+ OpResult process(String, String&, std::vector&, LabelList&) override { return OpResult::ok(); }
+};
+} // namespace
+
+// Verifies the stable contract between provider enums and persistent settings keys.
+TEST_F(SettingsBackedTest, ProviderToSettingsKeyDefinesStableProviderContracts)
+{
+ SharedAPIKeys keys;
+
+ EXPECT_EQ(keys.providerToSettingsKey(Provider::HuggingFace), "apikeys.HuggingFace");
+ EXPECT_EQ(keys.providerToSettingsKey(Provider::Stability), "apikeys.Stability");
+}
+
+// Verifies that persisted provider keys are restored into the in-memory token map.
+TEST_F(SettingsBackedTest, InitializeAPIKeysRestoresPersistedKeys)
+{
+ Settings::setValue("apikeys.HuggingFace", String("hf-token"));
+ Settings::setValue("apikeys.Stability", String("stability-token"));
+
+ SharedAPIKeys keys;
+ keys.initializeAPIKeys();
+
+ ASSERT_TRUE(keys.savedTokens.contains(Provider::HuggingFace));
+ ASSERT_TRUE(keys.savedTokens.contains(Provider::Stability));
+ EXPECT_EQ(keys.savedTokens[Provider::HuggingFace], "hf-token");
+ EXPECT_EQ(keys.savedTokens[Provider::Stability], "stability-token");
+}
+
+// Verifies that absent or empty persisted keys do not create usable credentials.
+TEST_F(SettingsBackedTest, InitializeAPIKeysIgnoresMissingAndEmptyKeys)
+{
+ Settings::setValue("apikeys.HuggingFace", String(""));
+
+ SharedAPIKeys keys;
+ keys.initializeAPIKeys();
+
+ EXPECT_FALSE(keys.savedTokens.contains(Provider::HuggingFace));
+ EXPECT_FALSE(keys.savedTokens.contains(Provider::Stability));
+}
+
+// Verifies that updating a provider key changes both memory and persistent storage.
+TEST_F(SettingsBackedTest, UpdateKeyUpdatesMemoryAndPersistentSettings)
+{
+ SharedAPIKeys keys;
+
+ keys.updateKey(Provider::HuggingFace, "new-token");
+
+ ASSERT_TRUE(keys.savedTokens.contains(Provider::HuggingFace));
+ EXPECT_EQ(keys.savedTokens[Provider::HuggingFace], "new-token");
+ EXPECT_EQ(Settings::getString("apikeys.HuggingFace"), "new-token");
+}
+
+// Verifies that updating one provider does not corrupt unrelated provider keys.
+TEST_F(SettingsBackedTest, UpdateKeyOnlyChangesSelectedProvider)
+{
+ SharedAPIKeys keys;
+ keys.updateKey(Provider::HuggingFace, "hf-token");
+ keys.updateKey(Provider::Stability, "stability-token");
+
+ keys.updateKey(Provider::HuggingFace, "updated-hf-token");
+
+ EXPECT_EQ(keys.savedTokens[Provider::HuggingFace], "updated-hf-token");
+ EXPECT_EQ(keys.savedTokens[Provider::Stability], "stability-token");
+ EXPECT_EQ(Settings::getString("apikeys.HuggingFace"), "updated-hf-token");
+ EXPECT_EQ(Settings::getString("apikeys.Stability"), "stability-token");
+}
+
+// Verifies that removing one provider key leaves unrelated provider keys intact.
+TEST_F(SettingsBackedTest, RemoveKeyRemovesOnlySelectedProvider)
+{
+ SharedAPIKeys keys;
+ keys.updateKey(Provider::HuggingFace, "hf-token");
+ keys.updateKey(Provider::Stability, "stability-token");
+
+ keys.removeKey(Provider::HuggingFace);
+
+ EXPECT_FALSE(keys.savedTokens.contains(Provider::HuggingFace));
+ ASSERT_TRUE(keys.savedTokens.contains(Provider::Stability));
+ EXPECT_EQ(keys.savedTokens[Provider::Stability], "stability-token");
+ EXPECT_FALSE(Settings::containsKey("apikeys.HuggingFace"));
+ EXPECT_EQ(Settings::getString("apikeys.Stability"), "stability-token");
+}
+
+// Verifies that removing an absent provider key is safe and does not alter existing keys.
+TEST_F(SettingsBackedTest, RemoveMissingKeyIsNoOp)
+{
+ SharedAPIKeys keys;
+ keys.updateKey(Provider::Stability, "stability-token");
+
+ keys.removeKey(Provider::HuggingFace);
+
+ EXPECT_FALSE(keys.savedTokens.contains(Provider::HuggingFace));
+ ASSERT_TRUE(keys.savedTokens.contains(Provider::Stability));
+ EXPECT_EQ(keys.savedTokens[Provider::Stability], "stability-token");
+}
+
+// Verifies that valid JSON is parsed into a JUCE var without losing object data.
+TEST(ClientJsonTest, ParseJSONStringAcceptsValidJson)
+{
+ var data;
+
+ OpResult result = parseJSONString(R"({"name":"harp"})", data);
+
+ EXPECT_TRUE(result.wasOk());
+ ASSERT_TRUE(data.isObject());
+ EXPECT_EQ(data.getDynamicObject()->getProperty("name").toString(), "harp");
+}
+
+// Verifies that malformed JSON is rejected with the expected JSON error type.
+TEST(ClientJsonTest, ParseJSONStringRejectsInvalidJson)
+{
+ var data;
+
+ OpResult result = parseJSONString(R"({"name":)", data);
+
+ EXPECT_TRUE(result.failed());
+ const auto* error = std::get_if(&result.getError());
+ ASSERT_NE(error, nullptr);
+ EXPECT_EQ(error->type, JsonError::Type::InvalidJSON);
+}
+
+// Verifies that dictionary JSON is converted into a DynamicObject pointer.
+TEST(ClientJsonTest, StringJSONToDictAcceptsDictionary)
+{
+ DynamicObject::Ptr dict;
+
+ OpResult result = stringJSONToDict(R"({"value": 5})", dict);
+
+ EXPECT_TRUE(result.wasOk());
+ ASSERT_NE(dict, nullptr);
+ EXPECT_EQ(static_cast(dict->getProperty("value")), 5);
+}
+
+// Verifies that array JSON is converted into a JUCE Array for downstream parsing.
+TEST(ClientJsonTest, StringJSONToListAcceptsArray)
+{
+ Array list;
+
+ OpResult result = stringJSONToList(R"([1,2,3])", list);
+
+ EXPECT_TRUE(result.wasOk());
+ ASSERT_EQ(list.size(), 3);
+ EXPECT_EQ(static_cast(list[0]), 1);
+}
+
+// Verifies that non-array JSON is rejected when an array response is required.
+TEST(ClientJsonTest, StringJSONToListRejectsDictionary)
+{
+ Array list;
+
+ OpResult result = stringJSONToList(R"({"value": 5})", list);
+
+ EXPECT_TRUE(result.failed());
+ const auto* error = std::get_if(&result.getError());
+ ASSERT_NE(error, nullptr);
+ EXPECT_EQ(error->type, JsonError::Type::NotAnArray);
+}
+
+// Verifies successful extraction of a required object property.
+TEST(ClientJsonTest, GetRequiredDictPropertyAcceptsObjectProperty)
+{
+ DynamicObject::Ptr child = new DynamicObject();
+ child->setProperty("name", "child");
+ DynamicObject::Ptr parent = new DynamicObject();
+ parent->setProperty("child", var(child));
+
+ DynamicObject::Ptr out;
+ OpResult result = getRequiredDictProperty(parent, Identifier("child"), out);
+
+ EXPECT_TRUE(result.wasOk());
+ ASSERT_NE(out, nullptr);
+ EXPECT_EQ(out->getProperty("name").toString(), "child");
+}
+
+// Verifies that missing required object properties are rejected explicitly.
+TEST(ClientJsonTest, GetRequiredDictPropertyRejectsMissingKey)
+{
+ DynamicObject::Ptr parent = new DynamicObject();
+ DynamicObject::Ptr out;
+
+ OpResult result = getRequiredDictProperty(parent, Identifier("child"), out);
+
+ EXPECT_TRUE(result.failed());
+ const auto* error = std::get_if(&result.getError());
+ ASSERT_NE(error, nullptr);
+ EXPECT_EQ(error->type, JsonError::Type::MissingKey);
+}
+
+// Verifies that required object properties reject values with the wrong type.
+TEST(ClientJsonTest, GetRequiredDictPropertyRejectsNonObjectValue)
+{
+ DynamicObject::Ptr parent = new DynamicObject();
+ parent->setProperty("child", 5);
+ DynamicObject::Ptr out;
+
+ OpResult result = getRequiredDictProperty(parent, Identifier("child"), out);
+
+ EXPECT_TRUE(result.failed());
+ const auto* error = std::get_if(&result.getError());
+ ASSERT_NE(error, nullptr);
+ EXPECT_EQ(error->type, JsonError::Type::NotADictionary);
+}
+
+// Verifies successful extraction of a required array property.
+TEST(ClientJsonTest, GetRequiredArrayPropertyAcceptsArrayProperty)
+{
+ Array values;
+ values.add(1);
+ values.add(2);
+ DynamicObject::Ptr parent = new DynamicObject();
+ parent->setProperty("values", var(values));
+
+ Array* out = nullptr;
+ OpResult result = getRequiredArrayProperty(parent, Identifier("values"), out);
+
+ EXPECT_TRUE(result.wasOk());
+ ASSERT_NE(out, nullptr);
+ EXPECT_EQ(out->size(), 2);
+}
+
+// Verifies that missing required array properties are rejected explicitly.
+TEST(ClientJsonTest, GetRequiredArrayPropertyRejectsMissingKey)
+{
+ DynamicObject::Ptr parent = new DynamicObject();
+ Array* out = nullptr;
+
+ OpResult result = getRequiredArrayProperty(parent, Identifier("values"), out);
+
+ EXPECT_TRUE(result.failed());
+ const auto* error = std::get_if(&result.getError());
+ ASSERT_NE(error, nullptr);
+ EXPECT_EQ(error->type, JsonError::Type::MissingKey);
+}
+
+// Verifies that required array properties reject values with the wrong type.
+TEST(ClientJsonTest, GetRequiredArrayPropertyRejectsNonArrayValue)
+{
+ DynamicObject::Ptr parent = new DynamicObject();
+ parent->setProperty("values", "not-an-array");
+ Array* out = nullptr;
+
+ OpResult result = getRequiredArrayProperty(parent, Identifier("values"), out);
+
+ EXPECT_TRUE(result.failed());
+ const auto* error = std::get_if(&result.getError());
+ ASSERT_NE(error, nullptr);
+ EXPECT_EQ(error->type, JsonError::Type::NotAnArray);
+}
+
+// Verifies the base Client upload behavior without invoking provider-specific network upload.
+TEST(ClientDefaultBehaviorTest, UploadFilePassesThroughLocalPath)
+{
+ MinimalClient client;
+ File file = File::createFileWithoutCheckingPath("/tmp/input.wav");
+ String remotePath;
+
+ OpResult result = client.uploadFile("model", file, remotePath);
+
+ EXPECT_TRUE(result.wasOk());
+ EXPECT_EQ(remotePath, file.getFullPathName());
+}
+
+// Verifies that the base Client cancel operation is a safe no-op.
+TEST(ClientDefaultBehaviorTest, CancelDefaultsToOk)
+{
+ MinimalClient client;
+
+ OpResult result = client.cancel("model");
+
+ EXPECT_TRUE(result.wasOk());
+}
diff --git a/tests/clients/gradio_client_tests.cpp b/tests/clients/gradio_client_tests.cpp
new file mode 100644
index 00000000..afc422e2
--- /dev/null
+++ b/tests/clients/gradio_client_tests.cpp
@@ -0,0 +1,260 @@
+// -----------------------------------------------------------------------------
+// Test rationale
+// -----------------------------------------------------------------------------
+// This file verifies deterministic, HARP-owned behavior in GradioClient.h
+// without exercising external Gradio, Hugging Face, HTTP, or file-download
+// communication.
+//
+// The tests focus on three categories of GradioClient behavior:
+//
+// Category: Path validation
+// - GradioClient::matchesPathSpec: verifies that supported local, Gradio,
+// and Hugging Face path formats are accepted while unsupported or ambiguous
+// paths are rejected.
+//
+// Category: Path inference
+// - GradioClient::inferHostSlashModel: verifies canonical host/model extraction.
+// - GradioClient::inferEndpointPath: verifies endpoint URL normalization and
+// Hugging Face Space URL construction.
+// - GradioClient::inferDocumentationPath: verifies documentation URL inference
+// for supported Hugging Face path formats.
+//
+// Category: Payload transformation
+// - GradioClient::wrapPayloadElement: verifies that file payloads are wrapped
+// with Gradio-specific metadata while non-file payloads remain unchanged.
+//
+// The makePayloadObject helper creates the minimal DynamicObject needed by
+// payload transformation tests. It keeps those tests focused on Gradio metadata
+// behavior rather than repeated object setup.
+// -----------------------------------------------------------------------------
+
+#include
+
+#include
+
+#include
+
+#include "../../src/clients/GradioClient.h"
+
+using namespace juce;
+
+namespace
+{
+// Creates a minimal file-like payload object used by wrapPayloadElement tests.
+DynamicObject::Ptr makePayloadObject(const String& path = "input.wav")
+{
+ DynamicObject::Ptr object = new DynamicObject();
+ object->setProperty("path", path);
+ return object;
+}
+} // namespace
+
+// Verifies that local paths accepted by HARP are recognized before network use.
+TEST(GradioClientPathValidationTest, AcceptsSupportedLocalPaths)
+{
+ EXPECT_TRUE(GradioClient::matchesPathSpec("localhost:7860"));
+ EXPECT_TRUE(GradioClient::matchesPathSpec("http://localhost:7860"));
+ EXPECT_TRUE(GradioClient::matchesPathSpec("127.0.0.1:7860"));
+ EXPECT_TRUE(GradioClient::matchesPathSpec("192.168.1.10:7860"));
+}
+
+// Verifies that temporary Gradio share URLs are accepted as supported model paths.
+TEST(GradioClientPathValidationTest, AcceptsSupportedGradioLivePaths)
+{
+ EXPECT_TRUE(GradioClient::matchesPathSpec("https://abc123.gradio.live"));
+ EXPECT_TRUE(GradioClient::matchesPathSpec("https://my-temporary-space.gradio.live"));
+}
+
+// Verifies that all supported Hugging Face path formats are accepted.
+TEST(GradioClientPathValidationTest, AcceptsSupportedHuggingFacePaths)
+{
+ EXPECT_TRUE(GradioClient::matchesPathSpec("https://owner-model.hf.space/"));
+ EXPECT_TRUE(GradioClient::matchesPathSpec("https://huggingface.co/spaces/owner/model_name"));
+ EXPECT_TRUE(GradioClient::matchesPathSpec("owner/model_name"));
+}
+
+// Verifies that unsupported or malformed paths are rejected before endpoint inference.
+TEST(GradioClientPathValidationTest, RejectsUnsupportedPaths)
+{
+ EXPECT_FALSE(GradioClient::matchesPathSpec(""));
+ EXPECT_FALSE(GradioClient::matchesPathSpec(" "));
+ EXPECT_FALSE(GradioClient::matchesPathSpec("https://example.com/model"));
+ EXPECT_FALSE(GradioClient::matchesPathSpec("ftp://owner-model.hf.space/"));
+ EXPECT_FALSE(GradioClient::matchesPathSpec("not a path"));
+}
+
+// Verifies that ambiguous short Hugging Face paths are rejected instead of guessed.
+TEST(GradioClientPathValidationTest, RejectsAmbiguousShortHuggingFacePath)
+{
+ EXPECT_FALSE(GradioClient::matchesPathSpec("https://owner-model-extra.hf.space/"));
+}
+
+// Verifies that local and Gradio paths map to the expected local host/model identity.
+TEST(GradioClientPathInferenceTest, InferHostSlashModelReturnsLocalhostForLocalAndGradioPaths)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferHostSlashModel("localhost:7860"), "localhost");
+ EXPECT_EQ(client.inferHostSlashModel("http://localhost:7860"), "localhost");
+ EXPECT_EQ(client.inferHostSlashModel("https://abc123.gradio.live"), "localhost");
+}
+
+// Verifies host/model extraction from short Hugging Face Space URLs.
+TEST(GradioClientPathInferenceTest, InferHostSlashModelParsesShortHuggingFacePath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferHostSlashModel("https://owner-model.hf.space/"), "owner/model");
+}
+
+// Verifies host/model extraction from long Hugging Face Space URLs.
+TEST(GradioClientPathInferenceTest, InferHostSlashModelParsesLongHuggingFacePath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferHostSlashModel("https://huggingface.co/spaces/owner/model_name"), "owner/model_name");
+}
+
+// Verifies that abbreviated Hugging Face paths already in owner/model form are preserved.
+TEST(GradioClientPathInferenceTest, InferHostSlashModelPreservesAbbreviatedHuggingFacePath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferHostSlashModel("owner/model_name"), "owner/model_name");
+}
+
+// Verifies that invalid paths fail closed by returning an empty host/model string.
+TEST(GradioClientPathInferenceTest, InferHostSlashModelReturnsEmptyForInvalidPath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferHostSlashModel("https://example.com/model"), "");
+}
+
+// Verifies that local paths without a protocol are normalized to HTTP endpoints.
+TEST(GradioClientPathInferenceTest, InferEndpointPathNormalizesLocalPathWithoutProtocol)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferEndpointPath("localhost:7860"), "http://localhost:7860");
+ EXPECT_EQ(client.inferEndpointPath("127.0.0.1:7860"), "http://127.0.0.1:7860");
+}
+
+// Verifies that local paths already using HTTP are not modified.
+TEST(GradioClientPathInferenceTest, InferEndpointPathPreservesLocalPathWithHttpProtocol)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferEndpointPath("http://localhost:7860"), "http://localhost:7860");
+}
+
+// Verifies that endpoint inference preserves direct Gradio and short Hugging Face URLs.
+TEST(GradioClientPathInferenceTest, InferEndpointPathPreservesGradioLiveAndShortHuggingFacePaths)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferEndpointPath("https://abc123.gradio.live"), "https://abc123.gradio.live");
+ EXPECT_EQ(client.inferEndpointPath("https://owner-model.hf.space/"), "https://owner-model.hf.space/");
+}
+
+// Verifies endpoint construction from long Hugging Face paths, including underscore normalization.
+TEST(GradioClientPathInferenceTest, InferEndpointPathBuildsHuggingFaceSpaceUrlFromLongPath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferEndpointPath("https://huggingface.co/spaces/owner/model_name"),
+ "https://owner-model-name.hf.space/");
+}
+
+// Verifies endpoint construction from abbreviated Hugging Face owner/model paths.
+TEST(GradioClientPathInferenceTest, InferEndpointPathBuildsHuggingFaceSpaceUrlFromAbbreviatedPath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferEndpointPath("owner/model_name"), "https://owner-model-name.hf.space/");
+}
+
+// Verifies that invalid paths fail closed by returning an empty endpoint path.
+TEST(GradioClientPathInferenceTest, InferEndpointPathReturnsEmptyForInvalidPath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferEndpointPath("https://example.com/model"), "");
+}
+
+// Verifies documentation URL construction from short and abbreviated Hugging Face paths.
+TEST(GradioClientPathInferenceTest, InferDocumentationPathBuildsHuggingFaceLongDocumentationUrl)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferDocumentationPath("https://owner-model.hf.space/"),
+ "https://huggingface.co/spaces/owner/model");
+ EXPECT_EQ(client.inferDocumentationPath("owner/model_name"),
+ "https://huggingface.co/spaces/owner/model_name");
+}
+
+// Verifies that long Hugging Face documentation paths are already canonical and are preserved.
+TEST(GradioClientPathInferenceTest, InferDocumentationPathPreservesLongHuggingFacePath)
+{
+ GradioClient client;
+
+ EXPECT_EQ(client.inferDocumentationPath("https://huggingface.co/spaces/owner/model_name"),
+ "https://huggingface.co/spaces/owner/model_name");
+}
+
+// Verifies that non-file payloads are not modified by Gradio-specific file wrapping.
+TEST(GradioClientPayloadTest, WrapPayloadElementLeavesNonFilePayloadUnchanged)
+{
+ GradioClient client;
+ var payload("hello");
+
+ var wrapped = client.wrapPayloadElement(payload, false);
+
+ EXPECT_EQ(wrapped.toString(), "hello");
+}
+
+// Verifies that void file payloads remain unchanged instead of receiving metadata.
+TEST(GradioClientPayloadTest, WrapPayloadElementLeavesVoidFilePayloadUnchanged)
+{
+ GradioClient client;
+ var payload;
+
+ var wrapped = client.wrapPayloadElement(payload, true);
+
+ EXPECT_TRUE(wrapped.isVoid());
+}
+
+// Verifies that file payloads receive the Gradio FileData metadata required by Gradio.
+TEST(GradioClientPayloadTest, WrapPayloadElementAddsGradioFileMetadataForFilePayload)
+{
+ GradioClient client;
+ DynamicObject::Ptr object = makePayloadObject();
+
+ var wrapped = client.wrapPayloadElement(var(object), true);
+
+ ASSERT_TRUE(wrapped.isObject());
+ auto* wrappedObject = wrapped.getDynamicObject();
+ ASSERT_NE(wrappedObject, nullptr);
+ ASSERT_TRUE(wrappedObject->hasProperty("meta"));
+
+ var meta = wrappedObject->getProperty("meta");
+ ASSERT_TRUE(meta.isObject());
+ EXPECT_EQ(meta.getDynamicObject()->getProperty("_type").toString(), "gradio.FileData");
+}
+
+// Verifies that Gradio file wrapping replaces stale metadata with the required FileData type.
+TEST(GradioClientPayloadTest, WrapPayloadElementReplacesExistingMetadataForFilePayload)
+{
+ GradioClient client;
+ DynamicObject::Ptr object = makePayloadObject();
+ DynamicObject::Ptr originalMeta = new DynamicObject();
+ originalMeta->setProperty("_type", "old");
+ object->setProperty("meta", var(originalMeta));
+
+ var wrapped = client.wrapPayloadElement(var(object), true);
+
+ ASSERT_TRUE(wrapped.isObject());
+ var meta = wrapped.getDynamicObject()->getProperty("meta");
+ ASSERT_TRUE(meta.isObject());
+ EXPECT_EQ(meta.getDynamicObject()->getProperty("_type").toString(), "gradio.FileData");
+}
diff --git a/tests/test_harp_logger.cpp b/tests/test_harp_logger.cpp
new file mode 100644
index 00000000..58c4de11
--- /dev/null
+++ b/tests/test_harp_logger.cpp
@@ -0,0 +1,3 @@
+#include "../src/utils/Logging.h"
+
+JUCE_IMPLEMENT_SINGLETON(HARPLogger)
diff --git a/tests/test_main.cpp b/tests/test_main.cpp
new file mode 100644
index 00000000..9bb465e0
--- /dev/null
+++ b/tests/test_main.cpp
@@ -0,0 +1,7 @@
+#include
+
+int main(int argc, char** argv)
+{
+ ::testing::InitGoogleTest(&argc, argv);
+ return RUN_ALL_TESTS();
+}
diff --git a/tests/test_smoke.cpp b/tests/test_smoke.cpp
new file mode 100644
index 00000000..75dd069c
--- /dev/null
+++ b/tests/test_smoke.cpp
@@ -0,0 +1,6 @@
+#include
+
+TEST(SmokeTest, BasicMath)
+{
+ EXPECT_EQ(2 + 2, 4);
+}
diff --git a/tests/utils/controls_tests.cpp b/tests/utils/controls_tests.cpp
new file mode 100644
index 00000000..c66728a0
--- /dev/null
+++ b/tests/utils/controls_tests.cpp
@@ -0,0 +1,584 @@
+// -----------------------------------------------------------------------------
+// Test rationale
+// -----------------------------------------------------------------------------
+// This file verifies deterministic, HARP-owned parsing and state-update behavior
+// in Controls.h. These tests focus on the internal control metadata objects that
+// HARP builds from DynamicObject data before the GUI renders or updates controls.
+//
+// The helper functions create compact DynamicObject and choice structures so
+// tests can focus on parsing behavior instead of repeated JUCE setup.
+//
+// The ControlsGuiTest fixture initializes JUCE GUI state for listener tests that
+// exercise TextEditor, ToggleButton, Slider, and ComboBox callbacks.
+// -----------------------------------------------------------------------------
+
+#include
+
+#include
+#include
+
+#include "../../src/utils/Controls.h"
+
+using namespace juce;
+
+namespace
+{
+// Creates an empty DynamicObject for default/missing-property parsing tests.
+DynamicObject::Ptr makeObject()
+{
+ return new DynamicObject();
+}
+
+// Creates a DynamicObject with named properties to keep parser tests compact.
+DynamicObject::Ptr makeObject(std::initializer_list> properties)
+{
+ DynamicObject::Ptr object = new DynamicObject();
+
+ for (const auto& [key, value] : properties)
+ {
+ object->setProperty(Identifier(key), value);
+ }
+
+ return object;
+}
+
+// Creates one Gradio-style combo-box choice, represented as an array whose first
+// element is the displayed value parsed by ComboBoxComponentInfo.
+var makeChoice(const var& displayedValue)
+{
+ Array choice;
+ choice.add(displayedValue);
+ return var(choice);
+}
+
+// Creates the choices array consumed by ComboBoxComponentInfo.
+var makeChoices(std::initializer_list choices)
+{
+ Array choiceArray;
+
+ for (const auto& choice : choices)
+ {
+ choiceArray.add(choice);
+ }
+
+ return var(choiceArray);
+}
+
+// Initializes JUCE GUI infrastructure required by listener/callback tests.
+class ControlsGuiTest : public ::testing::Test
+{
+protected:
+ ScopedJuceInitialiser_GUI juceInitialiser;
+};
+} // namespace
+
+// -----------------------------------------------------------------------------
+// Category: Boolean parsing
+// -----------------------------------------------------------------------------
+// These tests verify stringToBool behavior used by multiple control parsers.
+
+// Verifies that stringToBool accepts every true representation used by HARP parsers.
+TEST(StringToBoolTest, AcceptsDocumentedTrueValues)
+{
+ const StringArray trueValues { "true", "TRUE", "True", "tRuE", "1", "yes", "YES", "Yes", "y", "Y" };
+
+ for (const auto& input : trueValues)
+ {
+ EXPECT_TRUE(stringToBool(input)) << "Expected true for input: " << input;
+ }
+}
+
+
+// Verifies that boolean parsing is whitespace-sensitive and does not trim input.
+TEST(StringToBoolTest, DoesNotTrimWhitespace)
+{
+ const StringArray whitespaceInputs { "", " ", " ", " true", "true ", " yes ", "\ntrue", "true\n", "\ttrue" };
+
+ for (const auto& input : whitespaceInputs)
+ {
+ EXPECT_FALSE(stringToBool(input)) << "Expected false for whitespace-sensitive input: " << input;
+ }
+}
+
+// Verifies that unsupported boolean-like strings fail closed to false.
+TEST(StringToBoolTest, ReturnsFalseForUnsupportedValues)
+{
+ const StringArray unsupportedValues { "banana", "10", "-1", "null", "nullptr", "maybe", "truthy" };
+
+ for (const auto& input : unsupportedValues)
+ {
+ EXPECT_FALSE(stringToBool(input)) << "Expected false for unsupported input: " << input;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Category: Base control metadata parsing
+// -----------------------------------------------------------------------------
+// These tests verify shared label/info parsing in ModelComponentInfo.
+
+// Verifies that the base metadata object starts with empty display text.
+TEST(ModelComponentInfoTest, DefaultConstructorInitializesTextFields)
+{
+ ModelComponentInfo info;
+
+ EXPECT_EQ(info.label, "");
+ EXPECT_EQ(info.info, "");
+}
+
+// Verifies that missing label/info properties preserve base metadata defaults.
+TEST(ModelComponentInfoTest, EmptyDynamicObjectUsesDefaultTextFields)
+{
+ auto object = makeObject();
+ ModelComponentInfo info(object.get());
+
+ EXPECT_EQ(info.label, "");
+ EXPECT_EQ(info.info, "");
+}
+
+// Verifies that shared label/info metadata is parsed from DynamicObject input.
+TEST(ModelComponentInfoTest, ParsesLabelAndInfo)
+{
+ auto object = makeObject({ { "label", "Pitch" }, { "info", "Controls pitch shifting" } });
+ ModelComponentInfo info(object.get());
+
+ EXPECT_EQ(info.label, "Pitch");
+ EXPECT_EQ(info.info, "Controls pitch shifting");
+}
+
+// Verifies that label parsing does not require an info property.
+TEST(ModelComponentInfoTest, ParsesOnlyLabelWhenInfoIsMissing)
+{
+ auto object = makeObject({ { "label", "Gain" } });
+ ModelComponentInfo info(object.get());
+
+ EXPECT_EQ(info.label, "Gain");
+ EXPECT_EQ(info.info, "");
+}
+
+// Verifies that info parsing does not require a label property.
+TEST(ModelComponentInfoTest, ParsesOnlyInfoWhenLabelIsMissing)
+{
+ auto object = makeObject({ { "info", "Useful helper text" } });
+ ModelComponentInfo info(object.get());
+
+ EXPECT_EQ(info.label, "");
+ EXPECT_EQ(info.info, "Useful helper text");
+}
+
+// Verifies that non-string label/info properties are converted using JUCE var string conversion.
+TEST(ModelComponentInfoTest, ConvertsNonStringPropertiesToStrings)
+{
+ auto object = makeObject({ { "label", 123 }, { "info", 45.5 } });
+ ModelComponentInfo info(object.get());
+
+ EXPECT_EQ(info.label, "123");
+ EXPECT_EQ(info.info, "45.5");
+}
+
+// Verifies that metadata parsing preserves Unicode and long strings.
+TEST(ModelComponentInfoTest, PreservesUnicodeAndLongStrings)
+{
+ const String unicodeLabel = CharPointer_UTF8("ι³ι π΅");
+ const String longInfo = String::repeatedString("abc", 1000);
+
+ auto object = makeObject({ { "label", unicodeLabel }, { "info", longInfo } });
+ ModelComponentInfo info(object.get());
+
+ EXPECT_EQ(info.label, unicodeLabel.toStdString());
+ EXPECT_EQ(info.info, longInfo.toStdString());
+}
+
+// -----------------------------------------------------------------------------
+// Category: Track metadata parsing
+// -----------------------------------------------------------------------------
+// These tests verify required-track defaults and derived track parsing.
+
+// Verifies default track metadata, including required=true.
+TEST(TrackComponentInfoTest, DefaultConstructorRequiredIsTrue)
+{
+ TrackComponentInfo info;
+
+ EXPECT_TRUE(info.required);
+ EXPECT_EQ(info.path, "");
+}
+
+// Verifies that missing required metadata defaults to a required track.
+TEST(TrackComponentInfoTest, MissingRequiredPropertyDefaultsToTrue)
+{
+ auto object = makeObject({ { "label", "Input Track" } });
+ TrackComponentInfo info(object.get());
+
+ EXPECT_TRUE(info.required);
+}
+
+// Verifies that required=true values use the same boolean parsing contract as stringToBool.
+TEST(TrackComponentInfoTest, ParsesRequiredTrueValues)
+{
+ const StringArray trueValues { "true", "TRUE", "1", "yes", "y" };
+
+ for (const auto& value : trueValues)
+ {
+ auto object = makeObject({ { "required", value } });
+ TrackComponentInfo info(object.get());
+ EXPECT_TRUE(info.required) << "Expected required=true for input: " << value;
+ }
+}
+
+// Verifies that false and unsupported required values are parsed as false.
+TEST(TrackComponentInfoTest, ParsesRequiredFalseAndUnsupportedValuesAsFalse)
+{
+ const StringArray falseValues { "false", "FALSE", "0", "no", "n", "banana", " true " };
+
+ for (const auto& value : falseValues)
+ {
+ auto object = makeObject({ { "required", value } });
+ TrackComponentInfo info(object.get());
+ EXPECT_FALSE(info.required) << "Expected required=false for input: " << value;
+ }
+}
+
+// Verifies that audio and MIDI track metadata reuse TrackComponentInfo parsing behavior.
+TEST(TrackComponentInfoTest, AudioAndMidiTrackComponentsUseTrackParsing)
+{
+ auto object = makeObject({ { "label", "Track" }, { "required", "false" } });
+
+ AudioTrackComponentInfo audioInfo(object.get());
+ MidiTrackComponentInfo midiInfo(object.get());
+
+ EXPECT_EQ(audioInfo.label, "Track");
+ EXPECT_FALSE(audioInfo.required);
+ EXPECT_EQ(midiInfo.label, "Track");
+ EXPECT_FALSE(midiInfo.required);
+}
+
+// -----------------------------------------------------------------------------
+// Category: Text metadata and listener behavior
+// -----------------------------------------------------------------------------
+// These tests verify TextBoxComponentInfo parsing and TextEditor callback updates.
+
+// Verifies that missing text-box values default to an empty string.
+TEST(TextBoxComponentInfoTest, MissingValueDefaultsToEmptyString)
+{
+ auto object = makeObject({ { "label", "Prompt" } });
+ TextBoxComponentInfo info(object.get());
+
+ EXPECT_EQ(info.value, "");
+}
+
+// Verifies that text-box values are parsed from DynamicObject input.
+TEST(TextBoxComponentInfoTest, ParsesValue)
+{
+ auto object = makeObject({ { "value", "hello" } });
+ TextBoxComponentInfo info(object.get());
+
+ EXPECT_EQ(info.value, "hello");
+}
+
+// Verifies that non-string text-box values are converted to strings.
+TEST(TextBoxComponentInfoTest, ConvertsNonStringValueToString)
+{
+ auto object = makeObject({ { "value", 42 } });
+ TextBoxComponentInfo info(object.get());
+
+ EXPECT_EQ(info.value, "42");
+}
+
+// Verifies that text-editor callbacks update the stored text-box value.
+TEST_F(ControlsGuiTest, TextEditorListenerUpdatesStoredValue)
+{
+ auto object = makeObject({ { "value", "initial" } });
+ TextBoxComponentInfo info(object.get());
+
+ TextEditor editor;
+ editor.setText("updated", dontSendNotification);
+ info.textEditorTextChanged(editor);
+
+ EXPECT_EQ(info.value, "updated");
+}
+
+// Verifies that text-editor callbacks preserve Unicode user input.
+TEST_F(ControlsGuiTest, TextEditorListenerPreservesUnicodeText)
+{
+ const String unicodeText = CharPointer_UTF8("γγγ«γ‘γ― π΅");
+ auto object = makeObject({ { "value", "initial" } });
+ TextBoxComponentInfo info(object.get());
+
+ TextEditor editor;
+ editor.setText(unicodeText, dontSendNotification);
+ info.textEditorTextChanged(editor);
+
+ EXPECT_EQ(info.value, unicodeText.toStdString());
+}
+
+// -----------------------------------------------------------------------------
+// Category: Numeric metadata parsing
+// -----------------------------------------------------------------------------
+// These tests verify NumberBoxComponentInfo numeric parsing behavior.
+
+// Verifies numeric field parsing for explicit numeric values.
+TEST(NumberBoxComponentInfoTest, ParsesAllNumericFields)
+{
+ auto object = makeObject({ { "min", -1.5 }, { "max", 10.25 }, { "value", 3.75 } });
+ NumberBoxComponentInfo info(object.get());
+
+ EXPECT_DOUBLE_EQ(info.min, -1.5);
+ EXPECT_DOUBLE_EQ(info.max, 10.25);
+ EXPECT_DOUBLE_EQ(info.value, 3.75);
+}
+
+// Verifies numeric field parsing from string-backed values.
+TEST(NumberBoxComponentInfoTest, ParsesNumericStringFields)
+{
+ auto object = makeObject({ { "min", "-10" }, { "max", "20.5" }, { "value", "7.25" } });
+ NumberBoxComponentInfo info(object.get());
+
+ EXPECT_DOUBLE_EQ(info.min, -10.0);
+ EXPECT_DOUBLE_EQ(info.max, 20.5);
+ EXPECT_DOUBLE_EQ(info.value, 7.25);
+}
+
+// Verifies current malformed numeric-string behavior so parser assumptions remain explicit.
+TEST(NumberBoxComponentInfoTest, MalformedNumericStringsParseAsZero)
+{
+ auto object = makeObject({ { "min", "abc" }, { "max", "" }, { "value", "not-a-number" } });
+ NumberBoxComponentInfo info(object.get());
+
+ EXPECT_DOUBLE_EQ(info.min, 0.0);
+ EXPECT_DOUBLE_EQ(info.max, 0.0);
+ EXPECT_DOUBLE_EQ(info.value, 0.0);
+}
+
+// -----------------------------------------------------------------------------
+// Category: Toggle metadata and listener behavior
+// -----------------------------------------------------------------------------
+// These tests verify ToggleComponentInfo parsing and ToggleButton callback updates.
+
+// Verifies that missing toggle values default to false.
+TEST(ToggleComponentInfoTest, MissingValueDefaultsToFalse)
+{
+ auto object = makeObject({ { "label", "Enable" } });
+ ToggleComponentInfo info(object.get());
+
+ EXPECT_FALSE(info.value);
+}
+
+// Verifies toggle value parsing for supported true values.
+TEST(ToggleComponentInfoTest, ParsesTrueValues)
+{
+ const StringArray trueValues { "true", "TRUE", "1", "yes", "y" };
+
+ for (const auto& value : trueValues)
+ {
+ auto object = makeObject({ { "value", value } });
+ ToggleComponentInfo info(object.get());
+ EXPECT_TRUE(info.value) << "Expected toggle value=true for input: " << value;
+ }
+}
+
+// Verifies toggle value parsing for false and unsupported values.
+TEST(ToggleComponentInfoTest, ParsesFalseAndUnsupportedValuesAsFalse)
+{
+ const StringArray falseValues { "false", "FALSE", "0", "no", "n", "banana", " true " };
+
+ for (const auto& value : falseValues)
+ {
+ auto object = makeObject({ { "value", value } });
+ ToggleComponentInfo info(object.get());
+ EXPECT_FALSE(info.value) << "Expected toggle value=false for input: " << value;
+ }
+}
+
+// Verifies that button callbacks update the stored toggle value in both directions.
+TEST_F(ControlsGuiTest, ButtonListenerUpdatesStoredToggleValue)
+{
+ auto object = makeObject({ { "value", "false" } });
+ ToggleComponentInfo info(object.get());
+
+ ToggleButton button;
+ button.setToggleState(true, dontSendNotification);
+ info.buttonClicked(&button);
+ EXPECT_TRUE(info.value);
+
+ button.setToggleState(false, dontSendNotification);
+ info.buttonClicked(&button);
+ EXPECT_FALSE(info.value);
+}
+
+// -----------------------------------------------------------------------------
+// Category: Slider metadata and listener behavior
+// -----------------------------------------------------------------------------
+// These tests verify SliderComponentInfo parsing and Slider callback update behavior.
+
+// Verifies slider field parsing for explicit numeric values.
+TEST(SliderComponentInfoTest, ParsesAllSliderFields)
+{
+ auto object = makeObject({ { "minimum", -5.0 }, { "maximum", 5.0 }, { "step", 0.5 }, { "value", 2.5 } });
+ SliderComponentInfo info(object.get());
+
+ EXPECT_DOUBLE_EQ(info.minimum, -5.0);
+ EXPECT_DOUBLE_EQ(info.maximum, 5.0);
+ EXPECT_DOUBLE_EQ(info.step, 0.5);
+ EXPECT_DOUBLE_EQ(info.value, 2.5);
+}
+
+// Verifies that SliderComponentInfo preserves invalid ranges instead of validating them.
+TEST(SliderComponentInfoTest, PreservesMinGreaterThanMaxWithoutValidation)
+{
+ auto object = makeObject({ { "minimum", 10.0 }, { "maximum", -10.0 }, { "step", 1.0 }, { "value", 0.0 } });
+ SliderComponentInfo info(object.get());
+
+ EXPECT_DOUBLE_EQ(info.minimum, 10.0);
+ EXPECT_DOUBLE_EQ(info.maximum, -10.0);
+ EXPECT_DOUBLE_EQ(info.step, 1.0);
+ EXPECT_DOUBLE_EQ(info.value, 0.0);
+}
+
+// Verifies slider field parsing from string-backed numeric values.
+TEST(SliderComponentInfoTest, ParsesNumericStrings)
+{
+ auto object = makeObject({ { "minimum", "-1" }, { "maximum", "1.5" }, { "step", "0.25" }, { "value", "0.75" } });
+ SliderComponentInfo info(object.get());
+
+ EXPECT_DOUBLE_EQ(info.minimum, -1.0);
+ EXPECT_DOUBLE_EQ(info.maximum, 1.5);
+ EXPECT_DOUBLE_EQ(info.step, 0.25);
+ EXPECT_DOUBLE_EQ(info.value, 0.75);
+}
+
+// Verifies that transient slider movement does not update stored value.
+TEST_F(ControlsGuiTest, SliderValueChangedDoesNotUpdateStoredValue)
+{
+ auto object = makeObject({ { "minimum", 0.0 }, { "maximum", 10.0 }, { "step", 1.0 }, { "value", 2.0 } });
+ SliderComponentInfo info(object.get());
+
+ Slider slider;
+ slider.setValue(8.0, dontSendNotification);
+ info.sliderValueChanged(&slider);
+
+ EXPECT_DOUBLE_EQ(info.value, 2.0);
+}
+
+// Verifies that drag-end slider callbacks commit the stored value.
+TEST_F(ControlsGuiTest, SliderDragEndedUpdatesStoredValue)
+{
+ auto object = makeObject({ { "minimum", 0.0 }, { "maximum", 10.0 }, { "step", 1.0 }, { "value", 2.0 } });
+ SliderComponentInfo info(object.get());
+
+ Slider slider;
+ slider.setValue(8.0, dontSendNotification);
+ info.sliderDragEnded(&slider);
+
+ EXPECT_DOUBLE_EQ(info.value, 8.0);
+}
+
+// -----------------------------------------------------------------------------
+// Category: Combo box metadata and listener behavior
+// -----------------------------------------------------------------------------
+// These tests verify ComboBoxComponentInfo choice parsing and ComboBox updates.
+
+// Verifies that missing combo-box choices produce empty options and value.
+TEST(ComboBoxComponentInfoTest, MissingChoicesDefaultsToEmptyOptionsAndValue)
+{
+ auto object = makeObject({ { "label", "Mode" } });
+ ComboBoxComponentInfo info(object.get());
+
+ EXPECT_TRUE(info.options.empty());
+ EXPECT_EQ(info.value, "");
+}
+
+// Verifies that an explicit empty choices array produces empty options and value.
+TEST(ComboBoxComponentInfoTest, EmptyChoicesDefaultsToEmptyOptionsAndValue)
+{
+ Array choices;
+ auto object = makeObject({ { "choices", var(choices) } });
+ ComboBoxComponentInfo info(object.get());
+
+ EXPECT_TRUE(info.options.empty());
+ EXPECT_EQ(info.value, "");
+}
+
+// Verifies that missing combo-box value defaults to the first available choice.
+TEST(ComboBoxComponentInfoTest, MissingValueDefaultsToFirstChoice)
+{
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice("A"), makeChoice("B") }) } });
+ ComboBoxComponentInfo info(object.get());
+
+ ASSERT_EQ(info.options.size(), 2u);
+ EXPECT_EQ(info.options[0], "A");
+ EXPECT_EQ(info.options[1], "B");
+ EXPECT_EQ(info.value, "A");
+}
+
+// Verifies that an explicit combo-box value overrides first-choice defaulting.
+TEST(ComboBoxComponentInfoTest, ProvidedValueOverridesDefaultSelection)
+{
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice("A"), makeChoice("B") }) }, { "value", "B" } });
+ ComboBoxComponentInfo info(object.get());
+
+ ASSERT_EQ(info.options.size(), 2u);
+ EXPECT_EQ(info.value, "B");
+}
+
+// Verifies that explicit combo-box values are preserved even when absent from options.
+TEST(ComboBoxComponentInfoTest, ProvidedValueDoesNotNeedToMatchOption)
+{
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice("A"), makeChoice("B") }) }, { "value", "C" } });
+ ComboBoxComponentInfo info(object.get());
+
+ ASSERT_EQ(info.options.size(), 2u);
+ EXPECT_EQ(info.value, "C");
+}
+
+// Verifies that duplicate and empty combo-box choices are preserved.
+TEST(ComboBoxComponentInfoTest, PreservesDuplicateAndEmptyChoices)
+{
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice("A"), makeChoice("A"), makeChoice("") }) } });
+ ComboBoxComponentInfo info(object.get());
+
+ ASSERT_EQ(info.options.size(), 3u);
+ EXPECT_EQ(info.options[0], "A");
+ EXPECT_EQ(info.options[1], "A");
+ EXPECT_EQ(info.options[2], "");
+ EXPECT_EQ(info.value, "A");
+}
+
+// Verifies that non-string combo-box choice labels are converted to strings.
+TEST(ComboBoxComponentInfoTest, ConvertsChoiceLabelsToStrings)
+{
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice(123), makeChoice(45.5) }) } });
+ ComboBoxComponentInfo info(object.get());
+
+ ASSERT_EQ(info.options.size(), 2u);
+ EXPECT_EQ(info.options[0], "123");
+ EXPECT_EQ(info.options[1], "45.5");
+ EXPECT_EQ(info.value, "123");
+}
+
+// Verifies that combo-box parsing preserves Unicode choices.
+TEST(ComboBoxComponentInfoTest, PreservesUnicodeChoices)
+{
+ const String first = CharPointer_UTF8("ι³θ²");
+ const String second = CharPointer_UTF8("γͺγΊγ π΅");
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice(first), makeChoice(second) }) } });
+ ComboBoxComponentInfo info(object.get());
+
+ ASSERT_EQ(info.options.size(), 2u);
+ EXPECT_EQ(info.options[0], first.toStdString());
+ EXPECT_EQ(info.options[1], second.toStdString());
+ EXPECT_EQ(info.value, first.toStdString());
+}
+
+// Verifies that combo-box callbacks update the stored selection.
+TEST_F(ControlsGuiTest, ComboBoxListenerUpdatesStoredSelection)
+{
+ auto object = makeObject({ { "choices", makeChoices({ makeChoice("A"), makeChoice("B") }) } });
+ ComboBoxComponentInfo info(object.get());
+
+ ComboBox comboBox;
+ comboBox.addItem("A", 1);
+ comboBox.addItem("B", 2);
+ comboBox.setSelectedId(2, dontSendNotification);
+ info.comboBoxChanged(&comboBox);
+
+ EXPECT_EQ(info.value, "B");
+}
diff --git a/tests/utils/settings_tests.cpp b/tests/utils/settings_tests.cpp
new file mode 100644
index 00000000..e26e0017
--- /dev/null
+++ b/tests/utils/settings_tests.cpp
@@ -0,0 +1,228 @@
+// -----------------------------------------------------------------------------
+// Test rationale
+// -----------------------------------------------------------------------------
+// This file verifies deterministic, HARP-owned state-management behavior in
+// Settings.h without reading from or writing to real user settings.
+//
+// The SettingsTest fixture creates an isolated temporary PropertiesFile so each
+// test can verify settings behavior without affecting application state outside
+// the test process.
+//
+// Category sections are placed near the tests they describe so the rationale for
+// each group remains close to the behavior being verified.
+// -----------------------------------------------------------------------------
+
+#include
+
+#include
+#include
+
+#include "../../src/utils/Settings.h"
+
+using namespace juce;
+
+namespace
+{
+// Provides an isolated settings store for tests that need initialized Settings.
+class SettingsTest : public ::testing::Test
+{
+protected:
+ void SetUp() override
+ {
+ testDirectory = File::getSpecialLocation(File::tempDirectory)
+ .getChildFile("HARP_Settings_Tests_" + Uuid().toString());
+ ASSERT_TRUE(testDirectory.createDirectory());
+
+ PropertiesFile::Options options;
+ options.applicationName = "HARPSettingsTest";
+ options.filenameSuffix = "settings";
+ options.folderName = testDirectory.getFullPathName();
+ options.osxLibrarySubFolder = "Application Support";
+ options.storageFormat = PropertiesFile::storeAsXML;
+
+ appProperties.setStorageParameters(options);
+ Settings::initialize(&appProperties);
+ }
+
+ void TearDown() override
+ {
+ Settings::initialize(nullptr);
+ appProperties.closeFiles();
+ testDirectory.deleteRecursively();
+ }
+
+ File testDirectory;
+ ApplicationProperties appProperties;
+};
+} // namespace
+
+// -----------------------------------------------------------------------------
+// Category: Null-safe uninitialized behavior
+// -----------------------------------------------------------------------------
+// These tests verify that Settings operations remain safe before initialization.
+
+// Verifies that uninitialized Settings calls return defaults and do not crash.
+TEST(SettingsUninitializedTest, OperationsAreNullSafeAndReturnDefaults)
+{
+ Settings::initialize(nullptr);
+
+ EXPECT_EQ(Settings::getUserSettings(), nullptr);
+ EXPECT_FALSE(Settings::containsKey("missing"));
+ EXPECT_EQ(Settings::getString("missing", "fallback"), "fallback");
+ EXPECT_EQ(Settings::getIntValue("missing", 17), 17);
+ EXPECT_DOUBLE_EQ(Settings::getDoubleValue("missing", 3.5), 3.5);
+ EXPECT_TRUE(Settings::getBoolValue("missing", true));
+
+ Settings::setValue("key", "value");
+ Settings::setValue("flag", true);
+ Settings::removeValue("key");
+ Settings::saveIfNeeded();
+
+ EXPECT_FALSE(Settings::containsKey("key"));
+}
+
+// -----------------------------------------------------------------------------
+// Category: Initialization behavior
+// -----------------------------------------------------------------------------
+// These tests verify that Settings correctly binds to the active properties store.
+
+// Verifies that initialization provides access to a user settings file.
+TEST_F(SettingsTest, InitializeProvidesUserSettings)
+{
+ EXPECT_NE(Settings::getUserSettings(), nullptr);
+}
+
+// Verifies that reinitializing Settings switches the active backing store.
+TEST_F(SettingsTest, ReinitializationSwitchesSettingsStore)
+{
+ Settings::setValue("key", String("first"));
+ EXPECT_EQ(Settings::getString("key"), "first");
+
+ File secondDirectory = File::getSpecialLocation(File::tempDirectory)
+ .getChildFile("HARP_Settings_Tests_Second_" + Uuid().toString());
+ ASSERT_TRUE(secondDirectory.createDirectory());
+
+ ApplicationProperties secondProperties;
+ PropertiesFile::Options options;
+ options.applicationName = "HARPSettingsTestSecond";
+ options.filenameSuffix = "settings";
+ options.folderName = secondDirectory.getFullPathName();
+ options.osxLibrarySubFolder = "Application Support";
+ options.storageFormat = PropertiesFile::storeAsXML;
+ secondProperties.setStorageParameters(options);
+
+ Settings::initialize(&secondProperties);
+
+ EXPECT_FALSE(Settings::containsKey("key"));
+ EXPECT_EQ(Settings::getString("key", "default"), "default");
+
+ secondProperties.closeFiles();
+ secondDirectory.deleteRecursively();
+}
+
+// -----------------------------------------------------------------------------
+// Category: String setting lifecycle
+// -----------------------------------------------------------------------------
+// These tests verify string storage, retrieval, replacement, and removal.
+
+// Verifies that string settings remain consistent across set, get, update, and remove.
+TEST_F(SettingsTest, StringSettingLifecycleIsConsistent)
+{
+ EXPECT_FALSE(Settings::containsKey("prompt"));
+ EXPECT_EQ(Settings::getString("prompt", "default"), "default");
+
+ Settings::setValue("prompt", String("hello"));
+
+ EXPECT_TRUE(Settings::containsKey("prompt"));
+ EXPECT_EQ(Settings::getString("prompt", "default"), "hello");
+
+ Settings::setValue("prompt", String("updated"));
+ EXPECT_EQ(Settings::getString("prompt", "default"), "updated");
+
+ Settings::removeValue("prompt");
+ EXPECT_FALSE(Settings::containsKey("prompt"));
+ EXPECT_EQ(Settings::getString("prompt", "default"), "default");
+}
+
+// Verifies that empty strings are treated as stored values rather than missing keys.
+TEST_F(SettingsTest, EmptyStringIsStoredAndDiscoverable)
+{
+ Settings::setValue("empty", String(""));
+
+ EXPECT_TRUE(Settings::containsKey("empty"));
+ EXPECT_EQ(Settings::getString("empty", "default"), "");
+}
+
+// Verifies that Unicode strings round-trip through the settings store.
+TEST_F(SettingsTest, UnicodeStringRoundTrips)
+{
+ const String value = CharPointer_UTF8("γγγ«γ‘γ― π΅");
+
+ Settings::setValue("unicode", value);
+
+ EXPECT_TRUE(Settings::containsKey("unicode"));
+ EXPECT_EQ(Settings::getString("unicode"), value);
+}
+
+// -----------------------------------------------------------------------------
+// Category: Boolean setting representation
+// -----------------------------------------------------------------------------
+// These tests verify HARP's explicit string representation for boolean settings.
+
+// Verifies that boolean settings are stored as the strings expected by Settings.h.
+TEST_F(SettingsTest, BooleanSettingLifecycleStoresStringRepresentation)
+{
+ Settings::setValue("enabled", true);
+
+ EXPECT_TRUE(Settings::containsKey("enabled"));
+ EXPECT_EQ(Settings::getString("enabled"), "true");
+
+ Settings::setValue("enabled", false);
+
+ EXPECT_EQ(Settings::getString("enabled"), "false");
+}
+
+// -----------------------------------------------------------------------------
+// Category: Numeric setting retrieval
+// -----------------------------------------------------------------------------
+// These tests verify default and stored-value behavior for numeric settings.
+
+// Verifies that integer settings return defaults when missing and stored values when present.
+TEST_F(SettingsTest, IntegerValuesReturnStoredValueOrDefault)
+{
+ EXPECT_EQ(Settings::getIntValue("count", 9), 9);
+
+ Settings::setValue("count", 42);
+ EXPECT_EQ(Settings::getIntValue("count", 9), 42);
+
+ Settings::setValue("count", -7);
+ EXPECT_EQ(Settings::getIntValue("count", 9), -7);
+}
+
+// Verifies that double settings return defaults when missing and stored values when present.
+TEST_F(SettingsTest, DoubleValuesReturnStoredValueOrDefault)
+{
+ EXPECT_DOUBLE_EQ(Settings::getDoubleValue("amount", 1.25), 1.25);
+
+ Settings::setValue("amount", 2.5);
+ EXPECT_DOUBLE_EQ(Settings::getDoubleValue("amount", 1.25), 2.5);
+
+ Settings::setValue("amount", -3.75);
+ EXPECT_DOUBLE_EQ(Settings::getDoubleValue("amount", 1.25), -3.75);
+}
+
+// -----------------------------------------------------------------------------
+// Category: Removal behavior
+// -----------------------------------------------------------------------------
+// These tests verify that remove operations are safe and isolated.
+
+// Verifies that removing a missing key does not disturb existing settings.
+TEST_F(SettingsTest, RemovingMissingKeyIsNoOp)
+{
+ Settings::setValue("present", String("value"));
+
+ Settings::removeValue("missing");
+
+ EXPECT_TRUE(Settings::containsKey("present"));
+ EXPECT_EQ(Settings::getString("present"), "value");
+}