Skip to content

Commit 274870a

Browse files
dsarnoclaude
andauthored
Fix #397: pass encoding="utf-8" to read_text() in tests/ (#402)
* Fix #397: pass encoding="utf-8" to read_text() in tests/ Path.read_text() with no arguments uses locale.getpreferredencoding(). On Windows that's cp1252, which raises UnicodeDecodeError on the verbatim Korean string in plugin/addons/godot_ai/utils/uv_cache_cleanup.gd. test_plugin_self_update_safety.py rglob-walks the plugin tree and crashed 4 tests on Windows; CI is Linux-only (issue #35) so the hazard was silent until a developer ran pytest on Windows. Adds encoding="utf-8" to all 87 bare .read_text() calls across tests/unit/, plus a new test_read_text_encoding.py lint that fails when any future bare call lands. Closes #397 * Apply /simplify findings to test_read_text_encoding.py - Trim oversized module docstring to its WHY (cp1252 trap on Windows) - Trim assert message to one line; the offender list is the actionable part - Fast-path skip files where "read_text" doesn't appear before AST parse (~50% lint runtime saved on this tree per /simplify efficiency review) - Add four detector self-tests (positive, negative, unrelated-name, multi-offender) so a future refactor can't silently turn the lint into a no-op — mirrors pattern in test_no_direct_undo_redo_in_gdscript_tests.py and test_gdscript_no_adjacent_string_concat.py * Document AST lint blind spots from /review nit Note in the helper docstring that the matcher catches only the keyword form. Positional `read_text("utf-8")` and `**kwargs` splat are opaque to AST and would falsely trip the lint — codebase convention is the keyword form, so the match stays exact. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd09035 commit 274870a

15 files changed

Lines changed: 183 additions & 115 deletions

tests/unit/test_audit_data_loss_safeguards.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838

