Skip to content

Commit ac550eb

Browse files
committed
Address Copilot review on #16
Five fixes from the Copilot reviewer pass: 1. build_layout: validate loaded resource is a Theme before assigning to node.theme. A non-Theme resource path (e.g. a .tres StandardMaterial3D) now errors with INVALID_PARAMS instead of silently failing at runtime. 2. build_layout _apply_property: when Color/Vector2/Vector2i/NodePath coercion fails, return INVALID_PARAMS instead of silently passing the raw value through to node.set(). _coerce_for_type now returns {"ok": bool, "value": ...} so failures are explicit. 3. create_theme overwritten: captured pre-save existence flag instead of computing it post-save (which was always true when overwrite=true). 4. _validate_res_path: accepts a param_name (default "theme_path") so create_theme reports "Missing required param: path" rather than "theme_path". Applies the name uniformly across all three validation branches. 5. _set_scalar: reject null value explicitly with a clear error message. Previously a null payload slipped past the parser failure check and reached Theme.set_color(null) / equivalent. Adds GDScript regression tests for: - theme: null-value rejection, overwritten-flag accuracy on first vs second create, param-name-aware missing-path error - build_layout: non-Theme resource rejection, uncoercible property rejection
1 parent acf169c commit ac550eb

4 files changed

Lines changed: 120 additions & 26 deletions

File tree

plugin/addons/godot_ai/handlers/theme_handler.gd

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ func create_theme(params: Dictionary) -> Dictionary:
2525
var path: String = params.get("path", "")
2626
var overwrite: bool = params.get("overwrite", false)
2727

28-
var err := _validate_res_path(path, ".tres")
28+
var err := _validate_res_path(path, ".tres", "path")
2929
if err != null:
3030
return err
3131

32-
if FileAccess.file_exists(path) and not overwrite:
32+
# Capture whether the file was already there BEFORE the save so we can
33+
# report `overwritten` accurately (after save the file always exists).
34+
var existed_before := FileAccess.file_exists(path)
35+
if existed_before and not overwrite:
3336
return McpErrorCodes.make(
3437
McpErrorCodes.INVALID_PARAMS,
3538
"Theme already exists at %s (pass overwrite=true to replace)" % path
@@ -51,7 +54,7 @@ func create_theme(params: Dictionary) -> Dictionary:
5154
return {
5255
"data": {
5356
"path": path,
54-
"overwritten": overwrite and FileAccess.file_exists(path),
57+
"overwritten": existed_before,
5558
"undoable": false,
5659
"reason": "File creation is persistent; delete the file manually to revert",
5760
}
@@ -115,9 +118,15 @@ func _set_scalar(
115118
if not "value" in params:
116119
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: value")
117120

118-
var parsed = parser.call(params.get("value"))
119-
if parsed == null and params.get("value") != null:
120-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Invalid %s value: %s" % [kind, params.get("value")])
121+
var raw_value = params.get("value")
122+
if raw_value == null:
123+
return McpErrorCodes.make(
124+
McpErrorCodes.INVALID_PARAMS,
125+
"Invalid %s value: null (pass a concrete value; use the appropriate clear command to remove a slot)" % kind
126+
)
127+
var parsed = parser.call(raw_value)
128+
if parsed == null:
129+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Invalid %s value: %s" % [kind, raw_value])
121130

122131
var had_before: bool = has_fn.call(theme, name, class_name_param)
123132
var before_value = getter.call(theme, name, class_name_param) if had_before else null
@@ -331,15 +340,18 @@ func _load_theme_from_params(params: Dictionary) -> Dictionary:
331340
return {"theme": theme, "path": theme_path}
332341

333342

334-
static func _validate_res_path(path: String, required_suffix: String) -> Variant:
343+
static func _validate_res_path(path: String, required_suffix: String, param_name: String = "theme_path") -> Variant:
335344
if path.is_empty():
336-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: theme_path")
345+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Missing required param: %s" % param_name)
337346
if not path.begins_with("res://"):
338-
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Path must start with res:// (got %s)" % path)
347+
return McpErrorCodes.make(
348+
McpErrorCodes.INVALID_PARAMS,
349+
"%s must start with res:// (got %s)" % [param_name, path]
350+
)
339351
if not path.ends_with(required_suffix):
340352
return McpErrorCodes.make(
341353
McpErrorCodes.INVALID_PARAMS,
342-
"Path must end with %s (got %s)" % [required_suffix, path]
354+
"%s must end with %s (got %s)" % [param_name, required_suffix, path]
343355
)
344356
return null
345357

plugin/addons/godot_ai/handlers/ui_handler.gd

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,13 @@ func _build_subtree(spec: Dictionary) -> Dictionary:
237237
node.free()
238238
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "Theme not found: %s" % theme_path)
239239
var theme_res: Resource = ResourceLoader.load(theme_path)
240+
if theme_res == null or not theme_res is Theme:
241+
node.free()
242+
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "theme path must point to a Theme resource: %s" % theme_path)
240243
if not node is Control and not node is Window:
241244
node.free()
242245
return McpErrorCodes.make(McpErrorCodes.INVALID_PARAMS, "theme can only be set on Control / Window (got %s)" % node_type)
243-
node.theme = theme_res
246+
node.theme = theme_res as Theme
244247

245248
# Anchor preset — applied before children so children inherit sensible anchors.
246249
if spec.has("anchor_preset"):
@@ -292,40 +295,59 @@ func _apply_property(node: Node, prop: String, value: Variant) -> Variant:
292295
"Property '%s' not found on %s" % [prop, node.get_class()]
293296
)
294297

