Skip to content

Harden gameplay settings JS bridge and add comprehensive tests#488

Merged
ikostan merged 41 commits intomainfrom
settings-labels-display-unclamped-values
Mar 19, 2026
Merged

Harden gameplay settings JS bridge and add comprehensive tests#488
ikostan merged 41 commits intomainfrom
settings-labels-display-unclamped-values

Conversation

@ikostan
Copy link
Copy Markdown
Owner

@ikostan ikostan commented Mar 18, 2026


name: Default Pull Request Template
about: Suggesting changes to SkyLockAssault
title: ''
labels: ''
assignees: ''

Description

What does this PR do? (e.g., "Fixes player jump physics in level 2" or "Adds
new enemy AI script")

Description

This PR implements a comprehensive suite of unit tests and defensive programming patterns for the GameplaySettings module. The primary goal is to resolve Issue #471 (engine crashes during JavaScript-to-Godot communication) and to ensure the UI menu remains stable during complex lifecycle events and teardowns.

Key Changes & Bug Fixes

  • Hardened JS Bridge: Added stricter type, bounds, and payload validation for JavaScript-to-Godot communication. This prevents engine crashes from malformed, empty, or out-of-range JavaScriptBridge payloads.
  • Lifecycle & Teardown Safety: Ensured GameplaySettings UI and observers safely handle missing or invalid Globals settings, freed nodes, and unexpected tree exits without crashing.
  • UI Synchronization: Fixed the difficulty label and slider to accurately reflect clamped values from the settings resource, including when values are updated via JavaScript.
  • Signal Safety: Guarded against duplicate signal connections and unsafe disconnections by checking node validity before wiring or unwiring.
  • Web Overlay State: Improved teardown logic to consistently clear JS callbacks and web overlay states while seamlessly restoring previous menus when appropriate.
  • Type Flexibility: Relaxed the js_window reference type to a generic Variant to better support dictionary-based mocks and flexible JS interfaces.

Testing

  • Resource Tests: Added tests for GameSettingsResource clamping, signal emission behavior, and boundary values.
  • UI & Reactivity Tests: Verified that the UI safely synchronizes from the resource, handles slider changes, resets behavior, and propagates observer-driven updates without feedback loops.
  • JS Bridge Tests: Added coverage for valid payload shapes, malformed inputs, unsupported types, scalar regressions, and missing-node edge cases.
  • Lifecycle Tests: Verified that cleanup on exit properly disconnects observers, restores previous menus, tears down web overlays, and nullifies callbacks (even if Globals is shaky).

Contributions

  • @ikostan:
    • Authored the core refactoring logic to resolve Issue [BUG] Settings Labels Display Unclamped Values #471, implementing strict is_instance_valid lifecycle guards across the GameplaySettings module.
    • Designed and wrote the comprehensive GUT unit test suites covering the resource, UI reactivity, JS bridge, and lifecycle teardown paths.
    • Fixed the critical WebGL/Emscripten crash by correctly implementing standard dot notation (.length) for JavaScriptObject properties to avoid bridge evaluation errors.
    • Updated and verified E2E Playwright tests (difficulty_flow_test.py and reset_audio_flow_test.py) to align with the new value-normalization and DOM overlay logic.
    • Integrated and resolved feedback from automated review tools to ensure maximum stability and code quality.

AI & Bot Contributions

  • @sourcery-ai:

    • Conducted automated code reviews and identified an edge case in _on_change_difficulty_js where JavaScriptObject payloads could be improperly rejected, suggesting primitive extraction prior to type-checking.
    • Recommended hardening _unset_gameplay_settings_window_callbacks to handle generic Variant scenarios safely in non-web or mocked environments.
    • Suggested test suite improvements, including adding after_each() teardowns to prevent cross-test pollution and ensuring all JS callbacks (like _gameplay_reset_cb) are explicitly asserted as nullified during cleanup tests.
    • Generated detailed PR breakdowns categorizing changes by bug fixes, enhancements, and testing.
  • @coderabbitai:

    • Formatted and refined the pull request description structure.
    • Generated high-level automated summaries emphasizing defensive signal wiring, missing-node safety, and strict validation of external JS inputs.
    • Tracked chore-level updates, including the removal of inline citation comments to improve code readability.
  • @deepsource-io:

    • Conducted automated static analysis and code review.
    • Evaluated the pull request with an overall "A" grade, verifying that the new changes maintain high standards across security, reliability, complexity, and code hygiene.

Related Issue

Closes #ISSUE_NUMBER (if applicable)

Changes

  • List key changes here (e.g., "Updated Jump.gd to use Godot 4.4's new Tween
    system")
  • Any breaking changes? (e.g., "Deprecated old signal; migrate to new one")

Testing

  • Ran the game in Godot v4.5 editor—describe what you tested (e.g., "Jump
    works on Win10 with 60 FPS")
  • Any new unit tests added? (Link to test scene if yes)
  • Screenshots/GIFs if UI-related: (Attach below)

Checklist

  • Code follows Godot style guide (e.g., snake_case for variables)
  • No console errors in editor/output
  • Ready for review!

Additional Notes

Anything else? (e.g., "Tested on Win10 64-bit; needs Linux validation")

Summary by Sourcery

Harden GameplaySettings initialization, cleanup, and JS bridge handling while adding comprehensive tests for settings resource, UI synchronization, lifecycle behavior, and JavaScript communication.

Bug Fixes:

  • Ensure gameplay settings UI and observers safely handle missing or invalid Globals/settings, freed nodes, and unexpected tree exits without crashing.
  • Fix difficulty label and slider to always reflect clamped difficulty values from the settings resource, including values coming from JavaScript.
  • Prevent engine crashes and bad state from malformed or scalar JavaScriptBridge payloads by validating types, bounds, and node availability before applying changes.
  • Avoid duplicate signal connections and unsafe disconnections by guarding connections and checking node validity before connecting or disconnecting signals.

Enhancements:

  • Relax the type of the JS window reference to a generic Variant to support dictionary-based mocks and more flexible JS interfaces.
  • Improve gameplay settings teardown to consistently clear JS callbacks and web overlay state while restoring previous menus when appropriate.
  • Simplify and clarify Globals settings observer comments without changing behavior.

Tests:

  • Add unit tests for GameSettingsResource clamping, signal emission behavior, and default/boundary values.
  • Add initialization tests to verify GameplaySettings syncs its UI from the resource, connects observers once, and initializes safely in non-web environments.
  • Add UI interaction tests to confirm slider changes, reset behavior, and observer-driven updates propagate correctly without feedback loops.
  • Add JavaScriptBridge-focused tests covering valid payload shapes, malformed inputs, unsupported types, scalar regressions, and missing-node safety.
  • Add lifecycle tests ensuring cleanup on exit disconnects observers, restores previous menus, tears down web overlays, and nullifies callbacks even with shaky Globals.

Summary by CodeRabbit

  • Bug Fixes
    • Hardened gameplay settings UI: defensive signal wiring/teardown, safe handling when UI nodes are missing, and stricter validation/logging for external JS inputs (malformed, empty, out-of-range payloads).
  • Tests
    • Added comprehensive tests covering resource behavior, UI interactions, lifecycle/teardown, observer reactivity, and JS-bridge edge cases; updated an integration test's expected logs.
  • Chores
    • Removed inline citation comments for clarity.

ikostan added 4 commits March 17, 2026 19:32
…tialization #485

Description:

Implement the foundation of the gameplay settings test suite, focusing on the GameSettingsResource as the single source of truth and the initial menu setup.

Why is this useful?:

Ensures that difficulty clamping (0.5 to 2.0) and signal emissions work correctly before the UI layer is involved.

Proposed Implementation:

Create test_game_settings_resource.gd covering:

GS-RES-01 to 07: Validate clamping, boundary values (0.5, 1.0, 2.0), and redundant emission stability.
GS-READY-01 to 06: Confirm _ready() correctly syncs the slider and label to Globals.settings.difficulty and connects signals exactly once.
…vity #486

Description:
[cite_start]Develop tests for user-driven interactions and the menu's behavior as an observer of external resource changes[cite: 8, 75].

Why is this useful?:
[cite_start]Verifies that the UI stays in sync with the global state and that set_value_no_signal prevents infinite feedback loops between the slider and the resource[cite: 10, 11].

Proposed Implementation:

Create test_gameplay_settings_ui.gd covering:

GS-UI-01 to 06: User slider changes, Reset button functionality, and label updates[cite: 15, 20].
GS-OBS-01 to 05: Verify _on_external_setting_changed correctly updates the UI when the resource is modified by other scripts[cite: 10, 11].
Create test_gameplay_settings_js.gd covering:

GS-JS-01 to 05: Validating nested array shapes like [[1.5]].

GS-JS-10 to 25: Critical Defensive Tests for empty arrays [], non-numeric strings [["abc"]], and scalar values [1.5] to ensure no calls to .size() or .is_empty() occur on primitive types.

GS-JS-30 to 32: Missing node safety (e.g., if the slider is null during a JS callback).
…#471)

This PR implements a comprehensive suite of unit tests and defensive programming patterns for the GameplaySettings module. The primary goal was to resolve Issue #471, which involved engine crashes during JavaScript-to-Godot communication, and to ensure the menu remains stable during complex lifecycle events.
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Mar 18, 2026

Reviewer's Guide

Strengthens GameplaySettings’ resilience to invalid or late JS / lifecycle events by guarding node and Globals access, routing all difficulty updates through the settings resource (with clamping), relaxing js_window typing, and adds extensive GUT test suites for the settings resource, gameplay settings UI, JS bridge, and lifecycle/cleanup paths.

Sequence diagram for the new JS difficulty update and clamping flow

sequenceDiagram
    actor JS as JavaScript
    participant JSBridge as JavaScriptBridgeWrapper
    participant GS as GameplaySettings
    participant Globals
    participant Settings as GameSettingsResource
    participant Slider as DifficultySlider
    participant Label as DifficultyLabel

    JS->>JSBridge: invoke window.changeDifficulty(args)
    JSBridge->>GS: _on_change_difficulty_js(args)

    GS->>GS: validate args (empty, type, array/JavaScriptObject)
    GS->>GS: extract potential_value
    GS->>GS: ensure convertible (int/float/string)
    GS->>GS: reject non-numeric strings

    GS->>GS: value = float(potential_value)
    GS->>Slider: check min_value/max_value
    GS->>GS: log out-of-bounds but do not return

    GS->>Globals: access Globals.settings
    alt Globals.settings invalid
        GS->>Globals: log warning (settings unavailable)
        GS-->>JSBridge: return
    else settings available
        Globals-->>GS: settings_res
        GS->>Settings: set difficulty = value
        Settings-->>Settings: clamp difficulty in setter
        Settings-->>Globals: emit setting_changed("difficulty", clamped_value)
        Globals->>Globals: _on_setting_changed
        Globals->>Globals: log and persist setting
        Globals-->>Settings: done

        GS->>Slider: set value = settings_res.difficulty
        GS->>Label: set text = "{" + str(settings_res.difficulty) + "}"
        GS-->>JSBridge: return
    end
Loading

Updated class diagram for GameplaySettings, Globals, and GameSettingsResource

classDiagram

    class GameplaySettings {
        <<Control>>
        - JavaScriptBridgeWrapper js_bridge_wrapper
        - OSWrapper os_wrapper
        - Variant js_window
        - JavaScriptObject _change_difficulty_cb
        - JavaScriptObject _gameplay_back_button_pressed_cb
        - JavaScriptObject _gameplay_reset_cb
        - float _default_difficulty
        - DifficultySlider difficulty_slider
        - DifficultyLabel difficulty_label
        - Button gameplay_back_button
        - Button gameplay_reset_button
        + _ready() void
        + _on_external_setting_changed(setting_name String, new_value Variant) void
        + _on_tree_exited() void
        + _on_difficulty_value_changed(value float) void
        + _on_change_difficulty_js(args Array) void
        - _unset_gameplay_settings_window_callbacks() void
    }

    class Globals {
        <<Node>>
        + GameSettingsResource settings
        - bool _is_loading_settings
        + _ready() void
        + _load_settings() void
        + _save_settings() void
        + _on_setting_changed(setting_name String, new_value Variant) void
        + log_message(message String, level int) void
    }

    class GameSettingsResource {
        <<Resource>>
        + float difficulty
        + signal setting_changed(setting_name String, new_value Variant)
        + set_difficulty(value float) void
        + get_difficulty() float
    }

    class JavaScriptBridgeWrapper {
        <<Helper>>
        + connect_callbacks() void
        + disconnect_callbacks() void
    }

    class OSWrapper {
        <<Helper>>
        + has_feature(name String) bool
    }

    class DifficultySlider {
        <<Control>>
        + float value
        + float min_value
        + float max_value
        + signal value_changed(value float)
        + set_value_no_signal(value float) void
    }

    class DifficultyLabel {
        <<Label>>
        + String text
    }

    class Button {
        <<Button>>
        + signal pressed()
    }

    GameplaySettings ..> Globals : accesses
    Globals *-- GameSettingsResource : settings
    GameplaySettings ..> GameSettingsResource : reads_writes_difficulty
    GameplaySettings ..> JavaScriptBridgeWrapper : uses
    GameplaySettings ..> OSWrapper : uses
    GameplaySettings *-- DifficultySlider : owns
    GameplaySettings *-- DifficultyLabel : owns
    GameplaySettings *-- Button : owns back_button
    GameplaySettings *-- Button : owns reset_button
Loading

File-Level Changes

Change Details Files
Harden GameplaySettings initialization, observer wiring, and cleanup around missing or freed nodes and potentially-null Globals.settings.
  • Wrap access to Globals.settings in a local settings_res with is_instance_valid checks before use or signal connection.
  • Guard all signal connects in _ready() with is_connected checks to avoid duplicate connections on re-entry.
  • On _ready(), fall back to a default difficulty and label text when Globals or the settings resource is unavailable.
  • Guard tree_exited connection itself, only connecting if not already wired.
  • In _on_tree_exited, check node validity before disconnecting slider and button signals, and ensure the settings_res observer is disconnected when valid.
  • On cleanup, always unset JS window callbacks and null stored callback references.
scripts/gameplay_settings.gd
Rework difficulty update flow and JS bridge callback to rely on the settings resource for clamping and to robustly handle varied JS payload shapes.
  • Change js_window type from JavaScriptObject to Variant to support dictionary mocks and non-JSObject interfaces in tests.
  • In _on_difficulty_value_changed, resolve Globals.settings safely and route the slider value through settings_res.difficulty so clamping logic is centralized, then update slider and label from the resource value.
  • Extend _on_change_difficulty_js to validate empty args early, support nested arrays, JavaScriptObject proxies, and scalar payload formats, and to coerce numeric strings only when they are valid floats.
  • Add explicit type checks so only int/float/string values are accepted, reject non-numeric strings and unsupported types, and log detailed warnings instead of crashing.
  • Handle missing difficulty_slider in JS callbacks by updating only the resource when possible and skipping UI access.
  • Remove the early return on out-of-bounds JS difficulty values so the resource setter can clamp them instead of rejecting outright.
scripts/gameplay_settings.gd
Remove inline citation-style comments from globals and existing tests for clarity.
  • Strip trailing “[cite: …]” annotations from comments in globals.gd and several GUT tests while preserving the explanatory text.
scripts/globals.gd
test/gut/test_globals_resource.gd
test/gut/test_settings_observer.gd
Introduce a focused lifecycle and cleanup test suite for GameplaySettings to validate signal disconnection, callback nulling, back-button behavior, and web overlay teardown.
  • Add before_each setup that provisions a fresh GameSettingsResource, instantiates gameplay_settings.tscn, injects OSWrapper, and adds it to the tree.
  • Verify _on_tree_exited disconnects resource and slider signals and nulls all JS callbacks.
  • Test back-button behavior restores a previous hidden menu and queues the gameplay menu for deletion.
  • Add web-overlay cleanup tests that stub OS and JS bridge wrappers, using dictionary-based window mocks to allow property assignment and assert eval is called during teardown.
  • Cover null Globals / shaky state by creating a real JavaScriptObject callback and asserting it is nullified on cleanup.
  • Add regression test for unexpected removal where js_window is manually set to a mock so _on_tree_exited restores the previous menu and calls JS cleanup.
test/gut/test_gameplay_settings_lifecycle.gd
test/gut/test_gameplay_settings_lifecycle.gd.uid
Add a contract and initialization test suite for GameSettingsResource and its interaction with the GameplaySettings scene.
  • Validate that changing difficulty on GameSettingsResource emits setting_changed with correct parameters on valid updates.
  • Verify clamping behavior for values outside the configured [0.5, 2.0] range and acceptance of boundary values.
  • Check that redundant difficulty assignments do not re-emit setting_changed.
  • Assert that GameplaySettings UI initialized from the scene syncs slider and label to the resource difficulty on _ready.
  • Confirm settings_observer and slider signals are connected exactly once even across redundant _ready calls by counting connections.
  • Ensure non-web initialization path behaves safely, with a mocked OSWrapper returning false for has_feature, and that JS callbacks / js_window remain unset.
test/gut/test_game_settings_resource.gd
test/gut/test_game_settings_resource.gd.uid
Add JS-bridge–focused tests around the gameplay settings difficulty callback to cover payload shapes, malformed input, clamping, and missing-node safety.
  • Set up tests with a fresh GameSettingsResource and instantiated gameplay_settings.tscn using a real OSWrapper mock.
  • Verify nested array payloads like [[1.5]] correctly update Globals.settings.difficulty and support multiple sequential updates.
  • Confirm numeric strings inside nested arrays are coerced to float when valid and are clamped by the resource on out-of-range values.
  • Ensure empty arrays or arrays of empty arrays do not alter state and are safely ignored with no crash.
  • Reject malformed or whitespace-only strings, unsupported types (null, bool), and malformed scalar strings without modifying difficulty.
  • Verify scalar float payloads such as [1.5] are accepted and missing slider nodes during JS callbacks are handled via is_instance_valid checks without crashes.
test/gut/test_gameplay_settings_js.gd
test/gut/test_gameplay_settings_js.gd.uid
Add UI interaction and observer-reactivity tests to ensure GameplaySettings correctly syncs with GameSettingsResource and avoids feedback loops.
  • Set up each test with a fresh GameSettingsResource and instantiated gameplay_settings.tscn, injecting OSWrapper.
  • Verify that changing the slider via _on_difficulty_value_changed updates the resource and the difficulty label text.
  • Test that the reset button restores difficulty to 1.0 in both resource and UI.
  • Use GUT’s watch_signals and assert_signal_emit_count to ensure difficulty changes propagate exactly once and that external observer updates don’t cause recursive slider signals thanks to set_value_no_signal.
  • Ensure the observer only reacts to the “difficulty” key and ignores unrelated settings like sfx_volume.
test/gut/test_gameplay_settings_ui.gd
test/gut/test_gameplay_settings_ui.gd.uid

Assessment against linked issues

Issue Objective Addressed Explanation
#471 Update gameplay_settings.gd so that when the difficulty slider changes, the GameSettingsResource is updated first (allowing clamping), and the difficulty UI (slider and label) then displays the clamped value from the resource instead of the raw slider input.
#485 Add a GUT test suite for GameSettingsResource (test_game_settings_resource.gd) that validates difficulty clamping to [0.5, 2.0], correct handling of boundary values (0.5, 1.0, 2.0), and stability of signal emissions on redundant assignments (GS-RES-01 to 07).
#485 Add tests to verify GameplaySettings _ready() correctly initializes and syncs the difficulty slider and label from Globals.settings.difficulty and connects required signals (including the settings resource observer) exactly once, including guards against duplicate connections and safe initialization in non-web contexts (GS-READY-01 to 06).
#486 Add a GUT test suite (test_gameplay_settings_ui.gd) that covers GS-UI-01 to GS-UI-06, verifying user-driven slider changes, reset button behavior, and difficulty label updates for the GameplaySettings UI.
#486 Add GUT tests in test_gameplay_settings_ui.gd that cover GS-OBS-01 to GS-OBS-05, verifying that GameplaySettings observes the GameSettingsResource via _on_external_setting_changed, keeps the UI in sync with external resource changes, uses set_value_no_signal to avoid feedback loops, and ignores unrelated setting changes.
#487 Add a test_gameplay_settings_lifecycle.gd unit test suite that verifies GameplaySettings lifecycle behavior: signal disconnection and callback nullification (GS-LIFE-01 to 05), intentional vs. unexpected removal and restoration of Globals.hidden_menus (GS-LIFE-02 & 09), and web-specific teardown using js_bridge_wrapper.eval (GS-LIFE-07 & 08).
#487 Update gameplay_settings.gd lifecycle logic so that _on_tree_exited safely disconnects signals, nullifies _change_difficulty_cb, _gameplay_back_button_pressed_cb, _gameplay_reset_cb, restores previous menus via Globals.hidden_menus when appropriate, and performs web-specific cleanup compatible with the new lifecycle tests.

Possibly linked issues

  • #unknown: PR changes _on_difficulty_value_changed to set GameSettingsResource first, then update slider/label from clamped difficulty.
  • #unknown: PR implements test_game_settings_resource.gd plus _ready()/observer guards and tests matching GS-RES-01–07 and GS-READY-01–06.
  • #N/A: PR introduces test_gameplay_settings_lifecycle.gd and related web teardown tests, directly implementing the requested GS-LIFE scenarios.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Hardened gameplay_settings.gd: relaxed js_window type to Variant, defensive signal/resource/node checks, cached settings resource usage, robust JS difficulty parsing/coercion and clamping, defensive teardown; plus four new GUT test suites (resource, JS, UI, lifecycle) and small comment cleanups.

Changes

Cohort / File(s) Summary
Core Script Hardening
scripts/gameplay_settings.gd
Change js_window to Variant; cache settings_res; add guarded connect/disconnect and is_instance_valid checks; defensive UI-node checks; robust _extract_js_difficulty with strict numeric coercion; remove early-return on out-of-bounds to let setter handle clamping; clear JS callbacks on teardown.
GameSettings Resource Tests
test/gut/test_game_settings_resource.gd, test/gut/test_game_settings_resource.gd.uid
New GUT suite validating GameSettingsResource signal emission, clamping behavior, boundary acceptance, suppression of redundant assignments, and menu init behavior including non-web OSWrapper handling.
JS Bridge Regression Tests
test/gut/test_gameplay_settings_js.gd, test/gut/test_gameplay_settings_js.gd.uid
New tests exercising _on_change_difficulty_js for nested arrays, array-like JS objects, numeric-string coercion, scalar payloads, clamping, malformed inputs, and missing-UI safety (updates resource even if slider freed).
Lifecycle / Teardown Tests
test/gut/test_gameplay_settings_lifecycle.gd, test/gut/test_gameplay_settings_lifecycle.gd.uid
New lifecycle tests for _on_tree_exited cleanup (signal disconnects, callback nullification), back-button restoration and queue_free semantics, web-overlay JS eval on exit, and null-Globals safety.
UI Interaction & Observer Tests
test/gut/test_gameplay_settings_ui.gd, test/gut/test_gameplay_settings_ui.gd.uid
New UI tests: slider→resource updates, label sync, reset behavior, single-emission guarantees, observer-driven UI updates via set_value_no_signal, and filtering unrelated setting signals.
Minor Comment Cleanup
scripts/globals.gd, test/gut/test_globals_resource.gd, test/gut/test_settings_observer.gd
Removed inline citation markers/comments only; no behavioral changes.

Sequence Diagram(s)

sequenceDiagram
  participant JS as JavaScriptBridge
  participant OS as OSWrapper
  participant GS as GameSettingsResource
  participant UI as GameplaySettings_UI
  participant Node as gameplay_settings.gd

  JS->>Node: _on_change_difficulty_js(args)
  Node->>Node: _extract_js_difficulty(args) / validate numeric
  alt valid numeric extracted
    Node->>GS: set difficulty via cached settings_res (clamped)
    GS-->>Node: emit setting_changed("difficulty", new_value)
    Node->>UI: set_value_no_signal(new_value)
    UI-->>Node: update difficulty_label
  else invalid/malformed
    Node->>JS: log warning / ignore (no crash)
  end

  Note over Node,GS: On tree exit / teardown
  Node->>GS: disconnect setting_changed (if valid)
  Node->>UI: disconnect slider signals (if valid)
  Node->>JS: unset callbacks / call eval on JS bridge (if present)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

A rabbit nibbles at tangled threads,
Unhooks callbacks, tucks away old webs,
Sliders hum true, tests clap their paws,
Cleanup neat — no dangling claws, 🐇
Soft hops, safe code, and tidy debs.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main objectives: hardening the JS bridge for robustness and adding comprehensive test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering objectives, key changes, testing additions, contributions, and related issues.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch settings-labels-display-unclamped-values
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ikostan ikostan linked an issue Mar 18, 2026 that may be closed by this pull request
@ikostan ikostan added bug Something isn't working enhancement New feature or request web testing menu GUI labels Mar 18, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In _on_change_difficulty_js, the branch that treats first_arg as a container still calls .size() and indexes [0] on values that may be JavaScriptObjects; if the regression you’re fixing was around calling array APIs on scalars, consider restricting the container path to first_arg is Array and handling JavaScriptObject via a safer accessor (e.g., known property) to avoid future engine-level crashes.
  • When the difficulty slider node is invalid in _on_change_difficulty_js, you fall back to writing Globals.settings.difficulty without checking that Globals/Globals.settings are valid; mirroring the guards you added in _on_tree_exited would make this callback more robust to teardown timing issues.
  • Several tests use assert_true(true, ...) as the only assertion (e.g., scalar safety, malformed input, cleanup safety); you could make these more precise by asserting on observable side effects such as unchanged Globals.settings.difficulty, absence of extra signal emissions, or specific mocked method calls instead of relying on “no crash” semantics.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_on_change_difficulty_js`, the branch that treats `first_arg` as a container still calls `.size()` and indexes `[0]` on values that may be `JavaScriptObject`s; if the regression you’re fixing was around calling array APIs on scalars, consider restricting the container path to `first_arg is Array` and handling `JavaScriptObject` via a safer accessor (e.g., known property) to avoid future engine-level crashes.
- When the difficulty slider node is invalid in `_on_change_difficulty_js`, you fall back to writing `Globals.settings.difficulty` without checking that `Globals`/`Globals.settings` are valid; mirroring the guards you added in `_on_tree_exited` would make this callback more robust to teardown timing issues.
- Several tests use `assert_true(true, ...)` as the only assertion (e.g., scalar safety, malformed input, cleanup safety); you could make these more precise by asserting on observable side effects such as unchanged `Globals.settings.difficulty`, absence of extra signal emissions, or specific mocked method calls instead of relying on “no crash” semantics.

## Individual Comments

### Comment 1
<location path="scripts/gameplay_settings.gd" line_range="303-305" />
<code_context>
 	var first_arg: Variant = args[0]
+	var potential_value: Variant = null
+
+	# GS-JS-20/21: Strict Type Check 
+	# Verify first_arg is a container before calling .size() or index [0]
+	if typeof(first_arg) == TYPE_ARRAY or first_arg is JavaScriptObject:
+		# GS-JS-11: Guard against nested empty arrays [[]]
+		if first_arg.size() > 0:
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid calling `.size()` on `JavaScriptObject`, which doesn’t guarantee that method exists.

In the branch below, `first_arg.size()` is still called when `first_arg is JavaScriptObject`. Since `JavaScriptObject` doesn’t guarantee `size()` or index access, this is a potential runtime error. Please branch the logic so that `.size()` and indexing are only used when `typeof(first_arg) == TYPE_ARRAY`, and handle `JavaScriptObject` via a separate path (e.g., converting to an array or using a defined API) before accessing its contents.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/gameplay_settings.gd Outdated
@deepsource-io
Copy link
Copy Markdown

deepsource-io Bot commented Mar 18, 2026

DeepSource Code Review

We reviewed changes in 164a2b8...976147a on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

Important

Some issues found as part of this review are outside of the diff in this pull request and aren't shown in the inline review comments due to GitHub's API limitations. You can see those issues on the DeepSource dashboard.

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Python Mar 19, 2026 5:25a.m. Review ↗
JavaScript Mar 19, 2026 5:25a.m. Review ↗

ikostan added 8 commits March 17, 2026 20:24
…esn’t guarantee that method exists.

In the branch below, first_arg.size() is still called when first_arg is JavaScriptObject. Since JavaScriptObject doesn’t guarantee size() or index access, this is a potential runtime error. Please branch the logic so that .size() and indexing are only used when typeof(first_arg) == TYPE_ARRAY, and handle JavaScriptObject via a separate path (e.g., converting to an array or using a defined API) before accessing its contents.
In _on_change_difficulty_js, the branch that treats first_arg as a container still calls .size() and indexes [0] on values that may be JavaScriptObjects; if the regression you’re fixing was around calling array APIs on scalars, consider restricting the container path to first_arg is Array and handling JavaScriptObject via a safer accessor (e.g., known property) to avoid future engine-level crashes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
test/gut/test_gameplay_settings_lifecycle.gd (2)

81-90: ⚠️ Potential issue | 🟠 Major

create_callback can make this cleanup test vacuous off-web.

At Line 84, JavaScriptBridge.create_callback(...) may be null on non-web, so the final assert_null can pass without validating any cleanup transition.

In Godot 4.x, what does `JavaScriptBridge.create_callback(callable)` return on non-Web platforms?
Safer test shape
 func test_gs_life_05_null_globals_safety() -> void:
-	# FIX: Wrap the lambda in a JavaScriptObject callback
+	if OS.get_name() != "Web":
+		pending("Web-only behavior: JavaScriptBridge.create_callback is not meaningful off-web.")
+		return
+
 	var dummy_callable := func(_args: Array) -> void: pass
 	gameplay_menu._change_difficulty_cb = JavaScriptBridge.create_callback(dummy_callable)
+	assert_not_null(gameplay_menu._change_difficulty_cb, "Precondition: callback should be initialized")
 	
 	# Act: Call the cleanup function directly
 	gameplay_menu._on_tree_exited()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/gut/test_gameplay_settings_lifecycle.gd` around lines 81 - 90, The test
assigns gameplay_menu._change_difficulty_cb using
JavaScriptBridge.create_callback(dummy_callable), but on non-web platforms
create_callback may return null making the assert vacuous; update
test_gs_life_05_null_globals_safety to ensure _change_difficulty_cb is a real
JavaScriptObject before calling gameplay_menu._on_tree_exited(): call
JavaScriptBridge.create_callback(dummy_callable) and if it returns null,
construct or stub a JavaScriptObject fallback (or otherwise wrap dummy_callable
in a JS callback object) and assign that to gameplay_menu._change_difficulty_cb
so the subsequent cleanup actually nullifies a non-null callback.

99-135: ⚠️ Potential issue | 🟡 Minor

Make mock_prev cleanup failure-safe in this test too.

This mirrors the earlier cleanup concern: if an assertion fails before Line 135, manual free() is skipped and global test state can remain dirty.

Safer cleanup pattern
 	var mock_prev: Control = Control.new()
+	autofree(mock_prev)
 	mock_prev.name = "MockOptionsMenu"
 	mock_prev.visible = false
 	Globals.hidden_menus.push_back(mock_prev)
@@
 	assert_true(mock_prev.visible, "Unexpected exit must restore previous menu visibility")
 	assert_null(test_menu._change_difficulty_cb, "Callbacks must still be nullified on unexpected exit")
 	assert_called(mock_js_bridge, "eval")
-	
-	# Cleanup mock menu
-	mock_prev.free()
+	assert_false(Globals.hidden_menus.has(mock_prev), "Restored menu should be popped from hidden stack")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/gut/test_gameplay_settings_lifecycle.gd` around lines 99 - 135, Make the
mock_prev cleanup failure-safe by ensuring it is always removed from
Globals.hidden_menus and freed even if assertions fail: after creating mock_prev
add it to a test-local cleanup step (or group) and in your test teardown always
check if mock_prev is still in Globals.hidden_menus and remove it, then free it
(use is_instance_valid(mock_prev) before calling mock_prev.free() or
call_deferred("free") for safety); refer to the symbols mock_prev,
Globals.hidden_menus, mock_prev.free(), and test_menu when implementing the
teardown/cleanup so the global state is never left dirty if the test aborts
early.
🧹 Nitpick comments (1)
test/gut/test_gameplay_settings_lifecycle.gd (1)

30-35: Also assert back/reset button signal disconnections here.

_on_tree_exited() disconnects three local UI signals, but this test currently verifies only the slider one. Add assertions for gameplay_back_button.pressed and gameplay_reset_button.pressed to prevent silent regressions.

Suggested assertion additions
 	assert_false(gameplay_menu.difficulty_slider.value_changed.is_connected(gameplay_menu._on_difficulty_value_changed), 
 		"Local UI signals should be disconnected")
+	assert_false(gameplay_menu.gameplay_back_button.pressed.is_connected(gameplay_menu._on_gameplay_back_button_pressed),
+		"Back button signal should be disconnected")
+	assert_false(gameplay_menu.gameplay_reset_button.pressed.is_connected(gameplay_menu._on_gameplay_reset_button_pressed),
+		"Reset button signal should be disconnected")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/gut/test_gameplay_settings_lifecycle.gd` around lines 30 - 35, The test
is missing assertions that the back and reset button signals were disconnected
after _on_tree_exited(); update the assertions block to also
assert_false(gameplay_menu.gameplay_back_button.pressed.is_connected(gameplay_menu._on_back_pressed))
and
assert_false(gameplay_menu.gameplay_reset_button.pressed.is_connected(gameplay_menu._on_reset_pressed))
(alongside the existing slider and global signal checks) so the three local UI
signals disconnected in _on_tree_exited() are all covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/gut/test_gameplay_settings_js.gd`:
- Around line 93-99: Replace the no-op assertion in
test_gs_js_30_missing_node_safety with a concrete check that the fallback
resource update occurred after freeing difficulty_slider; call
gameplay_menu._on_change_difficulty_js([[1.2]]) and then assert the expected
state change on the object that should be updated by the fallback (for example
check gameplay_menu.current_difficulty or gameplay_menu.resource.difficulty
equals the expected value), referencing the test function name
test_gs_js_30_missing_node_safety, the freed member difficulty_slider, and the
handler gameplay_menu._on_change_difficulty_js to locate where to assert the
fallback behavior.

---

Duplicate comments:
In `@test/gut/test_gameplay_settings_lifecycle.gd`:
- Around line 81-90: The test assigns gameplay_menu._change_difficulty_cb using
JavaScriptBridge.create_callback(dummy_callable), but on non-web platforms
create_callback may return null making the assert vacuous; update
test_gs_life_05_null_globals_safety to ensure _change_difficulty_cb is a real
JavaScriptObject before calling gameplay_menu._on_tree_exited(): call
JavaScriptBridge.create_callback(dummy_callable) and if it returns null,
construct or stub a JavaScriptObject fallback (or otherwise wrap dummy_callable
in a JS callback object) and assign that to gameplay_menu._change_difficulty_cb
so the subsequent cleanup actually nullifies a non-null callback.
- Around line 99-135: Make the mock_prev cleanup failure-safe by ensuring it is
always removed from Globals.hidden_menus and freed even if assertions fail:
after creating mock_prev add it to a test-local cleanup step (or group) and in
your test teardown always check if mock_prev is still in Globals.hidden_menus
and remove it, then free it (use is_instance_valid(mock_prev) before calling
mock_prev.free() or call_deferred("free") for safety); refer to the symbols
mock_prev, Globals.hidden_menus, mock_prev.free(), and test_menu when
implementing the teardown/cleanup so the global state is never left dirty if the
test aborts early.

---

Nitpick comments:
In `@test/gut/test_gameplay_settings_lifecycle.gd`:
- Around line 30-35: The test is missing assertions that the back and reset
button signals were disconnected after _on_tree_exited(); update the assertions
block to also
assert_false(gameplay_menu.gameplay_back_button.pressed.is_connected(gameplay_menu._on_back_pressed))
and
assert_false(gameplay_menu.gameplay_reset_button.pressed.is_connected(gameplay_menu._on_reset_pressed))
(alongside the existing slider and global signal checks) so the three local UI
signals disconnected in _on_tree_exited() are all covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 017a0606-bb31-4fd5-a3f0-ce2b85ff132e

📥 Commits

Reviewing files that changed from the base of the PR and between 36260eb and 6c84171.

📒 Files selected for processing (6)
  • scripts/globals.gd
  • test/gut/test_gameplay_settings_js.gd
  • test/gut/test_gameplay_settings_lifecycle.gd
  • test/gut/test_gameplay_settings_ui.gd
  • test/gut/test_globals_resource.gd
  • test/gut/test_settings_observer.gd
✅ Files skipped from review due to trivial changes (3)
  • test/gut/test_settings_observer.gd
  • test/gut/test_globals_resource.gd
  • scripts/globals.gd
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/gut/test_gameplay_settings_ui.gd
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Browser Functional Tests / test
🔇 Additional comments (1)
test/gut/test_gameplay_settings_js.gd (1)

25-88: Strong, behavior-driven coverage for JS payload handling.

These tests verify real state transitions (accepted values, rejection paths, clamping), not just execution flow. Nice improvement for regression safety.

Comment thread test/gut/test_gameplay_settings_js.gd Outdated
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In several places (e.g. _ready() and _on_difficulty_value_changed) you compute a settings_res local but still read from Globals.settings inside the guarded block; for consistency and to avoid any future race with Globals becoming invalid, consider using the local reference exclusively once it’s established.
  • The _on_change_difficulty_js handler has become quite branch-heavy (array vs JavaScriptObject vs scalar, plus type/bounds checks); extracting the value-normalization into a small helper (e.g. _extract_js_difficulty(args: Array) -> Variant) would make the main callback easier to follow and reason about.
  • You now warn on any out-of-bounds JS difficulty, even though the value is then clamped by the resource; if out-of-range inputs are expected, consider downgrading this to debug or only warning when the value is wildly outside the allowed range to avoid noisy logs.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In several places (e.g. `_ready()` and `_on_difficulty_value_changed`) you compute a `settings_res` local but still read from `Globals.settings` inside the guarded block; for consistency and to avoid any future race with `Globals` becoming invalid, consider using the local reference exclusively once it’s established.
- The `_on_change_difficulty_js` handler has become quite branch-heavy (array vs `JavaScriptObject` vs scalar, plus type/bounds checks); extracting the value-normalization into a small helper (e.g. `_extract_js_difficulty(args: Array) -> Variant`) would make the main callback easier to follow and reason about.
- You now warn on any out-of-bounds JS difficulty, even though the value is then clamped by the resource; if out-of-range inputs are expected, consider downgrading this to debug or only warning when the value is wildly outside the allowed range to avoid noisy logs.

## Individual Comments

### Comment 1
<location path="scripts/gameplay_settings.gd" line_range="334-340" />
<code_context>
+		else:
+			Globals.log_message("JS callback: Array is empty.", Globals.LogLevel.WARNING)
+			return
+	elif first_arg is JavaScriptObject:
+		# For JavaScriptObject, treat it as a proxy to a JS array
+		# Use the specific JS indexing if you are certain it is a JS array,
+		# or handle it as a single-value reference.
+		# JS-FIX: If we receive a JS Object (like from Playwright),
+		# we must index it to get the raw value before the type check.
+		if first_arg.length > 0:
+			potential_value = first_arg[0]
+		else:
</code_context>
<issue_to_address>
**issue (bug_risk):** Assuming `.length` and numeric indexing on any `JavaScriptObject` can be fragile.

Here we assume every `JavaScriptObject` is an array-like proxy and use `length` and `[0]`. If a plain object is passed (e.g. `{ value: 3 }`), `length` may be missing or non-numeric and indexing can misbehave. Please either validate that the object is array-like before using `length`/indexing (e.g. check for a numeric `length`), or handle generic `JavaScriptObject` values as scalars instead to avoid runtime issues.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/gameplay_settings.gd Outdated
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 19, 2026

@sourcery-ai title

@sourcery-ai sourcery-ai Bot changed the title Settings labels display unclamped values Harden gameplay settings JS bridge and add comprehensive tests Mar 19, 2026
ikostan added 9 commits March 18, 2026 20:48
Line 99 (assert_true(true, ...)) can’t detect regressions. This path has a defined fallback behavior (resource update when slider is invalid), so assert that explicitly.
…riptObject can be fragile.

Here we assume every JavaScriptObject is an array-like proxy and use length and [0]. If a plain object is passed (e.g. { value: 3 }), length may be missing or non-numeric and indexing can misbehave. Please either validate that the object is array-like before using length/indexing (e.g. check for a numeric length), or handle generic JavaScriptObject values as scalars instead to avoid runtime issues.
In several places (e.g. _ready() and _on_difficulty_value_changed) you compute a settings_res local but still read from Globals.settings inside the guarded block; for consistency and to avoid any future race with Globals becoming invalid, consider using the local reference exclusively once it’s established.
The _on_change_difficulty_js handler has become quite branch-heavy (array vs JavaScriptObject vs scalar, plus type/bounds checks); extracting the value-normalization into a small helper (e.g. _extract_js_difficulty(args: Array) -> Variant) would make the main callback easier to follow and reason about.
By switching to .length, Godot correctly reads the length property of the JS Array [2.0], sees that it is 1, passes the js_length > 0 condition, safely extracts the float 2.0 via first_arg[0], and proceeds to successfully log: "JS difficulty callback called with valid value: 2.0".
@ikostan
Copy link
Copy Markdown
Owner Author

ikostan commented Mar 19, 2026

Hey - I've found 1 issue, and left some high level feedback:

  • In several places (e.g. _ready() and _on_difficulty_value_changed) you compute a settings_res local but still read from Globals.settings inside the guarded block; for consistency and to avoid any future race with Globals becoming invalid, consider using the local reference exclusively once it’s established.
  • The _on_change_difficulty_js handler has become quite branch-heavy (array vs JavaScriptObject vs scalar, plus type/bounds checks); extracting the value-normalization into a small helper (e.g. _extract_js_difficulty(args: Array) -> Variant) would make the main callback easier to follow and reason about.
  • You now warn on any out-of-bounds JS difficulty, even though the value is then clamped by the resource; if out-of-range inputs are expected, consider downgrading this to debug or only warning when the value is wildly outside the allowed range to avoid noisy logs.

Prompt for AI Agents

Please address the comments from this code review:

## Overall Comments
- In several places (e.g. `_ready()` and `_on_difficulty_value_changed`) you compute a `settings_res` local but still read from `Globals.settings` inside the guarded block; for consistency and to avoid any future race with `Globals` becoming invalid, consider using the local reference exclusively once it’s established.
- The `_on_change_difficulty_js` handler has become quite branch-heavy (array vs `JavaScriptObject` vs scalar, plus type/bounds checks); extracting the value-normalization into a small helper (e.g. `_extract_js_difficulty(args: Array) -> Variant`) would make the main callback easier to follow and reason about.
- You now warn on any out-of-bounds JS difficulty, even though the value is then clamped by the resource; if out-of-range inputs are expected, consider downgrading this to debug or only warning when the value is wildly outside the allowed range to avoid noisy logs.

## Individual Comments

### Comment 1
<location path="scripts/gameplay_settings.gd" line_range="334-340" />
<code_context>
+		else:
+			Globals.log_message("JS callback: Array is empty.", Globals.LogLevel.WARNING)
+			return
+	elif first_arg is JavaScriptObject:
+		# For JavaScriptObject, treat it as a proxy to a JS array
+		# Use the specific JS indexing if you are certain it is a JS array,
+		# or handle it as a single-value reference.
+		# JS-FIX: If we receive a JS Object (like from Playwright),
+		# we must index it to get the raw value before the type check.
+		if first_arg.length > 0:
+			potential_value = first_arg[0]
+		else:
</code_context>
<issue_to_address>
**issue (bug_risk):** Assuming `.length` and numeric indexing on any `JavaScriptObject` can be fragile.

Here we assume every `JavaScriptObject` is an array-like proxy and use `length` and `[0]`. If a plain object is passed (e.g. `{ value: 3 }`), `length` may be missing or non-numeric and indexing can misbehave. Please either validate that the object is array-like before using `length`/indexing (e.g. check for a numeric `length`), or handle generic `JavaScriptObject` values as scalars instead to avoid runtime issues.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

All addressed.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/gameplay_settings.gd (1)

151-188: ⚠️ Potential issue | 🟠 Major

Decouple unexpected menu restoration from the web bridge.

Line 152 currently gates the Globals.hidden_menus restore path behind os_wrapper.has_feature("web") and js_window and js_bridge_wrapper. On native builds — or on web when js_window setup fails — an unexpected removal leaves the previous menu hidden and the stack stale.

Suggested fix
-	# Web overlay cleanup + optional menu restore
-	if os_wrapper.has_feature("web") and js_window and js_bridge_wrapper:
+	var hidden_menu_found := false
+	if not _intentional_exit and not Globals.hidden_menus.is_empty():
+		var prev_menu: Node = Globals.hidden_menus.pop_back()
+		if is_instance_valid(prev_menu):
+			prev_menu.visible = true
+			hidden_menu_found = true
+			Globals.log_message(
+				"tree_exited: Restored menu: " + prev_menu.name, Globals.LogLevel.DEBUG
+			)
+
+	# Web overlay cleanup
+	if os_wrapper.has_feature("web") and js_window and js_bridge_wrapper:
 		# Hide gameplay overlays (same DOM elements shown in _ready)
 		var hide_gameplay: String = """
 			document.getElementById('difficulty-slider').style.display = 'none';
 			document.getElementById('gameplay-back-button').style.display = 'none';
 			document.getElementById('gameplay-reset-button').style.display = 'none';
 			"""
 
-		if not _intentional_exit and not Globals.hidden_menus.is_empty():
-			# Unexpected exit → restore previous menu and options overlays
-			var prev_menu: Node = Globals.hidden_menus.pop_back()
-			if is_instance_valid(prev_menu):
-				prev_menu.visible = true
-				Globals.log_message(
-					"tree_exited: Restored menu: " + prev_menu.name, Globals.LogLevel.DEBUG
-				)
-
+		if hidden_menu_found:
+			# Unexpected exit → restore previous menu and options overlays
 			(
 				js_bridge_wrapper
 				. eval(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/gameplay_settings.gd` around lines 151 - 188, The restore of
Globals.hidden_menus is currently nested inside the web-bridge check
(os_wrapper.has_feature("web") and js_window and js_bridge_wrapper) which
prevents restoring the previous menu when the web bridge or js_window is absent;
refactor so that the logic that pops and restores Globals.hidden_menus (check
_intentional_exit, pop_back into prev_menu, is_instance_valid(prev_menu), set
prev_menu.visible = true and Globals.log_message) runs unconditionally (or at
least outside the js_bridge_wrapper block), while keeping only the DOM JS eval
calls (js_bridge_wrapper.eval(hide_gameplay, true) and the options-show script)
behind the js_bridge_wrapper/js_window/web feature checks; use the existing
symbols Globals.hidden_menus, _intentional_exit, prev_menu,
js_bridge_wrapper.eval, js_window and os_wrapper.has_feature to locate and
separate the restore logic from the web-only eval calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/gameplay_settings.gd`:
- Around line 292-304: The handler currently updates UI controls unconditionally
after validating settings_res, which can access freed nodes (difficulty_slider
and difficulty_label) during teardown; change the code to check the UI nodes
before using them (e.g., wrap the assignments to difficulty_slider.value and
difficulty_label.text in is_instance_valid(difficulty_slider) /
is_instance_valid(difficulty_label) or nil checks) so the handler safely skips
UI sync when those controls are freed while still using the validated local
settings_res reference.

In `@tests/difficulty_flow_test.py`:
- Around line 193-197: The current assertion in tests/difficulty_flow_test.py
uses a substring "setting 'difficulty' updated to: 1" which can match values
like 1.5; update the check to require "setting 'difficulty' updated to: 1.0" or,
more robustly, parse the numeric suffix from each log["text"].lower() and assert
float(parsed_value) == 1.0 so that the reset_logs check only passes when the
emitted difficulty equals 1.0 (referencing the existing reset_logs variable and
the log text pattern "setting 'difficulty' updated to: ...").

---

Outside diff comments:
In `@scripts/gameplay_settings.gd`:
- Around line 151-188: The restore of Globals.hidden_menus is currently nested
inside the web-bridge check (os_wrapper.has_feature("web") and js_window and
js_bridge_wrapper) which prevents restoring the previous menu when the web
bridge or js_window is absent; refactor so that the logic that pops and restores
Globals.hidden_menus (check _intentional_exit, pop_back into prev_menu,
is_instance_valid(prev_menu), set prev_menu.visible = true and
Globals.log_message) runs unconditionally (or at least outside the
js_bridge_wrapper block), while keeping only the DOM JS eval calls
(js_bridge_wrapper.eval(hide_gameplay, true) and the options-show script) behind
the js_bridge_wrapper/js_window/web feature checks; use the existing symbols
Globals.hidden_menus, _intentional_exit, prev_menu, js_bridge_wrapper.eval,
js_window and os_wrapper.has_feature to locate and separate the restore logic
from the web-only eval calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3c682588-2f15-4d87-a781-5c63d2759ab3

📥 Commits

Reviewing files that changed from the base of the PR and between 6c84171 and 188120d.

📒 Files selected for processing (3)
  • scripts/gameplay_settings.gd
  • test/gut/test_gameplay_settings_js.gd
  • tests/difficulty_flow_test.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/gut/test_gameplay_settings_js.gd
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Browser Functional Tests / test
🔇 Additional comments (1)
scripts/gameplay_settings.gd (1)

373-409: Nice containment of the JS payload parsing.

Pulling the shape handling into _extract_js_difficulty() keeps _on_change_difficulty_js() linear and makes the array-vs-JavaScriptObject cases much easier to reason about and test.

Comment thread scripts/gameplay_settings.gd Outdated
Comment thread tests/difficulty_flow_test.py
ikostan and others added 5 commits March 18, 2026 21:45
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
 The substring "updated to: 1" would still match log lines like "setting 'difficulty' updated to: 1.5". A safer approach using a Python regex would address that without breaking on GDScript's string representation:
This commit fixes the style issues introduced in 6562911 according to the output
from Black and isort.

Details: #488
The lookahead part of your regex (?![\d.]) specifically tells Python: "Find a 1, but fail if it is followed by a digit or a dot." Because Godot is dealing with a float, it is logging the reset value as 1.0. The regex sees the . after the 1 and intentionally rejects the match, causing your test to fail even though the game logic worked perfectly!
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/gameplay_settings.gd (1)

145-188: ⚠️ Potential issue | 🟠 Major

Don't gate overlay teardown on js_window.

_ready() shows the gameplay overlays before get_interface("window") succeeds. If js_window is null or already cleared, this branch skips the eval(...) hide path and strands those overlays on screen after teardown. Keep callback nulling behind js_window, but drive the DOM cleanup off os_wrapper.has_feature("web") and js_bridge_wrapper.

Suggested fix
-	if os_wrapper.has_feature("web") and js_window and js_bridge_wrapper:
+	if os_wrapper.has_feature("web") and js_bridge_wrapper:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/gameplay_settings.gd` around lines 145 - 188, The DOM teardown is
incorrectly gated on js_window so overlays can be left visible; keep nulling
callbacks (_change_difficulty_cb, _gameplay_back_button_pressed_cb,
_gameplay_reset_cb and call to _unset_gameplay_settings_window_callbacks()) only
when js_window is present, but drive the overlay cleanup when
os_wrapper.has_feature("web") and js_bridge_wrapper are available (i.e., remove
js_window from the outer conditional that controls the hide/eval path); ensure
both the simple js_bridge_wrapper.eval(hide_gameplay, true) and the restore path
that calls js_bridge_wrapper.eval(...) with the options-show + hide_gameplay
payload still run when js_bridge_wrapper exists so overlays are always hidden
even if js_window is null.
♻️ Duplicate comments (1)
tests/difficulty_flow_test.py (1)

176-179: ⚠️ Potential issue | 🟡 Minor

Make the JS difficulty log assertion tolerant of Godot float formatting.

scripts/gameplay_settings.gd Lines 367-368 also log str(value), but this assertion hard-codes 2.0 while the reset assertion below already assumes whole-number floats may be rendered without .0. If the engine logs 2 here, the bridge works and the test still fails.

Suggested fix
        assert any(
-            "js difficulty callback called with valid value: 2.0" in log["text"].lower()
+            re.search(
+                r"js difficulty callback called with valid value:\s*2(?:\.0+)?(?![\d.])",
+                log["text"].lower(),
+            )
             for log in new_logs
        ), "Failed to extract/validate difficulty 2.0 from JS payload"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/difficulty_flow_test.py` around lines 176 - 179, The assertion checking
for the JS difficulty log is too strict about the float formatting; update the
assertion that inspects new_logs (the comprehension using log["text"].lower())
to accept either "2.0" or "2" (or normalize with a regex/float parse) when
looking for the substring like "js difficulty callback called with valid value:"
so the test is tolerant of Godot printing whole-number floats without the .0;
modify the condition in the failing assertion to match both representations (or
parse the numeric token after the message and compare numerically) to ensure the
bridge validation still passes.
🧹 Nitpick comments (1)
tests/difficulty_flow_test.py (1)

169-200: Add one direct settings-screen state assertion.

These checks prove the callback fired and gameplay eventually uses the value, but they still won't catch the known label/slider desync for unclamped inputs because the test never verifies what the menu displays after changeDifficulty(...) or reset. Please cover that in this flow or in the dedicated GUT UI suite.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/difficulty_flow_test.py` around lines 169 - 200, Add assertions that
directly verify the settings UI state (label/slider) after calling
window.changeDifficulty([2.0]) and after calling
window.gameplayResetPressed([]): locate the on-page elements that display the
difficulty value (e.g., the difficulty label text and the difficulty
input/slider element) using the same page object used in this test, wait for
them to be present after each evaluate call, and assert their displayed
value/text matches the expected values (e.g., "2.0" after changeDifficulty and
"1.0" after reset); reference the existing uses of
page.evaluate("window.changeDifficulty([2.0])"),
page.evaluate("window.gameplayResetPressed([])"), and the logs/reset_logs to add
these UI assertions immediately after the corresponding wait_for_timeout blocks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/gameplay_settings.gd`:
- Line 123: Guard every dereference of the Globals singleton in
_on_tree_exited() by first checking that Globals is not null/undefined before
calling its methods or accessing its properties; specifically, wrap the
Globals.log_message call in _on_tree_exited() and the hidden_menus restore logic
(the code around hidden_menus at the later block) with a simple null/exists
check (e.g., verify Globals != null or typeof(Globals) != TYPE_NIL) and only
perform logging or restore when that check passes so the node can exit safely if
the singleton was torn down.

---

Outside diff comments:
In `@scripts/gameplay_settings.gd`:
- Around line 145-188: The DOM teardown is incorrectly gated on js_window so
overlays can be left visible; keep nulling callbacks (_change_difficulty_cb,
_gameplay_back_button_pressed_cb, _gameplay_reset_cb and call to
_unset_gameplay_settings_window_callbacks()) only when js_window is present, but
drive the overlay cleanup when os_wrapper.has_feature("web") and
js_bridge_wrapper are available (i.e., remove js_window from the outer
conditional that controls the hide/eval path); ensure both the simple
js_bridge_wrapper.eval(hide_gameplay, true) and the restore path that calls
js_bridge_wrapper.eval(...) with the options-show + hide_gameplay payload still
run when js_bridge_wrapper exists so overlays are always hidden even if
js_window is null.

---

Duplicate comments:
In `@tests/difficulty_flow_test.py`:
- Around line 176-179: The assertion checking for the JS difficulty log is too
strict about the float formatting; update the assertion that inspects new_logs
(the comprehension using log["text"].lower()) to accept either "2.0" or "2" (or
normalize with a regex/float parse) when looking for the substring like "js
difficulty callback called with valid value:" so the test is tolerant of Godot
printing whole-number floats without the .0; modify the condition in the failing
assertion to match both representations (or parse the numeric token after the
message and compare numerically) to ensure the bridge validation still passes.

---

Nitpick comments:
In `@tests/difficulty_flow_test.py`:
- Around line 169-200: Add assertions that directly verify the settings UI state
(label/slider) after calling window.changeDifficulty([2.0]) and after calling
window.gameplayResetPressed([]): locate the on-page elements that display the
difficulty value (e.g., the difficulty label text and the difficulty
input/slider element) using the same page object used in this test, wait for
them to be present after each evaluate call, and assert their displayed
value/text matches the expected values (e.g., "2.0" after changeDifficulty and
"1.0" after reset); reference the existing uses of
page.evaluate("window.changeDifficulty([2.0])"),
page.evaluate("window.gameplayResetPressed([])"), and the logs/reset_logs to add
these UI assertions immediately after the corresponding wait_for_timeout blocks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: be24394e-3013-4c54-9257-7181f497f4fa

📥 Commits

Reviewing files that changed from the base of the PR and between 188120d and f5f752d.

📒 Files selected for processing (2)
  • scripts/gameplay_settings.gd
  • tests/difficulty_flow_test.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Browser Functional Tests / test
🔇 Additional comments (1)
scripts/gameplay_settings.gd (1)

396-405: JavaScriptObject bracket indexing is supported by Godot; the current implementation is defensible.

The code correctly validates that js_length is numeric and positive before indexing (line 405). Godot's official documentation explicitly states JavaScriptObject supports bracket notation for both reading and writing, mirroring JavaScript's dynamic access. The length check is a reasonable array-like guard, and the fallback path (returning first_arg as a scalar) safely handles any object that does not behave as expected. Downstream validation in _on_change_difficulty_js() further ensures the extracted value is numeric before use.

			> Likely an incorrect or invalid review comment.

Comment thread scripts/gameplay_settings.gd Outdated
Comment thread tests/difficulty_flow_test.py Outdated
Comment thread tests/difficulty_flow_test.py
Comment thread tests/difficulty_flow_test.py
ikostan added 3 commits March 18, 2026 22:11
Line 123 and Lines 160-166 still dereference Globals directly. If the singleton is torn down before this node exits, _on_tree_exited() can still explode while trying to log or restore hidden_menus, which undercuts the lifecycle hardening added elsewhere.
@ikostan ikostan merged commit 9ddba69 into main Mar 19, 2026
11 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Sky Lock Assault Project Mar 19, 2026
@ikostan ikostan deleted the settings-labels-display-unclamped-values branch March 19, 2026 05:32
@sourcery-ai sourcery-ai Bot mentioned this pull request Apr 5, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request GUI menu testing web

Projects

Status: Done

1 participant