3939
def test_runner_declares_install_status_enum() -> None:
40-
source = RUNNER_PATH.read_text()
40+
source = RUNNER_PATH.read_text(encoding="utf-8")
4141
assert "enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED }" in source, (
4242
"Three-state install outcome is the contract callers depend on. "
4343
"FAILED_CLEAN means rollback restored the prior state; FAILED_MIXED "
@@ -48,7 +48,7 @@ def test_runner_declares_install_status_enum() -> None:
4848

4949

5050
def test_runner_tracks_paths_written_for_cross_batch_rollback() -> None:
51-
source = RUNNER_PATH.read_text()
51+
source = RUNNER_PATH.read_text(encoding="utf-8")
5252
assert "var _paths_written = []" in source, (
5353
"Per-file install records must accumulate across both `_new_file_paths` "
5454
"and `_existing_file_paths` batches so a failure in the second batch "
@@ -60,7 +60,7 @@ def test_runner_tracks_paths_written_for_cross_batch_rollback() -> None:
6060

6161

6262
def test_install_zip_paths_returns_install_status_and_drives_rollback() -> None:
63-
source = RUNNER_PATH.read_text()
63+
source = RUNNER_PATH.read_text(encoding="utf-8")
6464
paths_block = get_func_block(source, "func _install_zip_paths(")
6565
assert "-> int:" in source[: source.index(paths_block) + len(paths_block)]
6666
assert "InstallStatus.OK" in paths_block, (
@@ -78,7 +78,7 @@ def test_install_zip_paths_returns_install_status_and_drives_rollback() -> None:
7878

7979

8080
def test_install_zip_file_returns_dictionary_record_with_backup_metadata() -> None:
81-
source = RUNNER_PATH.read_text()
81+
source = RUNNER_PATH.read_text(encoding="utf-8")
8282
install_block = get_func_block(source, "func _install_zip_file(")
8383
assert "-> Dictionary:" in source[: source.index(install_block) + len(install_block)]
8484
# Backup is taken via COPY (not rename) so the original stays in place
@@ -94,7 +94,7 @@ def test_install_zip_file_returns_dictionary_record_with_backup_metadata() -> No
9494

9595

9696
def test_install_zip_file_does_not_remove_target_before_rename_attempt() -> None:
97-
source = RUNNER_PATH.read_text()
97+
source = RUNNER_PATH.read_text(encoding="utf-8")
9898
install_block = get_func_block(source, "func _install_zip_file(")
9999
# The first rename attempt must precede any DirAccess.remove_absolute(target_path).
100100
# The remove-then-rename pattern only appears INSIDE the
@@ -112,7 +112,7 @@ def test_install_zip_file_does_not_remove_target_before_rename_attempt() -> None
112112

113113

114114
def test_rollback_returns_failed_mixed_when_any_restore_fails() -> None:
115-
source = RUNNER_PATH.read_text()
115+
source = RUNNER_PATH.read_text(encoding="utf-8")
116116
rollback_block = get_func_block(source, "func _rollback_paths_written(")
117117
assert "-> int:" in source[: source.index(rollback_block) + len(rollback_block)]
118118
assert "InstallStatus.FAILED_MIXED" in rollback_block, (
@@ -138,17 +138,15 @@ def test_inner_install_restore_failure_surfaces_failed_mixed() -> None:
138138
its conditional set in `_install_zip_file`, and its consumption in
139139
`_rollback_paths_written`."""
140140

141-
source = RUNNER_PATH.read_text()
141+
source = RUNNER_PATH.read_text(encoding="utf-8")
142142
# Member declaration with the protective comment.
143143
assert "var _restore_failed := false" in source
144144

145145
# `_install_zip_file` must only delete the backup when the restore
146146
# copy actually succeeded. The pattern is: a guarded copy_absolute
147147
# call whose return is checked, and an `else: _restore_failed = true`.
148148
install_block = get_func_block(source, "func _install_zip_file(")
149-
assert (
150-
"DirAccess.copy_absolute(backup_path, target_path) == OK" in install_block
151-
), (
149+
assert "DirAccess.copy_absolute(backup_path, target_path) == OK" in install_block, (
152150
"Inner restore must check the copy result before treating the "
153151
"restore as complete. Without this check, a failed copy followed "
154152
"by an unconditional backup delete strands the file and produces "
@@ -170,7 +168,7 @@ def test_inner_install_restore_failure_surfaces_failed_mixed() -> None:
170168

171169

172170
def test_handle_install_failure_refuses_to_reenable_on_mixed_state() -> None:
173-
source = RUNNER_PATH.read_text()
171+
source = RUNNER_PATH.read_text(encoding="utf-8")
174172
handler_block = get_func_block(source, "func _handle_install_failure(")
175173
assert "InstallStatus.FAILED_MIXED" in handler_block
176174
# The MIXED branch must NOT call set_plugin_enabled(true). The caller
@@ -189,7 +187,7 @@ def test_handle_install_failure_refuses_to_reenable_on_mixed_state() -> None:
189187

190188

191189
def test_extract_and_scan_routes_failure_through_handle_install_failure() -> None:
192-
source = RUNNER_PATH.read_text()
190+
source = RUNNER_PATH.read_text(encoding="utf-8")
193191
extract_block = get_func_block(source, "func _extract_and_scan() -> void:")
194192
assert "_install_zip_paths(_new_file_paths)" in extract_block
195193
assert "_handle_install_failure(status)" in extract_block, (
@@ -213,7 +211,7 @@ def test_extract_and_scan_routes_failure_through_handle_install_failure() -> Non
213211
def test_atomic_write_does_not_remove_target_before_swap() -> None:
214212
"""The bug pattern: remove(path) then retry rename. Must not return."""
215213

216-
source = ATOMIC_WRITE_PATH.read_text()
214+
source = ATOMIC_WRITE_PATH.read_text(encoding="utf-8")
217215
write_block = get_func_block(source, "static func write(")
218216
# The dangerous sequence was: rename failed -> remove(path) -> rename retry.
219217
# In the new code, remove(path) only happens AFTER a successful copy
@@ -237,7 +235,7 @@ def test_atomic_write_does_not_remove_target_before_swap() -> None:
237235

238236

239237
def test_atomic_write_uses_copy_then_verify_as_rename_fallback() -> None:
240-
source = ATOMIC_WRITE_PATH.read_text()
238+
source = ATOMIC_WRITE_PATH.read_text(encoding="utf-8")
241239
write_block = get_func_block(source, "static func write(")
242240
assert "DirAccess.copy_absolute(tmp_path, path)" in write_block, (
243241
"Overwrite-copy is the safe fallback when rename-over-existing is "
@@ -252,7 +250,7 @@ def test_atomic_write_uses_copy_then_verify_as_rename_fallback() -> None:
252250

253251

254252
def test_atomic_write_restores_from_backup_when_swap_fails() -> None:
255-
source = ATOMIC_WRITE_PATH.read_text()
253+
source = ATOMIC_WRITE_PATH.read_text(encoding="utf-8")
256254
write_block = get_func_block(source, "static func write(")
257255
assert "DirAccess.copy_absolute(path, backup_path)" in write_block, (
258256
"Snapshot the prior file via copy_absolute BEFORE attempting the "
@@ -272,7 +270,7 @@ def test_atomic_write_restore_does_not_remove_path_before_copy() -> None:
272270
only. `copy_absolute` overwrites by default, so the pre-remove was
273271
unnecessary AND introduced a window where `path` could disappear."""
274272

275-
source = ATOMIC_WRITE_PATH.read_text()
273+
source = ATOMIC_WRITE_PATH.read_text(encoding="utf-8")
276274
write_block = get_func_block(source, "static func write(")
277275

278276
# Locate the `if backup_made:` branch and assert it does NOT contain
@@ -296,7 +294,7 @@ def test_atomic_write_restore_does_not_remove_path_before_copy() -> None:
296294

297295
def test_atomic_write_size_verification_uses_utf8_byte_count() -> None:
298296
"""`store_string` writes UTF-8 bytes — verify against to_utf8_buffer().size()."""
299-
source = ATOMIC_WRITE_PATH.read_text()
297+
source = ATOMIC_WRITE_PATH.read_text(encoding="utf-8")
300298
verify_block = get_func_block(source, "static func _written_size_matches(")
301299
assert "content.to_utf8_buffer().size()" in verify_block, (
302300
"String.length() returns char count which diverges from the byte "
@@ -314,7 +312,7 @@ def test_atomic_write_clears_partial_new_file_when_no_original_existed() -> None
314312
"destination is in its pre-call state on failure" — for a brand-new path
315313
that means nothing should be on disk after the function returns false."""
316314

317-
source = ATOMIC_WRITE_PATH.read_text()
315+
source = ATOMIC_WRITE_PATH.read_text(encoding="utf-8")
318316
write_block = get_func_block(source, "static func write(")
319317
assert "elif not had_original and FileAccess.file_exists(path):" in write_block, (
320318
"Failure path must clear partial bytes when no original existed. "

tests/unit/test_camera_current_contract.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def test_current_switch_undo_captures_logical_or_effective_current() -> None:
2121
current", leaving the new camera active after undo.
2222
"""
2323

24-
source = CAMERA_HANDLER_PATH.read_text()
24+
source = CAMERA_HANDLER_PATH.read_text(encoding="utf-8")
2525
switch_block = get_func_block(
2626
source,
2727
"func _add_make_current_to_action(node: Node, type_str: String, scene_root: Node) -> void:",
@@ -31,13 +31,13 @@ def test_current_switch_undo_captures_logical_or_effective_current() -> None:
3131

3232

3333
def test_configure_current_false_uses_authoritative_current_guard() -> None:
34-
source = CAMERA_HANDLER_PATH.read_text()
34+
source = CAMERA_HANDLER_PATH.read_text(encoding="utf-8")
3535
configure_block = get_func_block(source, "func configure(params: Dictionary) -> Dictionary:")
3636
assert "var was_on: bool = _resolve_current(scene_root, node)" in configure_block
3737

3838

3939
def test_camera_reads_still_route_through_logical_current_resolvers() -> None:
40-
source = CAMERA_HANDLER_PATH.read_text()
40+
source = CAMERA_HANDLER_PATH.read_text(encoding="utf-8")
4141
get_block = get_func_block(source, "func get_camera(params: Dictionary) -> Dictionary:")
4242
list_block = get_func_block(source, "func list_cameras(_params: Dictionary) -> Dictionary:")
4343
assert "_resolve_current(scene_root, node)" in get_block

tests/unit/test_deferred_timeout_contract.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111

1212
def test_deferred_timeout_error_code_registered_on_both_sides() -> None:
1313
assert ErrorCode.DEFERRED_TIMEOUT == "DEFERRED_TIMEOUT"
14-
gd_error_codes = (PLUGIN_ROOT / "utils" / "error_codes.gd").read_text()
14+
gd_error_codes = (PLUGIN_ROOT / "utils" / "error_codes.gd").read_text(encoding="utf-8")
1515
assert 'const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"' in gd_error_codes
1616

1717

1818
def test_dispatcher_tracks_deferred_ids_and_emits_timeout_error() -> None:
19-
source = (PLUGIN_ROOT / "dispatcher.gd").read_text()
19+
source = (PLUGIN_ROOT / "dispatcher.gd").read_text(encoding="utf-8")
2020
assert "_pending_deferred" in source
2121
assert 'const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")' in source
2222
assert "ErrorCodes.DEFERRED_TIMEOUT" in source

tests/unit/test_dock_cli_timeout.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
def test_cli_strategy_routes_every_shell_out_through_mcpcliexec() -> None:
2929
"""No bare OS.execute survives in _cli_strategy.gd."""
3030

31-
cli_source = (PLUGIN_ROOT / "clients" / "_cli_strategy.gd").read_text()
31+
cli_source = (PLUGIN_ROOT / "clients" / "_cli_strategy.gd").read_text(encoding="utf-8")
3232

3333
# The whole point of the refactor: every CLI invocation must go
3434
# through the bounded helper. A bare OS.execute slipping back in
@@ -50,7 +50,7 @@ def test_cli_strategy_routes_every_shell_out_through_mcpcliexec() -> None:
5050
def test_cli_exec_helper_uses_pipe_spawn_and_poll_kill() -> None:
5151
"""The helper must spawn detached and kill on timeout — not a blocking OS.execute."""
5252

53-
helper_source = (PLUGIN_ROOT / "clients" / "_cli_exec.gd").read_text()
53+
helper_source = (PLUGIN_ROOT / "clients" / "_cli_exec.gd").read_text(encoding="utf-8")
5454

5555
# Pipe-based spawn returns a PID we can poll on. A blocking
5656
# OS.execute(..., true) here would just relocate the original hang.
@@ -67,7 +67,7 @@ def test_cli_exec_helper_uses_pipe_spawn_and_poll_kill() -> None:
6767
def test_cli_strategy_surfaces_timeout_in_configure_and_remove_messages() -> None:
6868
"""A timeout must produce a user-actionable error, not a cryptic exit code."""
6969

70-
cli_source = (PLUGIN_ROOT / "clients" / "_cli_strategy.gd").read_text()
70+
cli_source = (PLUGIN_ROOT / "clients" / "_cli_strategy.gd").read_text(encoding="utf-8")
7171

7272
# The dock surfaces these strings in its row-error label and "Run
7373
# this manually" panel. Drift here means the user sees "exit code
@@ -84,7 +84,7 @@ def test_cli_strategy_surfaces_timeout_in_configure_and_remove_messages() -> Non
8484
def test_dock_dispatches_configure_and_remove_to_worker_thread() -> None:
8585
"""Issue #239: the Configure / Remove buttons must not block main."""
8686

87-
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text()
87+
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text(encoding="utf-8")
8888

8989
# The dispatch funnel must exist and route the click into a worker.
9090
assert "func _dispatch_client_action(" in dock_source
@@ -114,8 +114,8 @@ def test_dock_dispatches_configure_and_remove_to_worker_thread() -> None:
114114
def test_dock_drains_action_workers_during_install_update_and_exit_tree() -> None:
115115
"""Worker drain must cover both shutdown paths — same reason as the refresh worker."""
116116

117-
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text()
118-
manager_source = (PLUGIN_ROOT / "utils" / "update_manager.gd").read_text()
117+
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text(encoding="utf-8")
118+
manager_source = (PLUGIN_ROOT / "utils" / "update_manager.gd").read_text(encoding="utf-8")
119119

120120
# `_exit_tree` (dock teardown) must drain inline; the install-time
121121
# drain runs through `McpUpdateManager._drain_dock_workers()` which
@@ -141,7 +141,7 @@ def test_dock_drains_action_workers_during_install_update_and_exit_tree() -> Non
141141
def test_dock_action_dispatch_gates_on_self_update_in_progress() -> None:
142142
"""The same gate the refresh worker honors must protect Configure / Remove."""
143143

144-
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text()
144+
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text(encoding="utf-8")
145145
block = get_func_block(dock_source, "func _dispatch_client_action(")
146146
assert "_is_self_update_in_progress" in block, (
147147
"Configure / Remove dispatch must short-circuit during the "
@@ -155,7 +155,7 @@ def test_dock_action_dispatch_gates_on_self_update_in_progress() -> None:
155155
def test_status_refresh_apply_skips_rows_with_in_flight_action() -> None:
156156
"""A concurrent refresh result must not stomp the 'Configuring…' badge."""
157157

158-
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text()
158+
dock_source = (PLUGIN_ROOT / "mcp_dock.gd").read_text(encoding="utf-8")
159159
apply_block = get_func_block(dock_source, "func _apply_client_status_refresh_results(")
160160
assert "_client_action_threads.has(" in apply_block, (
161161
"Refresh-result apply must skip rows whose action worker is "

0 commit comments

Comments
 (0)