diff --git a/AGENTS.md b/AGENTS.md index 229473d..38285e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Key utilities in [addons/talo/utils/](addons/talo/utils/): - **CryptoManager** - Encryption key generation/storage for offline data - **SessionManager** - Session token persistence - **DebounceTimer** - Debounces health checks, player updates, save updates (1s default, configurable via `debounce_timer_seconds`) +- **TaloDebouncedAPI** ([apis/debounced_api.gd](addons/talo/apis/debounced_api.gd)) - Base class for debounced APIs. Provides `flush_updates() -> FlushResult` and per-update awaiters. Player and save updates debounce by default but can be awaited for entity-specific results. ## GDScript Standards diff --git a/addons/talo/apis/debounced_api.gd b/addons/talo/apis/debounced_api.gd new file mode 100644 index 0000000..531f672 --- /dev/null +++ b/addons/talo/apis/debounced_api.gd @@ -0,0 +1,94 @@ +class_name TaloDebouncedAPI extends TaloAPI + +enum FlushResult { + NOTHING_PENDING, + SUCCESS, + FAILURE, +} + +signal _update_settled(success: bool, operation_data: Variant) + +var _update_timer: TaloDebounceTimer +var _is_executing: bool +var _is_queued: bool +var _pending_waiters: Array[UpdateWaiter] = [] + +func _init(base_path: String, leading: bool = true) -> void: + super(base_path) + _update_timer = TaloDebounceTimer.new(_on_debounce_fired, leading) + add_child(_update_timer) + +func _debounce() -> void: + _update_timer.debounce() + +func _queue_update() -> UpdateWaiter: + var waiter := UpdateWaiter.new() + _pending_waiters.append(waiter) + _debounce() + return waiter + +func _run_debounced_update() -> Variant: + return null + +func _build_update_result(success: bool, operation_data: Variant) -> Variant: + return operation_data + +func _on_debounce_fired() -> void: + # if an update is executing, queue a new update + if _is_executing: + _is_queued = true + return + # else, just execute it + _execute_update() + +func _execute_update() -> void: + while true: + var waiters := _pending_waiters + _pending_waiters = [] + _is_executing = true + var result := await _run_debounced_update() + _is_executing = false + + var success := result != null + if not success: + push_error("%s debounced update failed" % name) + + # check if another update was queued during execution + if _is_queued: + _is_queued = false + _is_executing = true + + var update_result := _build_update_result(success, result) + for waiter in waiters: + waiter.settle(update_result) + _update_settled.emit(success, result) + + if not _is_executing: + return + +func flush_updates() -> FlushResult: + var result := FlushResult.NOTHING_PENDING + while _is_executing or not _update_timer.is_stopped(): + if _is_executing: + var settled: Array = await _update_settled + var success: bool = settled[0] + if success: + # don't override the failure result + if result == FlushResult.NOTHING_PENDING: + result = FlushResult.SUCCESS + else: + result = FlushResult.FAILURE + else: + _update_timer.stop() + _execute_update() + + return result + +class UpdateWaiter: + signal settled(result: Variant) + + var result: Variant + + func settle(update_result: Variant) -> void: + result = update_result + settled.emit(update_result) diff --git a/addons/talo/apis/debounced_api.gd.uid b/addons/talo/apis/debounced_api.gd.uid new file mode 100644 index 0000000..4491343 --- /dev/null +++ b/addons/talo/apis/debounced_api.gd.uid @@ -0,0 +1 @@ +uid://cyhwup66cl8nu diff --git a/addons/talo/apis/players_api.gd b/addons/talo/apis/players_api.gd index a1a1c66..7bd5ff0 100644 --- a/addons/talo/apis/players_api.gd +++ b/addons/talo/apis/players_api.gd @@ -1,4 +1,4 @@ -class_name PlayersAPI extends TaloAPI +class_name PlayersAPI extends TaloDebouncedAPI ## An interface for communicating with the Talo Players API. ## ## This API is used to identify players and update player data. @@ -20,14 +20,15 @@ signal identity_cleared() ## Emitted when one or more props are rejected during a player update. signal props_rejected(rejected_props: Array[TaloRejectedProp]) -var _update_timer := TaloDebounceTimer.new(_handle_update_timer_timeout, false) +## Emitted when a debounced player update settles. +signal player_updated(success: bool) + +func _init(base_path: String) -> void: + super(base_path) + _update_settled.connect(_on_update_settled) func _ready() -> void: Talo.connection_restored.connect(_on_connection_restored) - add_child(_update_timer) - -func _handle_update_timer_timeout() -> void: - await Talo.players.update() func _handle_identify_success(alias: TaloPlayerAlias, socket_token: String = "") -> TaloPlayerAlias: if not await Talo.is_offline() and Talo.socket.is_identified(): @@ -89,16 +90,14 @@ func identify_game_center( }) return await identify("game_center", identifier.uri_encode()) -## Queue a debounced update to the current player. The timer will reset every time this method is called. -func debounce_update() -> void: - _update_timer.debounce() - -## Flush and sync the player's current data with Talo. -func update() -> TaloPlayer: +func _run_debounced_update() -> Variant: if Talo.identity_check() != OK: return null - var res := await client.make_request(HTTPClient.METHOD_PATCH, "/%s" % Talo.current_player.id, { props = Talo.current_player.get_serialized_props() }) + var res := await client.make_request(HTTPClient.METHOD_PATCH, "/%s" % Talo.current_player.id, { + props = Talo.current_player.get_serialized_props() + }) + match res.status: 200: if is_instance_valid(Talo.current_alias.player): @@ -112,10 +111,29 @@ func update() -> TaloPlayer: if rejected_props.size() > 0: props_rejected.emit(rejected_props) - return Talo.current_player + return rejected_props _: return null +func _on_update_settled(success: bool, _operation_data: Variant) -> void: + player_updated.emit(success) + +func _build_update_result(success: bool, operation_data: Variant) -> Variant: + if not success: + return PlayerUpdateResult.new(false) + return PlayerUpdateResult.new(true, operation_data) + +## Flush and sync the player's current data with Talo. +func update() -> TaloPlayer: + var data := await _run_debounced_update() + if data == null: + return null + return Talo.current_player + +## Queue a debounced update. The returned signal resolves with a PlayerUpdateResult. +func debounce_update() -> Signal: + return _queue_update().settled + ## Merge all of the data from player_id2 into player_id1 and delete player_id2. func merge(player_id1: String, player_id2: String, options := MergeOptions.new()) -> TaloPlayer: var res := await client.make_request(HTTPClient.METHOD_POST, "/merge", { @@ -205,3 +223,11 @@ class SearchPage: class MergeOptions: var post_merge_identity_service: String = "" + +class PlayerUpdateResult: + var success: bool + var rejected_props: Array[TaloRejectedProp] + + func _init(success: bool, rejected_props: Array[TaloRejectedProp] = []) -> void: + self.success = success + self.rejected_props = rejected_props diff --git a/addons/talo/apis/saves_api.gd b/addons/talo/apis/saves_api.gd index e5c710e..02019c7 100644 --- a/addons/talo/apis/saves_api.gd +++ b/addons/talo/apis/saves_api.gd @@ -1,4 +1,4 @@ -class_name SavesAPI extends TaloAPI +class_name SavesAPI extends TaloDebouncedAPI ## An interface for communicating with the Talo Saves API. ## ## This API allows you to save and load game data for your players. You can create, update, and delete saves, as well as load and unload them. @@ -13,6 +13,8 @@ signal save_chosen(save: TaloGameSave) signal save_loading_completed ## Emitted when the current save is unloaded. signal save_unloaded(save: TaloGameSave) +## Emitted when the current save is updated. The save is null on failure. +signal save_updated(success: bool, save: TaloGameSave) var _saves_manager := TaloSavesManager.new() @@ -28,14 +30,9 @@ var latest: TaloGameSave: var current: TaloGameSave: get: return _saves_manager.current_save -var _update_timer := TaloDebounceTimer.new(_handle_update_timer_timeout) - -func _ready() -> void: - add_child(_update_timer) - -func _handle_update_timer_timeout() -> void: - if _saves_manager.current_save: - await update_save(_saves_manager.current_save) +func _init(base_path: String) -> void: + super(base_path) + _update_settled.connect(_on_update_settled) ## Sync an offline save with an online save using the offline save data. func replace_save_with_offline_save(offline_save: TaloGameSave) -> TaloGameSave: @@ -119,19 +116,33 @@ func create_save(save_name: String, content: Dictionary = {}) -> TaloGameSave: func register(loadable: TaloLoadable) -> void: _saves_manager.register(loadable) +func _run_debounced_update() -> Variant: + if _saves_manager.current_save: + return await update_save(_saves_manager.current_save) + return null + +func _on_update_settled(success: bool, operation_data: Variant) -> void: + save_updated.emit(success, operation_data if success else null) + +func _build_update_result(success: bool, operation_data: Variant) -> Variant: + var save: TaloGameSave = operation_data if success else null + return SaveUpdateResult.new(success, save) + ## Update the currently loaded save using the current state of the game and with the given name. -func update_current_save(new_name: String = "") -> TaloGameSave: +func update_current_save(new_name: String = "") -> Variant: if not _saves_manager.current_save: return null # if the save is being renamed, sync it immediately if not new_name.is_empty(): - return await update_save(_saves_manager.current_save, new_name) + var save := await update_save(_saves_manager.current_save, new_name) + var success := save != null + save_updated.emit(success, save if success else null) + return SaveUpdateResult.new(success, save) # else, update the save locally and queue it for syncing else: - _update_timer.debounce() _saves_manager.current_save.content = _saves_manager.get_save_content() - return _saves_manager.current_save + return await _queue_update().settled ## Update the given save using the current state of the game and with the given name. func update_save(save: TaloGameSave, new_name: String = "") -> TaloGameSave: @@ -157,6 +168,8 @@ func update_save(save: TaloGameSave, new_name: String = "") -> TaloGameSave: match res.status: 200: save = TaloGameSave.new(res.body.save) + _: + return null _saves_manager.replace_save(save) return save @@ -181,3 +194,11 @@ func delete_save(save: TaloGameSave, unload_if_current_save: bool = false) -> vo ## Get the format version for the current save. func get_format_version() -> String: return _saves_manager.get_format_version() + +class SaveUpdateResult extends RefCounted: + var success: bool + var save: TaloGameSave + + func _init(result_success: bool, result_save: TaloGameSave) -> void: + success = result_success + save = result_save diff --git a/addons/talo/entities/entity_with_props.gd b/addons/talo/entities/entity_with_props.gd index 43158c9..c9f87aa 100644 --- a/addons/talo/entities/entity_with_props.gd +++ b/addons/talo/entities/entity_with_props.gd @@ -15,21 +15,23 @@ func get_prop(key: String, fallback: String = "") -> String: return fallback if filtered.is_empty() else filtered.front().value ## Set a property by key and value. -func set_prop(key: String, value: String) -> void: +func set_prop(key: String, value: String) -> Variant: var filtered := props.filter(func (prop: TaloProp): return prop.key == key) if filtered.is_empty(): props.push_back(TaloProp.new(key, value)) else: filtered.front().value = value + return null ## Delete a property by key. -func delete_prop(key: String) -> void: +func delete_prop(key: String) -> Variant: props.assign(props.map( func (prop: TaloProp): if prop.key == key: prop.value = null return prop )) + return null func get_serialized_props() -> Array: return props.map(func (prop: TaloProp): return prop.to_dictionary()) @@ -43,7 +45,7 @@ func get_prop_array(key: String) -> Array[String]: return result ## Set all values for a prop array by key, replacing any existing values. -func set_prop_array(key: String, values: Array[String]) -> void: +func set_prop_array(key: String, values: Array[String]) -> Variant: var unique_values: Array[String] = [] for v in values: @@ -52,40 +54,43 @@ func set_prop_array(key: String, values: Array[String]) -> void: if unique_values.is_empty(): push_error("set_prop_array: values must not be empty") - return + return null var array_key := TaloProp.to_array_key(key) props.assign(props.filter(func (prop: TaloProp): return prop.key != array_key)) for v in unique_values: props.push_back(TaloProp.new(array_key, v)) + return null ## Delete a prop array by key, leaving a sentinel null entry. -func delete_prop_array(key: String) -> void: +func delete_prop_array(key: String) -> Variant: var array_key := TaloProp.to_array_key(key) var matches := props.filter(func (prop: TaloProp): return prop.key == array_key) if matches.is_empty(): push_error("delete_prop_array: array key not found") - return + return null props.assign(props.filter(func (prop: TaloProp): return prop.key != array_key)) props.push_back(TaloProp.new(array_key, null)) + return null ## Insert a value into a prop array by key. -func insert_into_prop_array(key: String, value: String) -> void: +func insert_into_prop_array(key: String, value: String) -> Variant: if value == "": push_error("insert_into_prop_array: value must not be empty") - return + return null var array_key := TaloProp.to_array_key(key) var already_exists := props.any(func (prop: TaloProp): return prop.key == array_key && prop.value == value) if !already_exists: props.assign(props.filter(func (prop: TaloProp): return !(prop.key == array_key && prop.value == null))) props.push_back(TaloProp.new(array_key, value)) + return null ## Remove a value from a prop array by key. -func remove_from_prop_array(key: String, value: String) -> void: +func remove_from_prop_array(key: String, value: String) -> Variant: var array_key := TaloProp.to_array_key(key) var had_sentinel := props.any(func (prop: TaloProp): return prop.key == array_key && prop.value == null) props.assign(props.filter(func (prop: TaloProp): return !(prop.key == array_key && prop.value == null))) @@ -95,8 +100,9 @@ func remove_from_prop_array(key: String, value: String) -> void: if had_sentinel: props.push_back(TaloProp.new(array_key, null)) push_error("remove_from_prop_array: value not found in array") - return + return null props.assign(props.filter(func (prop: TaloProp): return !(prop.key == array_key && prop.value == value))) if !props.any(func (prop: TaloProp): return prop.key == array_key): props.push_back(TaloProp.new(array_key, null)) + return null diff --git a/addons/talo/entities/player.gd b/addons/talo/entities/player.gd index 66e11b6..1c2cbb9 100644 --- a/addons/talo/entities/player.gd +++ b/addons/talo/entities/player.gd @@ -29,41 +29,50 @@ func update_from_raw_data(data: Dictionary) -> void: _offline_data = data +func _local_update_success_result() -> PlayersAPI.PlayerUpdateResult: + return PlayersAPI.PlayerUpdateResult.new(true) + ## Set a property by key and value. Optionally sync the player (default true) with Talo. -func set_prop(key: String, value: String, update: bool = true) -> void: +func set_prop(key: String, value: String, update: bool = true) -> Variant: super.set_prop(key, value) if update: - Talo.players.debounce_update() + return Talo.players.debounce_update() + return _local_update_success_result() ## Delete a property by key. Optionally sync the player (default true) with Talo. -func delete_prop(key: String, update: bool = true) -> void: +func delete_prop(key: String, update: bool = true) -> Variant: super.delete_prop(key) if update: - Talo.players.debounce_update() + return Talo.players.debounce_update() + return _local_update_success_result() ## Set all values for a prop array by key. Optionally sync the player (default true) with Talo. -func set_prop_array(key: String, values: Array[String], update: bool = true) -> void: +func set_prop_array(key: String, values: Array[String], update: bool = true) -> Variant: super.set_prop_array(key, values) if update: - Talo.players.debounce_update() + return Talo.players.debounce_update() + return _local_update_success_result() ## Delete a prop array by key. Optionally sync the player (default true) with Talo. -func delete_prop_array(key: String, update: bool = true) -> void: +func delete_prop_array(key: String, update: bool = true) -> Variant: super.delete_prop_array(key) if update: - Talo.players.debounce_update() + return Talo.players.debounce_update() + return _local_update_success_result() ## Insert a value into a prop array by key. Optionally sync the player (default true) with Talo. -func insert_into_prop_array(key: String, value: String, update: bool = true) -> void: +func insert_into_prop_array(key: String, value: String, update: bool = true) -> Variant: super.insert_into_prop_array(key, value) if update: - Talo.players.debounce_update() + return Talo.players.debounce_update() + return _local_update_success_result() ## Remove a value from a prop array by key. Optionally sync the player (default true) with Talo. -func remove_from_prop_array(key: String, value: String, update: bool = true) -> void: +func remove_from_prop_array(key: String, value: String, update: bool = true) -> Variant: super.remove_from_prop_array(key, value) if update: - Talo.players.debounce_update() + return Talo.players.debounce_update() + return _local_update_success_result() ## Check if the player is in a group with the given ID. func is_in_talo_group_id(group_id: String) -> bool: diff --git a/addons/talo/samples/playground/scripts/set_prop_button.gd b/addons/talo/samples/playground/scripts/set_prop_button.gd index 4b35b6f..f473d8f 100644 --- a/addons/talo/samples/playground/scripts/set_prop_button.gd +++ b/addons/talo/samples/playground/scripts/set_prop_button.gd @@ -3,13 +3,6 @@ extends Button @export var prop_name: String @export var prop_value: String -func _ready() -> void: - Talo.players.props_rejected.connect(_on_props_rejected) - -func _on_props_rejected(rejected_props: Array[TaloRejectedProp]) -> void: - var reasons := rejected_props.map(func (rp: TaloRejectedProp): return "[%s] %s" % [rp.key, rp.message]) - %ResponseLabel.text = "Rejected props: %s" % ", ".join(reasons) - func _on_pressed() -> void: if Talo.identity_check() != OK: %ResponseLabel.text = "You need to identify a player first!" @@ -19,4 +12,10 @@ func _on_pressed() -> void: %ResponseLabel.text = "prop_name or prop_value not set on SetPropButton" return - Talo.current_player.set_prop(prop_name, prop_value) + var result: PlayersAPI.PlayerUpdateResult = await Talo.current_player.set_prop(prop_name, prop_value) + if not result.rejected_props.is_empty(): + var reasons := result.rejected_props.map(func (rp: TaloRejectedProp): return "[%s] %s" % [rp.key, rp.message]) + %ResponseLabel.text = "Rejected props: %s" % ", ".join(reasons) + return + + %ResponseLabel.text = "%s saved successfully" % prop_value diff --git a/addons/talo/samples/playground/scripts/update_save_button.gd b/addons/talo/samples/playground/scripts/update_save_button.gd index d404811..6830ba3 100644 --- a/addons/talo/samples/playground/scripts/update_save_button.gd +++ b/addons/talo/samples/playground/scripts/update_save_button.gd @@ -1,5 +1,11 @@ extends Button +func _ready() -> void: + Talo.saves.save_updated.connect(_on_save_updated) + +func _on_save_updated(success: bool, save: TaloGameSave) -> void: + %ResponseLabel.text = "Saved successfully!" if success else "Save failed" + func _on_pressed() -> void: if not Talo.saves.current: %ResponseLabel.text = "No save currently loaded" @@ -17,4 +23,4 @@ func _on_pressed() -> void: var new_name := Talo.saves.current.name.replace("version %s" % version, "version %s" % (version + 1)) await Talo.saves.update_current_save(new_name) - %ResponseLabel.text = "Updated save, new name is: %s" % new_name + %ResponseLabel.text = "Renamed to: %s" % new_name diff --git a/addons/talo/talo_manager.gd b/addons/talo/talo_manager.gd index d62477a..deea817 100644 --- a/addons/talo/talo_manager.gd +++ b/addons/talo/talo_manager.gd @@ -138,5 +138,10 @@ func _handle_quit() -> void: await events.pending_events_flushed + # flush any pending debounced updates before we quit + if identity_check(false) == OK: + await Talo.players.flush_updates() + await Talo.saves.flush_updates() + if Talo.settings.handle_tree_quit: get_tree().quit() diff --git a/addons/talo/utils/debounce_timer.gd b/addons/talo/utils/debounce_timer.gd index 436541e..9400a0f 100644 --- a/addons/talo/utils/debounce_timer.gd +++ b/addons/talo/utils/debounce_timer.gd @@ -34,3 +34,7 @@ func _handle_leading_debounce() -> void: _callback.call() else: _has_pending = true + +func stop() -> void: + super.stop() + _has_pending = false diff --git a/test/apis/debounced_api_test.gd b/test/apis/debounced_api_test.gd new file mode 100644 index 0000000..4320ee8 --- /dev/null +++ b/test/apis/debounced_api_test.gd @@ -0,0 +1,151 @@ +extends GdUnitTestSuite + +class TestHarness extends TaloDebouncedAPI: + signal operation_started(count: int) + signal release_operation + + var operation_result: Variant + var operation_count: int + + func _init(leading: bool = false) -> void: + super._init("/v1/test", leading) + + func _run_debounced_update() -> Variant: + operation_count += 1 + operation_started.emit(operation_count) + await release_operation + return operation_result + + func _build_update_result(success: bool, _operation_data: Variant) -> Variant: + return TestResult.new(success) + + func queue_update() -> TaloDebouncedAPI.UpdateWaiter: + return _queue_update() + + func release() -> void: + release_operation.emit() + + class TestResult: + var success: bool + + func _init(result_success: bool) -> void: + success = result_success + +func before_test() -> void: + Talo.settings.debounce_timer_seconds = 0.01 + +func after_test() -> void: + Talo.settings.debounce_timer_seconds = 1.0 + +func _make_harness(result: Variant, leading: bool = false) -> TestHarness: + var harness: TestHarness = auto_free(TestHarness.new(leading)) + harness.operation_result = result + add_child(harness) + monitor_signals(harness) + return harness + +func _release_after(harness: TestHarness, seconds: float) -> void: + get_tree().create_timer(seconds).timeout.connect(harness.release, CONNECT_ONE_SHOT) + +func test_debounced_updates_merge_into_one() -> void: + var harness := _make_harness(TaloFixtures.make_player()) + var first := harness.queue_update() + var second := harness.queue_update() + var third := harness.queue_update() + + @warning_ignore("redundant_await") + await assert_signal(harness).is_emitted(harness.operation_started, 1) + _release_after(harness, 0.05) + var result := await harness.flush_updates() + + assert_int(harness.operation_count).is_equal(1) + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.SUCCESS) + assert_bool(first.result.success).is_true() + assert_bool(second.result.success).is_true() + assert_bool(third.result.success).is_true() + +func test_flush_forces_pending_update_and_resolves_waiter() -> void: + var harness := _make_harness(TaloFixtures.make_player()) + harness._update_timer.wait_time = 60.0 + var waiter := harness.queue_update() + _release_after(harness, 0.05) + + var result := await harness.flush_updates() + + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.SUCCESS) + assert_int(harness.operation_count).is_equal(1) + assert_bool(waiter.result.success).is_true() + +func test_flush_waits_for_in_flight_update() -> void: + var harness := _make_harness(TaloFixtures.make_player()) + var waiter := harness.queue_update() + harness._update_timer.stop() + harness._on_debounce_fired() + @warning_ignore("redundant_await") + await assert_signal(harness).is_emitted(harness.operation_started, 1) + _release_after(harness, 0.05) + + var result := await harness.flush_updates() + + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.SUCCESS) + assert_int(harness.operation_count).is_equal(1) + assert_bool(waiter.result.success).is_true() + +func test_queued_update_gets_its_own_result_after_in_flight_update() -> void: + var harness := _make_harness(TaloFixtures.make_player()) + var first := harness.queue_update() + harness._update_timer.stop() + harness._on_debounce_fired() + @warning_ignore("redundant_await") + await assert_signal(harness).is_emitted(harness.operation_started, 1) + + var second := harness.queue_update() + _release_after(harness, 0.05) + _release_after(harness, 0.10) + + var result := await harness.flush_updates() + + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.SUCCESS) + assert_int(harness.operation_count).is_equal(2) + assert_bool(first.result.success).is_true() + assert_bool(second.result.success).is_true() + assert_bool(harness._is_queued).is_false() + assert_bool(harness._is_executing).is_false() + +func test_failed_update_resolves_waiter_and_returns_failure() -> void: + var harness := _make_harness(null) + harness._update_timer.wait_time = 60.0 + var waiter := harness.queue_update() + _release_after(harness, 0.05) + + var result := await harness.flush_updates() + + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.FAILURE) + assert_bool(waiter.result.success).is_false() + +func test_leading_mode_fires_immediately_on_first_call() -> void: + var harness := _make_harness(TaloFixtures.make_player(), true) + + # (first) leading call fires immediately + var first := harness.queue_update() + @warning_ignore("redundant_await") + await assert_signal(harness).is_emitted(harness.operation_started, 1) + + # (second) trailing call within window merges into one execution + var second := harness.queue_update() + + _release_after(harness, 0.05) + _release_after(harness, 0.10) + var result := await harness.flush_updates() + + assert_int(harness.operation_count).is_equal(2) + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.SUCCESS) + assert_bool(first.result.success).is_true() + assert_bool(second.result.success).is_true() + assert_bool(harness._is_queued).is_false() + assert_bool(harness._is_executing).is_false() + +func test_flush_returns_nothing_pending_when_nothing_queued() -> void: + var harness := _make_harness(TaloFixtures.make_player()) + var result := await harness.flush_updates() + assert_int(result).is_equal(TaloDebouncedAPI.FlushResult.NOTHING_PENDING) diff --git a/test/apis/debounced_api_test.gd.uid b/test/apis/debounced_api_test.gd.uid new file mode 100644 index 0000000..d56bb76 --- /dev/null +++ b/test/apis/debounced_api_test.gd.uid @@ -0,0 +1 @@ +uid://ucj2weuvya2i