295-
var coerced := _coerce_for_type(value, prop_type)
296-
node.set(prop, coerced)
298+
var coercion := _coerce_for_type(value, prop_type)
299+
if not coercion.ok:
300+
return McpErrorCodes.make(
301+
McpErrorCodes.INVALID_PARAMS,
302+
"Property '%s' on %s expects type %s (cannot coerce %s)" % [
303+
prop, node.get_class(), type_string(prop_type), value
304+
]
305+
)
306+
node.set(prop, coercion.value)
297307
return null
298308

299309

300-
static func _coerce_for_type(value: Variant, prop_type: int) -> Variant:
310+
## Coerce a JSON-friendly value to the target Godot type. Returns
311+
## {"ok": true, "value": coerced} on success, {"ok": false} on failure.
312+
## For types we don't explicitly coerce, the value is returned as-is
313+
## (Godot will typecheck at set() time and fail loudly if it disagrees).
314+
static func _coerce_for_type(value: Variant, prop_type: int) -> Dictionary:
301315
match prop_type:
302316
TYPE_COLOR:
303317
if value is Color:
304-
return value
318+
return {"ok": true, "value": value}
305319
if value is String:
306320
var a := Color.from_string(value, Color(0, 0, 0, 0))
307321
var b := Color.from_string(value, Color(1, 1, 1, 1))
308322
if a == b:
309-
return a
323+
return {"ok": true, "value": a}
324+
return {"ok": false}
310325
if value is Dictionary and value.has("r") and value.has("g") and value.has("b"):
311-
return Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0)))
326+
return {
327+
"ok": true,
328+
"value": Color(float(value.r), float(value.g), float(value.b), float(value.get("a", 1.0))),
329+
}
330+
return {"ok": false}
312331
TYPE_VECTOR2:
313332
if value is Vector2:
314-
return value
333+
return {"ok": true, "value": value}
315334
if value is Dictionary and value.has("x") and value.has("y"):
316-
return Vector2(float(value.x), float(value.y))
335+
return {"ok": true, "value": Vector2(float(value.x), float(value.y))}
317336
if value is Array and value.size() == 2:
318-
return Vector2(float(value[0]), float(value[1]))
337+
return {"ok": true, "value": Vector2(float(value[0]), float(value[1]))}
338+
return {"ok": false}
319339
TYPE_VECTOR2I:
320340
if value is Vector2i:
321-
return value
341+
return {"ok": true, "value": value}
322342
if value is Dictionary and value.has("x") and value.has("y"):
323-
return Vector2i(int(value.x), int(value.y))
343+
return {"ok": true, "value": Vector2i(int(value.x), int(value.y))}
324344
if value is Array and value.size() == 2:
325-
return Vector2i(int(value[0]), int(value[1]))
345+
return {"ok": true, "value": Vector2i(int(value[0]), int(value[1]))}
346+
return {"ok": false}
326347
TYPE_NODE_PATH:
327348
if value is NodePath:
328-
return value
349+
return {"ok": true, "value": value}
329350
if value is String:
330-
return NodePath(value)
331-
return value
351+
return {"ok": true, "value": NodePath(value)}
352+
return {"ok": false}
353+
return {"ok": true, "value": value}

