render: extract WMO render resource finalizer
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
class_name WmoRenderResourceFinalizer
|
||||
extends RefCounted
|
||||
|
||||
## Polls lightweight WMO render-cache requests and publishes terminal outcomes.
|
||||
## Request admission, cache lifetime and WMO materialization remain caller-owned.
|
||||
|
||||
var _expected_resource_script: Script
|
||||
var _minimum_format_version: int
|
||||
var _resource_loader_adapter: Object
|
||||
|
||||
|
||||
func _init(
|
||||
expected_resource_script: Script,
|
||||
minimum_format_version: int,
|
||||
resource_loader_adapter: Object = null
|
||||
) -> void:
|
||||
_expected_resource_script = expected_resource_script
|
||||
_minimum_format_version = minimum_format_version
|
||||
_resource_loader_adapter = resource_loader_adapter
|
||||
|
||||
|
||||
## Polls a detached pending snapshot in insertion order. Only terminal requests
|
||||
## are removed and published as an exact Resource reference or missing outcome.
|
||||
func poll_terminal_requests(render_resource_cache_state: RefCounted) -> int:
|
||||
if render_resource_cache_state == null:
|
||||
return 0
|
||||
var completed_request_count := 0
|
||||
var request_paths: Dictionary = render_resource_cache_state.call(
|
||||
"request_paths_snapshot"
|
||||
)
|
||||
for normalized_relative_path_variant in request_paths.keys():
|
||||
var normalized_relative_path := String(normalized_relative_path_variant)
|
||||
var resource_path := String(request_paths[normalized_relative_path_variant])
|
||||
var load_status := load_threaded_get_status(resource_path)
|
||||
if (
|
||||
load_status != ResourceLoader.THREAD_LOAD_LOADED
|
||||
and load_status != ResourceLoader.THREAD_LOAD_FAILED
|
||||
):
|
||||
continue
|
||||
if load_status != ResourceLoader.THREAD_LOAD_LOADED:
|
||||
render_resource_cache_state.call(
|
||||
"complete_request_as_missing",
|
||||
normalized_relative_path
|
||||
)
|
||||
completed_request_count += 1
|
||||
continue
|
||||
var loaded_resource := load_threaded_get(resource_path)
|
||||
if is_current_render_resource(loaded_resource):
|
||||
render_resource_cache_state.call(
|
||||
"complete_request_with_resource",
|
||||
normalized_relative_path,
|
||||
loaded_resource
|
||||
)
|
||||
else:
|
||||
render_resource_cache_state.call(
|
||||
"complete_request_as_missing",
|
||||
normalized_relative_path
|
||||
)
|
||||
completed_request_count += 1
|
||||
return completed_request_count
|
||||
|
||||
|
||||
## Accepts only the exact configured script and its current-or-newer format.
|
||||
func is_current_render_resource(resource: Resource) -> bool:
|
||||
return (
|
||||
resource != null
|
||||
and _expected_resource_script != null
|
||||
and resource.get_script() == _expected_resource_script
|
||||
and int(resource.get("format_version")) >= _minimum_format_version
|
||||
)
|
||||
|
||||
|
||||
## Production ResourceLoader status boundary; injectable for synthetic tests.
|
||||
func load_threaded_get_status(resource_path: String) -> int:
|
||||
if _resource_loader_adapter != null:
|
||||
return int(_resource_loader_adapter.call(
|
||||
"load_threaded_get_status",
|
||||
resource_path
|
||||
))
|
||||
return ResourceLoader.load_threaded_get_status(resource_path)
|
||||
|
||||
|
||||
## Production ResourceLoader result boundary; injectable for synthetic tests.
|
||||
func load_threaded_get(resource_path: String) -> Resource:
|
||||
if _resource_loader_adapter != null:
|
||||
return _resource_loader_adapter.call(
|
||||
"load_threaded_get",
|
||||
resource_path
|
||||
) as Resource
|
||||
return ResourceLoader.load_threaded_get(resource_path)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtvba6g1grttk
|
||||
@@ -24,6 +24,9 @@ const WMO_RENDER_BUILD_QUEUE_SCRIPT := preload(
|
||||
const WMO_RENDER_RESOURCE_CACHE_STATE_SCRIPT := preload(
|
||||
"res://src/render/wmo/wmo_render_resource_cache_state.gd"
|
||||
)
|
||||
const WMO_RENDER_RESOURCE_FINALIZER_SCRIPT := preload(
|
||||
"res://src/render/wmo/wmo_render_resource_finalizer.gd"
|
||||
)
|
||||
const WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT := preload(
|
||||
"res://src/render/wmo/wmo_scene_resource_cache_state.gd"
|
||||
)
|
||||
@@ -361,6 +364,10 @@ var _tile_mesh_cache_order: Array = []
|
||||
var _terrain_quality_mesh_cache: RefCounted = TERRAIN_QUALITY_MESH_CACHE_SCRIPT.new()
|
||||
var _wmo_prototype_cache: Dictionary = {}
|
||||
var _wmo_render_resource_cache_state := WMO_RENDER_RESOURCE_CACHE_STATE_SCRIPT.new()
|
||||
var _wmo_render_resource_finalizer := WMO_RENDER_RESOURCE_FINALIZER_SCRIPT.new(
|
||||
WMO_STREAMING_SCRIPT,
|
||||
WMO_STREAMING_SCRIPT.FORMAT_VERSION
|
||||
)
|
||||
var _wmo_render_build_queue := WMO_RENDER_BUILD_QUEUE_SCRIPT.new()
|
||||
var _wmo_scene_resource_cache_state := WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT.new()
|
||||
var _wmo_missing_cache: Dictionary = {}
|
||||
@@ -3725,28 +3732,9 @@ func _drain_wmo_scene_loads() -> void:
|
||||
|
||||
|
||||
func _drain_wmo_render_loads() -> void:
|
||||
var request_paths := _wmo_render_resource_cache_state.request_paths_snapshot()
|
||||
for normalized_rel_variant in request_paths.keys():
|
||||
var normalized_rel := String(normalized_rel_variant)
|
||||
var path := String(request_paths[normalized_rel])
|
||||
var status := ResourceLoader.load_threaded_get_status(path)
|
||||
if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED:
|
||||
continue
|
||||
if status != ResourceLoader.THREAD_LOAD_LOADED:
|
||||
_wmo_render_resource_cache_state.complete_request_as_missing(normalized_rel)
|
||||
continue
|
||||
var resource: Resource = ResourceLoader.load_threaded_get(path)
|
||||
if (
|
||||
resource != null
|
||||
and resource.get_script() == WMO_STREAMING_SCRIPT
|
||||
and int(resource.get("format_version")) >= WMO_STREAMING_SCRIPT.FORMAT_VERSION
|
||||
):
|
||||
_wmo_render_resource_cache_state.complete_request_with_resource(
|
||||
normalized_rel,
|
||||
resource
|
||||
)
|
||||
else:
|
||||
_wmo_render_resource_cache_state.complete_request_as_missing(normalized_rel)
|
||||
_wmo_render_resource_finalizer.poll_terminal_requests(
|
||||
_wmo_render_resource_cache_state
|
||||
)
|
||||
|
||||
|
||||
func _get_wmo_render_or_request(rel_path: String) -> Resource:
|
||||
|
||||
@@ -7,6 +7,7 @@ const CACHE_STATE_SCRIPT := preload(
|
||||
"res://src/render/wmo/wmo_render_resource_cache_state.gd"
|
||||
)
|
||||
const CACHE_STATE_PATH := "res://src/render/wmo/wmo_render_resource_cache_state.gd"
|
||||
const FINALIZER_PATH := "res://src/render/wmo/wmo_render_resource_finalizer.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
@@ -209,6 +210,7 @@ func _verify_detached_sorted_diagnostics(failures: Array[String]) -> void:
|
||||
func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
var cache_state_source := _read_text(CACHE_STATE_PATH, failures)
|
||||
var finalizer_source := _read_text(FINALIZER_PATH, failures)
|
||||
_expect_true(
|
||||
loader_source.contains("WMO_RENDER_RESOURCE_CACHE_STATE_SCRIPT.new()"),
|
||||
"loader composes cache state",
|
||||
@@ -241,13 +243,13 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void:
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
loader_source.contains("resource.get_script() == WMO_STREAMING_SCRIPT"),
|
||||
"loader retains script validation",
|
||||
finalizer_source.contains("resource.get_script() == _expected_resource_script"),
|
||||
"finalizer owns script validation",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
loader_source.contains("WMO_STREAMING_SCRIPT.FORMAT_VERSION"),
|
||||
"loader retains cache format validation",
|
||||
"loader injects cache format version",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free terminal-I/O, validation, adoption, boundary and timing regression
|
||||
## for the lightweight WMO render Resource finalizer.
|
||||
|
||||
const FINALIZER_SCRIPT := preload(
|
||||
"res://src/render/wmo/wmo_render_resource_finalizer.gd"
|
||||
)
|
||||
const CACHE_STATE_SCRIPT := preload(
|
||||
"res://src/render/wmo/wmo_render_resource_cache_state.gd"
|
||||
)
|
||||
const FINALIZER_PATH := "res://src/render/wmo/wmo_render_resource_finalizer.gd"
|
||||
const CACHE_STATE_PATH := "res://src/render/wmo/wmo_render_resource_cache_state.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
class FakeWmoRenderResource extends Resource:
|
||||
var format_version: int = 0
|
||||
|
||||
|
||||
class FakeResourceLoaderAdapter extends RefCounted:
|
||||
var status_by_path: Dictionary = {}
|
||||
var resource_by_path: Dictionary = {}
|
||||
var status_paths: Array[String] = []
|
||||
var loaded_paths: Array[String] = []
|
||||
|
||||
func load_threaded_get_status(resource_path: String) -> int:
|
||||
status_paths.append(resource_path)
|
||||
return int(status_by_path.get(
|
||||
resource_path,
|
||||
ResourceLoader.THREAD_LOAD_IN_PROGRESS
|
||||
))
|
||||
|
||||
func load_threaded_get(resource_path: String) -> Resource:
|
||||
loaded_paths.append(resource_path)
|
||||
return resource_by_path.get(resource_path) as Resource
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_null_and_non_terminal_poll(failures)
|
||||
_verify_failed_and_loaded_poll_order(failures)
|
||||
_verify_loaded_resource_rejections(failures)
|
||||
_verify_current_resource_adoption(failures)
|
||||
_verify_validation_contract(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("WMO_RENDER_RESOURCE_FINALIZER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"WMO_RENDER_RESOURCE_FINALIZER PASS cases=24 iterations=1000 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_null_and_non_terminal_poll(failures: Array[String]) -> void:
|
||||
var adapter := FakeResourceLoaderAdapter.new()
|
||||
var expected_resource := FakeWmoRenderResource.new()
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new(
|
||||
expected_resource.get_script(),
|
||||
2,
|
||||
adapter
|
||||
)
|
||||
_expect_equal(
|
||||
int(finalizer.call("poll_terminal_requests", null)),
|
||||
0,
|
||||
"null cache state is rejected",
|
||||
failures
|
||||
)
|
||||
var cache_state: RefCounted = CACHE_STATE_SCRIPT.new()
|
||||
cache_state.call(
|
||||
"remember_request",
|
||||
"world/pending.wmo",
|
||||
"res://cache/pending.res"
|
||||
)
|
||||
_expect_equal(
|
||||
int(finalizer.call("poll_terminal_requests", cache_state)),
|
||||
0,
|
||||
"non-terminal request remains pending",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(cache_state.call("has_request", "world/pending.wmo")),
|
||||
"pending request retained",
|
||||
failures
|
||||
)
|
||||
_expect_string_array(
|
||||
adapter.status_paths,
|
||||
["res://cache/pending.res"],
|
||||
"pending path polled once",
|
||||
failures
|
||||
)
|
||||
_expect_equal(adapter.loaded_paths.size(), 0, "pending request is not loaded", failures)
|
||||
|
||||
|
||||
func _verify_failed_and_loaded_poll_order(failures: Array[String]) -> void:
|
||||
var adapter := FakeResourceLoaderAdapter.new()
|
||||
var valid_resource := FakeWmoRenderResource.new()
|
||||
valid_resource.format_version = 2
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new(
|
||||
valid_resource.get_script(),
|
||||
2,
|
||||
adapter
|
||||
)
|
||||
var cache_state: RefCounted = CACHE_STATE_SCRIPT.new()
|
||||
var requests := {
|
||||
"world/pending.wmo": "res://cache/pending.res",
|
||||
"world/failed.wmo": "res://cache/failed.res",
|
||||
"world/loaded.wmo": "res://cache/loaded.res",
|
||||
}
|
||||
for normalized_relative_path in requests:
|
||||
cache_state.call(
|
||||
"remember_request",
|
||||
normalized_relative_path,
|
||||
requests[normalized_relative_path]
|
||||
)
|
||||
adapter.status_by_path = {
|
||||
"res://cache/failed.res": ResourceLoader.THREAD_LOAD_FAILED,
|
||||
"res://cache/loaded.res": ResourceLoader.THREAD_LOAD_LOADED,
|
||||
}
|
||||
adapter.resource_by_path["res://cache/loaded.res"] = valid_resource
|
||||
_expect_equal(
|
||||
int(finalizer.call("poll_terminal_requests", cache_state)),
|
||||
2,
|
||||
"failed and loaded requests complete",
|
||||
failures
|
||||
)
|
||||
_expect_string_array(
|
||||
adapter.status_paths,
|
||||
[
|
||||
"res://cache/pending.res",
|
||||
"res://cache/failed.res",
|
||||
"res://cache/loaded.res",
|
||||
],
|
||||
"detached insertion order preserved",
|
||||
failures
|
||||
)
|
||||
_expect_string_array(
|
||||
adapter.loaded_paths,
|
||||
["res://cache/loaded.res"],
|
||||
"only loaded terminal Resource retrieved",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(cache_state.call("is_missing", "world/failed.wmo")),
|
||||
"failed request publishes missing",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
cache_state.call("resource_for", "world/loaded.wmo") == valid_resource,
|
||||
"loaded Resource identity adopted",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
bool(cache_state.call("has_request", "world/pending.wmo")),
|
||||
"non-terminal sibling stays pending",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_loaded_resource_rejections(failures: Array[String]) -> void:
|
||||
var expected_resource := FakeWmoRenderResource.new()
|
||||
var adapter := FakeResourceLoaderAdapter.new()
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new(
|
||||
expected_resource.get_script(),
|
||||
2,
|
||||
adapter
|
||||
)
|
||||
var stale_resource := FakeWmoRenderResource.new()
|
||||
stale_resource.format_version = 1
|
||||
var rejected_resources := {
|
||||
"null": null,
|
||||
"wrong_script": Resource.new(),
|
||||
"stale": stale_resource,
|
||||
}
|
||||
var cache_state: RefCounted = CACHE_STATE_SCRIPT.new()
|
||||
for rejection_name in rejected_resources:
|
||||
var normalized_relative_path := "world/%s.wmo" % rejection_name
|
||||
var cache_path := "res://cache/%s.res" % rejection_name
|
||||
cache_state.call("remember_request", normalized_relative_path, cache_path)
|
||||
adapter.status_by_path[cache_path] = ResourceLoader.THREAD_LOAD_LOADED
|
||||
adapter.resource_by_path[cache_path] = rejected_resources[rejection_name]
|
||||
_expect_equal(
|
||||
int(finalizer.call("poll_terminal_requests", cache_state)),
|
||||
3,
|
||||
"all invalid loaded Resources complete",
|
||||
failures
|
||||
)
|
||||
for rejection_name in rejected_resources:
|
||||
_expect_true(
|
||||
bool(cache_state.call("is_missing", "world/%s.wmo" % rejection_name)),
|
||||
"%s Resource publishes missing" % rejection_name,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_current_resource_adoption(failures: Array[String]) -> void:
|
||||
var current_resource := FakeWmoRenderResource.new()
|
||||
current_resource.format_version = 2
|
||||
var newer_resource := FakeWmoRenderResource.new()
|
||||
newer_resource.format_version = 5
|
||||
var adapter := FakeResourceLoaderAdapter.new()
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new(
|
||||
current_resource.get_script(),
|
||||
2,
|
||||
adapter
|
||||
)
|
||||
var cache_state: RefCounted = CACHE_STATE_SCRIPT.new()
|
||||
for resource_name in ["current", "newer"]:
|
||||
var cache_path := "res://cache/%s.res" % resource_name
|
||||
cache_state.call(
|
||||
"remember_request",
|
||||
"world/%s.wmo" % resource_name,
|
||||
cache_path
|
||||
)
|
||||
adapter.status_by_path[cache_path] = ResourceLoader.THREAD_LOAD_LOADED
|
||||
adapter.resource_by_path["res://cache/current.res"] = current_resource
|
||||
adapter.resource_by_path["res://cache/newer.res"] = newer_resource
|
||||
finalizer.call("poll_terminal_requests", cache_state)
|
||||
_expect_true(
|
||||
cache_state.call("resource_for", "world/current.wmo") == current_resource,
|
||||
"current exact Resource adopted",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
cache_state.call("resource_for", "world/newer.wmo") == newer_resource,
|
||||
"newer exact Resource adopted",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_validation_contract(failures: Array[String]) -> void:
|
||||
var resource := FakeWmoRenderResource.new()
|
||||
resource.format_version = 2
|
||||
var valid_finalizer: RefCounted = FINALIZER_SCRIPT.new(resource.get_script(), 2)
|
||||
_expect_true(
|
||||
bool(valid_finalizer.call("is_current_render_resource", resource)),
|
||||
"exact script and minimum format accepted",
|
||||
failures
|
||||
)
|
||||
var missing_script_finalizer: RefCounted = FINALIZER_SCRIPT.new(null, 2)
|
||||
_expect_false(
|
||||
bool(missing_script_finalizer.call("is_current_render_resource", resource)),
|
||||
"missing expected script rejects Resource",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
bool(valid_finalizer.call("is_current_render_resource", null)),
|
||||
"null Resource rejected",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
var finalizer_source := _read_text(FINALIZER_PATH, failures)
|
||||
var cache_source := _read_text(CACHE_STATE_PATH, failures)
|
||||
_expect_true(
|
||||
loader_source.contains("WMO_RENDER_RESOURCE_FINALIZER_SCRIPT.new("),
|
||||
"loader composes finalizer",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
loader_source.contains("_wmo_render_resource_finalizer.poll_terminal_requests("),
|
||||
"loader delegates terminal polling",
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
loader_source.contains("resource.get_script() == WMO_STREAMING_SCRIPT"),
|
||||
"loader releases script validation",
|
||||
failures
|
||||
)
|
||||
for required_token in [
|
||||
"ResourceLoader.load_threaded_get_status(resource_path)",
|
||||
"ResourceLoader.load_threaded_get(resource_path)",
|
||||
"resource.get_script() == _expected_resource_script",
|
||||
"complete_request_with_resource",
|
||||
"complete_request_as_missing",
|
||||
]:
|
||||
_expect_true(
|
||||
finalizer_source.contains(required_token),
|
||||
"finalizer owns %s" % required_token,
|
||||
failures
|
||||
)
|
||||
_expect_false(
|
||||
cache_source.contains("ResourceLoader."),
|
||||
"cache state remains free of ResourceLoader",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var resource := FakeWmoRenderResource.new()
|
||||
resource.format_version = 2
|
||||
var adapter := FakeResourceLoaderAdapter.new()
|
||||
var finalizer: RefCounted = FINALIZER_SCRIPT.new(
|
||||
resource.get_script(),
|
||||
2,
|
||||
adapter
|
||||
)
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(1000):
|
||||
var cache_state: RefCounted = CACHE_STATE_SCRIPT.new()
|
||||
var normalized_relative_path := "world/object_%d.wmo" % iteration
|
||||
var cache_path := "res://cache/object_%d.res" % iteration
|
||||
cache_state.call("remember_request", normalized_relative_path, cache_path)
|
||||
adapter.status_by_path[cache_path] = ResourceLoader.THREAD_LOAD_FAILED
|
||||
finalizer.call("poll_terminal_requests", cache_state)
|
||||
var elapsed_milliseconds := float(
|
||||
Time.get_ticks_usec() - started_microseconds
|
||||
) / 1000.0
|
||||
if elapsed_milliseconds >= 1000.0:
|
||||
failures.append(
|
||||
"1000 terminal polls took %.3fms (budget < 1000ms)"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot read %s" % path)
|
||||
return ""
|
||||
var source := file.get_as_text()
|
||||
file.close()
|
||||
return source
|
||||
|
||||
|
||||
func _expect_true(value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not value:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_false(value: bool, label: String, failures: Array[String]) -> void:
|
||||
if value:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %d, got %d" % [label, expected, actual])
|
||||
|
||||
|
||||
func _expect_string_array(
|
||||
actual: Array,
|
||||
expected: Array,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if actual != expected:
|
||||
failures.append("%s: expected %s, got %s" % [label, expected, actual])
|
||||
@@ -0,0 +1 @@
|
||||
uid://corj7g3e1mush
|
||||
Reference in New Issue
Block a user