test_project/tests/test_theme.gd

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,40 @@ func test_theme_apply_rejects_non_control() -> void:
260260
})
261261
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
262262
assert_contains(result.error.message, "not a Control")
263+
264+
265+
# ----- Regression: Copilot review fixes -----
266+
267+
func test_theme_set_color_rejects_null_value() -> void:
268+
_make_theme()
269+
var result := _handler.set_color({
270+
"theme_path": TEST_THEME_PATH,
271+
"class_name": "Label",
272+
"name": "font_color",
273+
"value": null,
274+
})
275+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
276+
assert_contains(result.error.message, "null")
277+
278+
279+
func test_create_theme_overwritten_flag_tracks_pre_save_state() -> void:
280+
# Fresh location — overwritten must be false even when overwrite=true.
281+
if FileAccess.file_exists(TEST_THEME_PATH):
282+
DirAccess.remove_absolute(TEST_THEME_PATH)
283+
var result := _handler.create_theme({"path": TEST_THEME_PATH, "overwrite": true})
284+
assert_has_key(result, "data")
285+
assert_eq(result.data.overwritten, false, "Overwritten should be false on fresh create")
286+
287+
# Second call: now it should be true.
288+
var result2 := _handler.create_theme({"path": TEST_THEME_PATH, "overwrite": true})
289+
assert_has_key(result2, "data")
290+
assert_eq(result2.data.overwritten, true, "Overwritten should be true on second create")
291+
292+
293+
func test_create_theme_missing_path_names_param_correctly() -> void:
294+
# Error message should name `path`, not `theme_path`, for theme_create.
295+
var result := _handler.create_theme({})
296+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
297+
assert_contains(result.error.message, "path")
298+
# Make sure it's NOT using the default "theme_path" label.
299+
assert_true(result.error.message.find("theme_path") == -1, "Error should say 'path', not 'theme_path'")

test_project/tests/test_ui.gd

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,26 @@ func test_build_layout_is_undoable() -> void:
296296
assert_eq(scene_root.get_child_count(), before_count + 1)
297297
_undo_redo.undo()
298298
assert_eq(scene_root.get_child_count(), before_count, "Undo should remove the whole built tree")
299+
300+
301+
func test_build_layout_rejects_non_theme_resource() -> void:
302+
# A .tres that is not a Theme — use a StandardMaterial3D saved to disk.
303+
var bogus_path := "res://tests/_mcp_test_not_a_theme.tres"
304+
var mat := StandardMaterial3D.new()
305+
ResourceSaver.save(mat, bogus_path)
306+
var result := _handler.build_layout({
307+
"tree": {"type": "Panel", "theme": bogus_path}
308+
})
309+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
310+
assert_contains(result.error.message, "Theme resource")
311+
if FileAccess.file_exists(bogus_path):
312+
DirAccess.remove_absolute(bogus_path)
313+
314+
315+
func test_build_layout_rejects_uncoercible_property() -> void:
316+
# Label.modulate is a Color — "not a color" must be rejected (not silently passed).
317+
var result := _handler.build_layout({
318+
"tree": {"type": "Label", "properties": {"modulate": "not a color!!"}}
319+
})
320+
assert_is_error(result, McpErrorCodes.INVALID_PARAMS)
321+
assert_contains(result.error.message, "modulate")

0 commit comments

Comments
 (0)