gdharness 1.3.46 → 1.3.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cli.js +27 -10
- package/build/godot/addons/gdharness_editor/scene_uids.gd +25 -13
- package/build/godot/addons/gdharness_editor/tools/animation_states.gd +20 -6
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +23 -4
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +90 -30
- package/build/godot/operations/export_presets.gd +32 -5
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -11027,9 +11027,10 @@ function rowOf(name) {
|
|
|
11027
11027
|
function rowRuns(share, row) {
|
|
11028
11028
|
return "only" in share ? row !== null && share.only.includes(row) : row === null || !share.besides.includes(row);
|
|
11029
11029
|
}
|
|
11030
|
-
function
|
|
11030
|
+
function skippedAsDeclaredIn(printed) {
|
|
11031
11031
|
const lines = printed.flatMap((text) => stripVTControlCharacters(text).split(/\r?\n/));
|
|
11032
11032
|
const found = [];
|
|
11033
|
+
const unrunnable = [];
|
|
11033
11034
|
let suite = null;
|
|
11034
11035
|
let lastCase = null;
|
|
11035
11036
|
for (let at = 0;at < lines.length; at += 1) {
|
|
@@ -11045,7 +11046,15 @@ function mismatchedRowsIn(printed) {
|
|
|
11045
11046
|
lastCase = caseLine[3] === "STARTED" ? lastCase : caseLine[2] ?? null;
|
|
11046
11047
|
continue;
|
|
11047
11048
|
}
|
|
11048
|
-
if (suite === null || lastCase === null
|
|
11049
|
+
if (suite === null || lastCase === null) {
|
|
11050
|
+
continue;
|
|
11051
|
+
}
|
|
11052
|
+
const declared = UNRUNNABLE_CASE.exec(line)?.[1];
|
|
11053
|
+
if (declared !== undefined) {
|
|
11054
|
+
unrunnable.push({ suite, name: lastCase, reason: declared });
|
|
11055
|
+
continue;
|
|
11056
|
+
}
|
|
11057
|
+
if (!MISMATCHED_ROW.test(line)) {
|
|
11049
11058
|
continue;
|
|
11050
11059
|
}
|
|
11051
11060
|
const parts = [line.trim()];
|
|
@@ -11055,7 +11064,7 @@ function mismatchedRowsIn(printed) {
|
|
|
11055
11064
|
}
|
|
11056
11065
|
found.push({ suite, name: lastCase, reason: parts.join(" ").replaceAll(/\[\/?color[^\]]*\]/g, "") });
|
|
11057
11066
|
}
|
|
11058
|
-
return found;
|
|
11067
|
+
return { mismatched: found, unrunnable };
|
|
11059
11068
|
}
|
|
11060
11069
|
function readRun(printed) {
|
|
11061
11070
|
const lines = stripVTControlCharacters(printed).replaceAll(`\r
|
|
@@ -11189,7 +11198,7 @@ function readRun(printed) {
|
|
|
11189
11198
|
leftOut,
|
|
11190
11199
|
engineVersion,
|
|
11191
11200
|
orphansLeft,
|
|
11192
|
-
|
|
11201
|
+
...skippedAsDeclaredIn(lines)
|
|
11193
11202
|
};
|
|
11194
11203
|
}
|
|
11195
11204
|
function withNestedErrors(printed, piped) {
|
|
@@ -11314,7 +11323,7 @@ function judgeEngine(printed, handed, end) {
|
|
|
11314
11323
|
if (printed.executed === null || printed.executed === 0) {
|
|
11315
11324
|
faults.push(printed.executed === null ? "the run never printed its summary" : "the run executed no test case at all, whatever it exited with");
|
|
11316
11325
|
}
|
|
11317
|
-
for (const row of printed.mismatched) {
|
|
11326
|
+
for (const row of [...printed.mismatched, ...printed.unrunnable]) {
|
|
11318
11327
|
faults.push(`${row.suite} > ${row.name} never ran: gdUnit4 skipped it, saying "${row.reason}"`);
|
|
11319
11328
|
failing.add(row.suite);
|
|
11320
11329
|
}
|
|
@@ -11481,7 +11490,7 @@ ${String(error.warning)}`);
|
|
|
11481
11490
|
const neverPrinted = [...owed.values()].flatMap((debt) => debt.texts.map((text) => ({ suite: debt.suite, during: debt.during, text, warning: debt.warning })));
|
|
11482
11491
|
return { printed: unexpected, neverPrinted };
|
|
11483
11492
|
}
|
|
11484
|
-
var SUITE_OPENED, TIME, CASE_LINE, STATISTICS, ERROR_LINE, WARNING_LINE, EXECUTED, MISMATCHED_ROW, BANNER, LEFT_OUT, DRIVER_FALLBACK, MACHINE_WARNED, ORPHANS_REPORTED, STARVED, NESTED_ERROR = "While attempting to print an error, another error was printed:", WINDOWS_EXITS, MEMORY_EXITS, GDUNIT_FAILED = 100, GDUNIT_ORPHANS = 101, GDUNIT_EXITS, GODOT_EXITS, TAIL = 60, MOST_NAMED = 8;
|
|
11493
|
+
var SUITE_OPENED, TIME, CASE_LINE, STATISTICS, ERROR_LINE, WARNING_LINE, EXECUTED, MISMATCHED_ROW, UNRUNNABLE_CASE, BANNER, LEFT_OUT, DRIVER_FALLBACK, MACHINE_WARNED, ORPHANS_REPORTED, STARVED, NESTED_ERROR = "While attempting to print an error, another error was printed:", WINDOWS_EXITS, MEMORY_EXITS, GDUNIT_FAILED = 100, GDUNIT_ORPHANS = 101, GDUNIT_EXITS, GODOT_EXITS, TAIL = 60, MOST_NAMED = 8;
|
|
11485
11494
|
var init_suite_judge = __esm(() => {
|
|
11486
11495
|
init_engine_leaks();
|
|
11487
11496
|
init_junit();
|
|
@@ -11493,6 +11502,7 @@ var init_suite_judge = __esm(() => {
|
|
|
11493
11502
|
WARNING_LINE = /^\s*(?:SCRIPT |USER )?WARNING: (.*?)\s*$/;
|
|
11494
11503
|
EXECUTED = /^Executed test cases\s*:\s*\((\d+)\//;
|
|
11495
11504
|
MISMATCHED_ROW = /^\s*The test data set at index \(\d+\) does not match the expected test parameters/;
|
|
11505
|
+
UNRUNNABLE_CASE = /^\s*Reason: '((?:Unknown test case argument's \[.*\] found|Fuzzer argument '.+' must be declared before the test config argument\(s\) \[.*\])\.)'\s*$/;
|
|
11496
11506
|
BANNER = /^\s*Godot Engine v(\S+?)(?:\s+-\s+\S+)?\s*$/;
|
|
11497
11507
|
LEFT_OUT = /^gdharness left out (res:\/\/.+?\.gd) > ([^:\s]+):(-?\d+)\s*$/;
|
|
11498
11508
|
DRIVER_FALLBACK = /^\s*WARNING: Your video card drivers .*, switching to /;
|
|
@@ -17130,7 +17140,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
17130
17140
|
path: {
|
|
17131
17141
|
type: ["string", "array"],
|
|
17132
17142
|
items: { type: "string" },
|
|
17133
|
-
description: 'A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. An entry "suite.gd:test_name" runs that case alone, as gdharness test reads it and as tests runs it, so it cannot share the list with a whole suite or a directory. An entry that is not there is left out and named under missing, the rest run, and such a run is never called passed; when no entry is there the run is refused before the engine starts, naming each one. A script named that declares no test_ function at the top level is refused the same way, and so is a directory holding no suite. A script that declares test_ functions and does not extend GdUnitTestSuite, which gdUnit4 never runs and never mentions, is refused when named, and under a directory run whole it is named under notSuites and the run is not called passed. A case declared there that gdUnit4 never ran, in a suite that passed, comes back under testsNotRun,
|
|
17143
|
+
description: 'A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. An entry "suite.gd:test_name" runs that case alone, as gdharness test reads it and as tests runs it, so it cannot share the list with a whole suite or a directory. An entry that is not there is left out and named under missing, the rest run, and such a run is never called passed; when no entry is there the run is refused before the engine starts, naming each one. A script named that declares no test_ function at the top level is refused the same way, and so is a directory holding no suite. A script that declares test_ functions and does not extend GdUnitTestSuite, which gdUnit4 never runs and never mentions, is refused when named, and under a directory run whole it is named under notSuites and the run is not called passed. A case declared there that gdUnit4 never ran, in a suite that passed, comes back under testsNotRun, a row it skipped for data that does not fit the case under rowsMismatched, and a case it skipped because it cannot run it as declared (a parameter nothing fills, or a fuzzer after a setting) under casesUnrunnable, each with the reason gdUnit4 gave; none of those runs is called passed. Default tests and test, those the project has, which is where gdharness test looks.'
|
|
17134
17144
|
},
|
|
17135
17145
|
ignore: {
|
|
17136
17146
|
type: "array",
|
|
@@ -18620,7 +18630,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
18620
18630
|
says: {
|
|
18621
18631
|
type: "string",
|
|
18622
18632
|
ops: ["until"],
|
|
18623
|
-
description: 'until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Matched the way runtime_inspect find matches says: case-insensitive, a plain word anywhere in the text, a pattern with * or ? a glob over the whole of it, and alternatives separated by | met by whichever appears first ("*won*|*lost*"); on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took, and says under looking_ms how long the looks took between them.'
|
|
18633
|
+
description: 'until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Matched the way runtime_inspect find matches says: case-insensitive, a plain word anywhere in the text, a pattern with * or ? a glob over the whole of it, and alternatives separated by | met by whichever appears first ("*won*|*lost*"); on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property. It looks every frame while a look is cheap and less often on a screen large enough for a look to cost a real share of a frame, so the game keeps its speed; the answer counts the frames the wait spanned and the looks it took, and says under looking_ms how long the looks took between them. A look on a large screen is read about 2ms to a frame, so no frame grows by more, except the last look as the time runs out, which is read whole; longest_hold_ms is the longest the looks held one frame, which tells a long frame the game logs apart from one the wait caused.'
|
|
18624
18634
|
},
|
|
18625
18635
|
includeHidden: {
|
|
18626
18636
|
type: "boolean",
|
|
@@ -53509,10 +53519,14 @@ ${error.text}`;
|
|
|
53509
53519
|
test: `${row.suite} > ${row.name}`,
|
|
53510
53520
|
reason: row.reason
|
|
53511
53521
|
}));
|
|
53522
|
+
const casesUnrunnable = casePrinted.unrunnable.map((one) => ({
|
|
53523
|
+
test: `${one.suite} > ${one.name}`,
|
|
53524
|
+
reason: one.reason
|
|
53525
|
+
}));
|
|
53512
53526
|
return this.jsonTextResponse({
|
|
53513
|
-
passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && undeclaredPrinted.length === 0 && declaredNotPrinted.length === 0 && countedLeaks.length === 0 && missing.length === 0 && testsNotRun.length === 0 && casesNotRun.length === 0 && rowsMismatched.length === 0 && notSuites.length === 0,
|
|
53527
|
+
passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && uncaught.length === 0 && undeclaredPrinted.length === 0 && declaredNotPrinted.length === 0 && countedLeaks.length === 0 && missing.length === 0 && testsNotRun.length === 0 && casesNotRun.length === 0 && rowsMismatched.length === 0 && casesUnrunnable.length === 0 && notSuites.length === 0,
|
|
53514
53528
|
verdict: [
|
|
53515
|
-
verdict === "passed" && undeclaredPrinted.length + declaredNotPrinted.length + countedLeaks.length + testsNotRun.length + casesNotRun.length + rowsMismatched.length + notSuites.length > 0 ? undefined : verdict,
|
|
53529
|
+
verdict === "passed" && undeclaredPrinted.length + declaredNotPrinted.length + countedLeaks.length + testsNotRun.length + casesNotRun.length + rowsMismatched.length + casesUnrunnable.length + notSuites.length > 0 ? undefined : verdict,
|
|
53516
53530
|
...[false, true].map((warning) => {
|
|
53517
53531
|
const count = undeclaredPrinted.filter((error) => error.warning === warning).length;
|
|
53518
53532
|
const word = warning ? "warning" : "error";
|
|
@@ -53526,12 +53540,14 @@ ${error.text}`;
|
|
|
53526
53540
|
countedLeaks.length > 0 ? "the engine exited holding what it should have freed" : undefined,
|
|
53527
53541
|
testsNotRun.length + casesNotRun.length > 0 ? `not run: ${[...testsNotRun, ...casesNotRun].join(", ")}` : undefined,
|
|
53528
53542
|
rowsMismatched.length > 0 ? `${rowsMismatched.length === 1 ? "one row" : `${String(rowsMismatched.length)} rows`} skipped for data that does not fit the case` : undefined,
|
|
53543
|
+
casesUnrunnable.length > 0 ? `${casesUnrunnable.length === 1 ? "one case" : `${String(casesUnrunnable.length)} cases`} skipped as gdUnit4 cannot run ${casesUnrunnable.length === 1 ? "it" : "them"} as declared` : undefined,
|
|
53529
53544
|
notSuites.length > 0 ? `${notSuites.length === 1 ? "one script declares" : `${String(notSuites.length)} scripts declare`} cases gdUnit4 never runs` : undefined
|
|
53530
53545
|
].filter((part) => part !== undefined).join("; "),
|
|
53531
53546
|
...missing.length > 0 ? { missing } : {},
|
|
53532
53547
|
...notSuites.length > 0 ? { notSuites } : {},
|
|
53533
53548
|
...testsNotRun.length + casesNotRun.length > 0 ? { testsNotRun: [...testsNotRun, ...casesNotRun] } : {},
|
|
53534
53549
|
...rowsMismatched.length > 0 ? { rowsMismatched } : {},
|
|
53550
|
+
...casesUnrunnable.length > 0 ? { casesUnrunnable } : {},
|
|
53535
53551
|
...timedOut ? { timedOut, hung, silentForMs } : {},
|
|
53536
53552
|
exitCode,
|
|
53537
53553
|
exitSignal: run.exitSignal ?? undefined,
|
|
@@ -53565,6 +53581,7 @@ ${error.text}`;
|
|
|
53565
53581
|
testsNotRun.length > 0 ? `${testsNotRun.join(", ")} ${testsNotRun.length === 1 ? "was" : "were"} asked for and not run, so the run is not called passed: gdUnit4 runs a test_ function only in a script that extends GdUnitTestSuite, and leaves out a suite whose script does not load.` : undefined,
|
|
53566
53582
|
casesNotRun.length > 0 ? `${casesNotRun.join(", ")} ${casesNotRun.length === 1 ? "is a case" : "are cases"} of a suite that ran and passed, and gdUnit4 never ran ${casesNotRun.length === 1 ? "it" : "them"} and said nothing of it, so the run is not called passed, as gdharness test judges it.` : undefined,
|
|
53567
53583
|
rowsMismatched.length > 0 ? `gdUnit4 skipped what rowsMismatched names because the row's data does not fit the case's parameters, though it printed ${rowsMismatched.length === 1 ? "the row" : "each row"} passed first; each entry carries gdUnit4's reason. The run is not called passed, as gdharness test judges it.` : undefined,
|
|
53584
|
+
casesUnrunnable.length > 0 ? `gdUnit4 skipped what casesUnrunnable names because it cannot run ${casesUnrunnable.length === 1 ? "that case" : "those cases"} as declared, though it printed ${casesUnrunnable.length === 1 ? "it" : "each"} passed first; each entry carries gdUnit4's reason. A parameter is filled only by _test_parameters, a fuzzer or a setting gdUnit4 names (timeout, do_skip, skip_reason, fuzzer_iterations, fuzzer_seed), and fuzzers come before the settings. The run is not called passed, as gdharness test judges it.` : undefined,
|
|
53568
53585
|
notSuites.length > 0 ? "What notSuites names declares cases gdUnit4 never runs, so the run is not called passed, as gdharness test judges it." : undefined,
|
|
53569
53586
|
briefed > 0 ? `${briefed} of the ${failed.length} failing cases, those with the largest reports, are named with their first message only, since their reports would have made the answer too large to deliver; pass one case's suite path to see its reports.` : undefined,
|
|
53570
53587
|
notApplied.length > 0 ? "An ignore entry named a case that ran all the same: gdUnit4 leaves a case out only as suite_name:test_name, and reads an entry with no colon as a suite name or a piece of a suite path. ignoreNotApplied gives the form for each case that ran." : undefined,
|
|
@@ -25,29 +25,41 @@ static func save_keeping_uid(resource: Resource, file_path: String) -> Error:
|
|
|
25
25
|
var marked: Error = ResourceSaver.set_uid(file_path, uid)
|
|
26
26
|
if marked != OK:
|
|
27
27
|
return marked
|
|
28
|
-
return
|
|
28
|
+
return _as_the_editor_writes(file_path)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
##
|
|
32
|
-
##
|
|
33
|
-
##
|
|
34
|
-
|
|
31
|
+
## Writes the text file at [param file_path] as the editor's save would have.
|
|
32
|
+
##
|
|
33
|
+
## Only the editor's file system tells the saver a path's uid, so a headless save writes every
|
|
34
|
+
## reference by path alone, and each breaks when the file it names is moved: the uid is put back on
|
|
35
|
+
## each external resource.
|
|
36
|
+
##
|
|
37
|
+
## The saver leaves out a shader parameter holding the default the renderer gives it, and a headless
|
|
38
|
+
## engine's renderer gives none, so every parameter a ShaderMaterial never set was written as null.
|
|
39
|
+
## Loading null clears the parameter, so the line changes nothing, and the editor drops it again.
|
|
40
|
+
static func _as_the_editor_writes(file_path: String) -> Error:
|
|
35
41
|
if not (file_path.ends_with(".tscn") or file_path.ends_with(".tres")):
|
|
36
42
|
return OK
|
|
37
43
|
var lines: PackedStringArray = FileAccess.get_file_as_string(file_path).split("\n")
|
|
38
44
|
var pattern: RegEx = RegEx.create_from_string(' path="([^"]+)"')
|
|
45
|
+
var unset: RegEx = RegEx.create_from_string("^shader_parameter/\\S+ = null$")
|
|
46
|
+
var kept: PackedStringArray = []
|
|
39
47
|
var changed: bool = false
|
|
40
|
-
for
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
continue
|
|
44
|
-
var named: RegExMatch = pattern.search(line)
|
|
45
|
-
if named == null:
|
|
48
|
+
for line: String in lines:
|
|
49
|
+
if unset.search(line) != null:
|
|
50
|
+
changed = true
|
|
46
51
|
continue
|
|
47
|
-
var
|
|
52
|
+
var named: RegExMatch = (
|
|
53
|
+
pattern.search(line)
|
|
54
|
+
if line.begins_with("[ext_resource ") and not line.contains(' uid="')
|
|
55
|
+
else null
|
|
56
|
+
)
|
|
57
|
+
var uid: String = "" if named == null else reference_uid(named.get_string(1))
|
|
48
58
|
if not uid.is_empty():
|
|
49
|
-
|
|
59
|
+
line = line.replace(' path="', uid + ' path="')
|
|
50
60
|
changed = true
|
|
61
|
+
var _added: bool = kept.append(line)
|
|
62
|
+
lines = kept
|
|
51
63
|
if not changed:
|
|
52
64
|
return OK
|
|
53
65
|
var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE)
|
|
@@ -137,13 +137,26 @@ func connect_animation_states(args: Dictionary) -> Dictionary:
|
|
|
137
137
|
sm.add_transition(StringName(from_state), StringName(to_state), transition)
|
|
138
138
|
if not sm.has_transition(StringName(from_state), StringName(to_state)):
|
|
139
139
|
return scene.refuse("The state machine did not take the transition.")
|
|
140
|
+
_tell_the_tree(sm)
|
|
140
141
|
var files: Dictionary = found[2]
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
var made: Dictionary = {
|
|
143
|
+
"ok": true,
|
|
144
|
+
"from": from_state,
|
|
145
|
+
"to": to_state,
|
|
146
|
+
"transitionType": transition_type,
|
|
147
|
+
"advanceMode": advance_mode,
|
|
148
|
+
}
|
|
149
|
+
if not advance_condition.is_empty():
|
|
150
|
+
made["advanceCondition"] = advance_condition
|
|
151
|
+
return _finish(scene, Callable(), files, made)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
## Has the AnimationTree playing [param sm] list its parameters again. Adding or removing a
|
|
155
|
+
## transition does not say the tree changed, so the save kept the parameters from before it: a
|
|
156
|
+
## condition's parameter was written by the next call to change the machine, and a removed one's
|
|
157
|
+
## stayed until then.
|
|
158
|
+
static func _tell_the_tree(sm: AnimationNodeStateMachine) -> void:
|
|
159
|
+
var _told: Error = sm.emit_signal(&"tree_changed")
|
|
147
160
|
|
|
148
161
|
|
|
149
162
|
## Why [param sm] cannot take a transition from [param from_state] to [param to_state], or "".
|
|
@@ -270,6 +283,7 @@ func disconnect_animation_states(args: Dictionary) -> Dictionary:
|
|
|
270
283
|
sm.remove_transition(StringName(from_state), StringName(to_state))
|
|
271
284
|
if sm.has_transition(StringName(from_state), StringName(to_state)):
|
|
272
285
|
return scene.refuse("The state machine did not let go of the transition.")
|
|
286
|
+
_tell_the_tree(sm)
|
|
273
287
|
var files: Dictionary = found[2]
|
|
274
288
|
return _finish(
|
|
275
289
|
scene, Callable(), files, { "ok": true, "removed": { "from": from_state, "to": to_state } }
|
|
@@ -718,6 +718,15 @@ func set_node_properties(args: Dictionary) -> Dictionary:
|
|
|
718
718
|
if property != "name" and property in node and not named.has(property):
|
|
719
719
|
named.append(property)
|
|
720
720
|
var source: String = scene.values_from(node, old_path)
|
|
721
|
+
# Read before the write, which frees the node.
|
|
722
|
+
var unsaved: Array[String] = []
|
|
723
|
+
for declared: Dictionary in node.get_property_list():
|
|
724
|
+
var declared_name: String = str(declared.get("name", ""))
|
|
725
|
+
if (
|
|
726
|
+
named.has(declared_name)
|
|
727
|
+
and not GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE
|
|
728
|
+
):
|
|
729
|
+
unsaved.append(declared_name)
|
|
721
730
|
var carried: GdhPaths = GdhPaths.new()
|
|
722
731
|
var _rewritten: int = carried.carry(
|
|
723
732
|
scene,
|
|
@@ -751,7 +760,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
|
|
|
751
760
|
answer["droppedNodes"] = dropped["nodes"]
|
|
752
761
|
_note_put_back(answer, scene, id, new_path)
|
|
753
762
|
_note_loads_as(answer, scene, new_path)
|
|
754
|
-
_note_left_out(answer, scene.written_pack, new_path, named, source)
|
|
763
|
+
_note_left_out(answer, scene.written_pack, new_path, named, unsaved, source)
|
|
755
764
|
if new_path != old_path:
|
|
756
765
|
answer.merge(GdhKin.follow(scene_path, old_path, new_path, carried))
|
|
757
766
|
return answer
|
|
@@ -793,9 +802,15 @@ static func _note_put_back(answer: Dictionary, scene: GdhSceneFile, id: int, at:
|
|
|
793
802
|
|
|
794
803
|
## Notes under notStored each of [param named] the file saved from [param pack] does not set on the
|
|
795
804
|
## node at [param at]: a set gave it the value the node has without it, so the packer leaves it out,
|
|
796
|
-
## as the editor does, and it follows [param source], or its default, from then on.
|
|
805
|
+
## as the editor does, and it follows [param source], or its default, from then on. [param unsaved]
|
|
806
|
+
## are those of them the node never stores by name.
|
|
797
807
|
static func _note_left_out(
|
|
798
|
-
answer: Dictionary,
|
|
808
|
+
answer: Dictionary,
|
|
809
|
+
pack: PackedScene,
|
|
810
|
+
at: String,
|
|
811
|
+
named: Array[String],
|
|
812
|
+
unsaved: Array[String],
|
|
813
|
+
source: String
|
|
799
814
|
) -> void:
|
|
800
815
|
var stored: Dictionary = {}
|
|
801
816
|
var state: SceneState = pack.get_state()
|
|
@@ -803,9 +818,13 @@ static func _note_left_out(
|
|
|
803
818
|
if str(state.get_node_path(index)).trim_prefix("./") == at:
|
|
804
819
|
for property: int in state.get_node_property_count(index):
|
|
805
820
|
stored[str(state.get_node_property_name(index, property))] = true
|
|
821
|
+
# A property the file never holds by name, as a Control's position, which its offsets hold, is
|
|
822
|
+
# stored when what changed with it is.
|
|
823
|
+
var also: Dictionary = answer.get("alsoChanged", {})
|
|
824
|
+
var held_with_it: bool = also.keys().any(func(other: Variant) -> bool: return stored.has(other))
|
|
806
825
|
var left: Array[String] = []
|
|
807
826
|
for asked: String in named:
|
|
808
|
-
if not stored.has(asked):
|
|
827
|
+
if not stored.has(asked) and not (held_with_it and unsaved.has(asked)):
|
|
809
828
|
left.append(asked)
|
|
810
829
|
if left.is_empty():
|
|
811
830
|
return
|
|
@@ -17,6 +17,11 @@ const MOST_FRAMES: int = 600
|
|
|
17
17
|
## How long a wait for words leaves between looks, as a multiple of what the last look took: nine
|
|
18
18
|
## gives looking about a tenth of the time.
|
|
19
19
|
const LOOKS_APART: int = 9
|
|
20
|
+
## The longest a wait for words holds one of the game's frames while it looks, in microseconds: a
|
|
21
|
+
## look over a screen too large to read in that time carries on in the next frame from where it
|
|
22
|
+
## stopped. Read whole, a look over an interface of about 1,000 labels held a frame for 28ms every
|
|
23
|
+
## few dozen, which a game logging its own frame times took for a hitch of its own.
|
|
24
|
+
const LOOK_SLICE_USEC: int = 2000
|
|
20
25
|
## How a property wait compares what it reads with the value: equality by default, and order for a
|
|
21
26
|
## number that steps rather than lands, as a day counter on a fast clock goes from 31 to 34 between
|
|
22
27
|
## two looks and a wait for 32 never sees it.
|
|
@@ -785,9 +790,12 @@ static func _declared_slot(holder: Variant, named: String) -> Dictionary:
|
|
|
785
790
|
## looking takes about a tenth of the time. Every frame on a hall of 3,500 nodes held the game at a
|
|
786
791
|
## sixth of its frame rate, and its clock, which advances by the frame's delta, lost three quarters
|
|
787
792
|
## of its time, so a wait for a date timed out on a clock that turned a day every thirty seconds.
|
|
788
|
-
##
|
|
789
|
-
##
|
|
790
|
-
##
|
|
793
|
+
## Each look is read a slice at a time, [constant LOOK_SLICE_USEC] to a frame, so the frames it runs
|
|
794
|
+
## in keep their length; the last is read whole, since the wait answers on it.
|
|
795
|
+
##
|
|
796
|
+
## The answer says how many frames the wait spanned, how many looks it took, how long they took
|
|
797
|
+
## between them and the longest they held one frame, which is how a caller sees what it cost and
|
|
798
|
+
## whether the game lost its speed, or a frame its length, to the looks or to something else.
|
|
791
799
|
func _wait_until_said(
|
|
792
800
|
node_path: String, said: String, timeout_ms: int, include_hidden: bool
|
|
793
801
|
) -> Dictionary:
|
|
@@ -795,18 +803,29 @@ func _wait_until_said(
|
|
|
795
803
|
var frames: int = 0
|
|
796
804
|
var looks: int = 0
|
|
797
805
|
var looked_usec: int = 0
|
|
806
|
+
var held_usec: int = 0
|
|
798
807
|
var next_look: int = 0
|
|
799
808
|
var looked_at_frame: int = 0
|
|
800
809
|
var found: bool = false
|
|
810
|
+
var look: GdhLook = null
|
|
811
|
+
var look_began: int = 0
|
|
801
812
|
while true:
|
|
802
|
-
if Time.get_ticks_usec() >= next_look:
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
var
|
|
807
|
-
looked_usec +=
|
|
808
|
-
|
|
813
|
+
if look == null and Time.get_ticks_usec() >= next_look:
|
|
814
|
+
look = GdhLook.new(_under(node_path), said, include_hidden)
|
|
815
|
+
look_began = Time.get_ticks_usec()
|
|
816
|
+
if look != null:
|
|
817
|
+
var held: int = look.go_on(LOOK_SLICE_USEC)
|
|
818
|
+
looked_usec += held
|
|
819
|
+
held_usec = maxi(held_usec, held)
|
|
809
820
|
looked_at_frame = frames
|
|
821
|
+
if look.done:
|
|
822
|
+
looks += 1
|
|
823
|
+
found = look.found
|
|
824
|
+
# From where the look began, since the frames it was read across are the game's:
|
|
825
|
+
# counted from its end, a look spread over a third of a second waited out its nine
|
|
826
|
+
# lengths after that, and a wait on 3,500 nodes looked four times in two seconds.
|
|
827
|
+
next_look = look_began + look.took_usec * (LOOKS_APART + 1)
|
|
828
|
+
look = null
|
|
810
829
|
if found or Time.get_ticks_msec() - started >= timeout_ms:
|
|
811
830
|
break
|
|
812
831
|
await _host.get_tree().process_frame
|
|
@@ -815,14 +834,18 @@ func _wait_until_said(
|
|
|
815
834
|
# never having come. Taken once a whole frame has run since the last look, which is two frame
|
|
816
835
|
# starts: a first look that outlasted the timeout otherwise left none, so the wait answered with
|
|
817
836
|
# the game not having run at all, and a command sent during that look not yet read.
|
|
837
|
+
var heard: bool = found
|
|
818
838
|
if not found:
|
|
819
839
|
while frames - looked_at_frame < 2:
|
|
820
840
|
await _host.get_tree().process_frame
|
|
821
841
|
frames += 1
|
|
822
|
-
var last:
|
|
823
|
-
|
|
842
|
+
var last: GdhLook = GdhLook.new(_under(node_path), said, include_hidden)
|
|
843
|
+
var held: int = last.go_on(-1)
|
|
824
844
|
looks += 1
|
|
825
|
-
looked_usec +=
|
|
845
|
+
looked_usec += held
|
|
846
|
+
held_usec = maxi(held_usec, held)
|
|
847
|
+
found = last.found
|
|
848
|
+
heard = last.heard
|
|
826
849
|
|
|
827
850
|
var answer: Dictionary = {
|
|
828
851
|
"type": "condition",
|
|
@@ -834,10 +857,11 @@ func _wait_until_said(
|
|
|
834
857
|
"frames": frames,
|
|
835
858
|
"looks": looks,
|
|
836
859
|
"looking_ms": looked_usec / 1000.0,
|
|
860
|
+
"longest_hold_ms": held_usec / 1000.0,
|
|
837
861
|
}
|
|
838
862
|
# Words that are there and cannot be read look, from a wait that ran out, like words that never
|
|
839
863
|
# came.
|
|
840
|
-
if not found and
|
|
864
|
+
if not found and heard:
|
|
841
865
|
answer["unseen_note"] = (
|
|
842
866
|
(
|
|
843
867
|
"Something under %s says it, but is hidden or drawn outside the viewport or a "
|
|
@@ -849,22 +873,8 @@ func _wait_until_said(
|
|
|
849
873
|
return answer
|
|
850
874
|
|
|
851
875
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
## `says`, a glob over the whole text when it has `*` or `?`: read as literal words, a glob a click
|
|
855
|
-
## had just pressed by was waited on for the whole timeout with its label on screen, and answered as
|
|
856
|
-
## words never arriving.
|
|
857
|
-
func _anything_says(node_path: String, said: String, include_hidden: bool) -> bool:
|
|
858
|
-
var root: Node = _host.get_tree().root.get_node_or_null(node_path)
|
|
859
|
-
if root == null:
|
|
860
|
-
return false
|
|
861
|
-
var pending: Array[Node] = [root]
|
|
862
|
-
while not pending.is_empty():
|
|
863
|
-
var node: Node = pending.pop_back()
|
|
864
|
-
if GdhSays.says(node, said) and (include_hidden or GdhQueries.readable(node)):
|
|
865
|
-
return true
|
|
866
|
-
pending.append_array(node.get_children(true))
|
|
867
|
-
return false
|
|
876
|
+
func _under(node_path: String) -> Node:
|
|
877
|
+
return _host.get_tree().root.get_node_or_null(node_path)
|
|
868
878
|
|
|
869
879
|
|
|
870
880
|
## Remembers that a signal fired and what it carried, for a wait that polls rather than
|
|
@@ -942,6 +952,56 @@ class GdhTrace:
|
|
|
942
952
|
dropped += 1
|
|
943
953
|
|
|
944
954
|
|
|
955
|
+
## One look for whether anything under a node says some words, the node itself included, and where
|
|
956
|
+
## the player can read it unless hidden ones are wanted too. Matched as a find and a click match
|
|
957
|
+
## `says`, a glob over the whole text when it has `*` or `?`: read as literal words, a glob a click
|
|
958
|
+
## had just pressed by was waited on for the whole timeout with its label on screen, and answered as
|
|
959
|
+
## words never arriving.
|
|
960
|
+
class GdhLook:
|
|
961
|
+
extends RefCounted
|
|
962
|
+
## Whether a node that counts says the words.
|
|
963
|
+
var found: bool = false
|
|
964
|
+
## Whether any node says them, hidden or not: words that are there and cannot be read look,
|
|
965
|
+
## from a wait that ran out, like words that never came.
|
|
966
|
+
var heard: bool = false
|
|
967
|
+
var done: bool = false
|
|
968
|
+
## How long the look has taken over every frame it has run in.
|
|
969
|
+
var took_usec: int = 0
|
|
970
|
+
var _said: String
|
|
971
|
+
var _include_hidden: bool
|
|
972
|
+
## Untyped, since a node waiting here can be freed between two frames of the look.
|
|
973
|
+
var _pending: Array = []
|
|
974
|
+
|
|
975
|
+
func _init(root: Node, said: String, include_hidden: bool) -> void:
|
|
976
|
+
_said = said
|
|
977
|
+
_include_hidden = include_hidden
|
|
978
|
+
if root != null:
|
|
979
|
+
_pending.append(root)
|
|
980
|
+
|
|
981
|
+
## Reads on until the look is done or [param budget_usec] has passed, -1 for no limit, and
|
|
982
|
+
## answers how long it read.
|
|
983
|
+
func go_on(budget_usec: int) -> int:
|
|
984
|
+
var from: int = Time.get_ticks_usec()
|
|
985
|
+
while not _pending.is_empty():
|
|
986
|
+
if budget_usec >= 0 and Time.get_ticks_usec() - from >= budget_usec:
|
|
987
|
+
break
|
|
988
|
+
var next: Variant = _pending.pop_back()
|
|
989
|
+
if not is_instance_valid(next):
|
|
990
|
+
continue
|
|
991
|
+
var node: Node = next
|
|
992
|
+
if GdhSays.says(node, _said):
|
|
993
|
+
heard = true
|
|
994
|
+
if _include_hidden or GdhQueries.readable(node):
|
|
995
|
+
found = true
|
|
996
|
+
_pending.clear()
|
|
997
|
+
break
|
|
998
|
+
_pending.append_array(node.get_children(true))
|
|
999
|
+
done = _pending.is_empty()
|
|
1000
|
+
var read: int = Time.get_ticks_usec() - from
|
|
1001
|
+
took_usec += read
|
|
1002
|
+
return read
|
|
1003
|
+
|
|
1004
|
+
|
|
945
1005
|
## A signal recorded from when it was armed until it is collected.
|
|
946
1006
|
class GdhWatch:
|
|
947
1007
|
extends RefCounted
|
|
@@ -121,6 +121,26 @@ static func _runnable(config: ConfigFile, section: String) -> bool:
|
|
|
121
121
|
return GdhRead.as_bool(config.get_value(section, "runnable", false))
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
## The name of the preset [param platform] runs, or "" when it has none: the one its section names,
|
|
125
|
+
## or in a file saved before that section, the last preset of the platform keeping the key, which is
|
|
126
|
+
## the one the editor ends up running. Read from the section alone, a file of that age had no
|
|
127
|
+
## runnable preset, so a preset added for its platform took the place of the one it had.
|
|
128
|
+
static func _runnable_name(config: ConfigFile, platform: String) -> String:
|
|
129
|
+
if config.has_section_key(RUNNABLE_SECTION, platform):
|
|
130
|
+
return str(config.get_value(RUNNABLE_SECTION, platform))
|
|
131
|
+
var named: String = ""
|
|
132
|
+
var at: int = 0
|
|
133
|
+
while config.has_section("preset." + str(at)):
|
|
134
|
+
var section: String = "preset." + str(at)
|
|
135
|
+
if (
|
|
136
|
+
str(config.get_value(section, "platform", "")) == platform
|
|
137
|
+
and GdhRead.as_bool(config.get_value(section, "runnable", false))
|
|
138
|
+
):
|
|
139
|
+
named = str(config.get_value(section, "name", ""))
|
|
140
|
+
at += 1
|
|
141
|
+
return named
|
|
142
|
+
|
|
143
|
+
|
|
124
144
|
## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
|
|
125
145
|
## writes for a new one, and every option in [code]known_options[/code] (what
|
|
126
146
|
## [method export_options] answers for the platform) at its default. The editor also writes the
|
|
@@ -160,7 +180,7 @@ func add_export_preset(params: Dictionary) -> Dictionary:
|
|
|
160
180
|
)
|
|
161
181
|
index += 1
|
|
162
182
|
# A platform with no preset to run takes the new one, as the editor's dialog does.
|
|
163
|
-
var runnable: bool =
|
|
183
|
+
var runnable: bool = _runnable_name(config, platform).is_empty()
|
|
164
184
|
if runnable:
|
|
165
185
|
config.set_value(RUNNABLE_SECTION, platform, preset_name)
|
|
166
186
|
|
|
@@ -609,6 +629,7 @@ func remove_export_preset(params: Dictionary) -> Dictionary:
|
|
|
609
629
|
var index: int = found["index"]
|
|
610
630
|
var section: String = found["section"]
|
|
611
631
|
var platform: String = str(config.get_value(section, "platform", ""))
|
|
632
|
+
var was_runnable: bool = _runnable_name(config, platform) == preset_name
|
|
612
633
|
var last: int = index
|
|
613
634
|
while config.has_section("preset." + str(last + 1)):
|
|
614
635
|
last += 1
|
|
@@ -622,11 +643,17 @@ func remove_export_preset(params: Dictionary) -> Dictionary:
|
|
|
622
643
|
if keeping != OK:
|
|
623
644
|
return _log.failure("Failed to save %s: %s" % [CREDENTIALS_FILE, error_string(keeping)])
|
|
624
645
|
answer["credentials"] = CREDENTIALS_FILE
|
|
625
|
-
if
|
|
626
|
-
config.
|
|
627
|
-
|
|
646
|
+
if was_runnable:
|
|
647
|
+
if config.has_section_key(RUNNABLE_SECTION, platform):
|
|
648
|
+
config.set_value(RUNNABLE_SECTION, platform, null)
|
|
649
|
+
# A file saved before the section can mark another preset of the platform too, which the
|
|
650
|
+
# editor then runs.
|
|
651
|
+
var kept: String = _runnable_name(config, platform)
|
|
652
|
+
answer["runnableNow"] = kept
|
|
653
|
+
if kept.is_empty():
|
|
654
|
+
answer["runnableNow"] = null
|
|
628
655
|
var at: int = 0
|
|
629
|
-
while config.has_section("preset." + str(at)):
|
|
656
|
+
while kept.is_empty() and config.has_section("preset." + str(at)):
|
|
630
657
|
if str(config.get_value("preset." + str(at), "platform", "")) == platform:
|
|
631
658
|
var next: String = str(config.get_value("preset." + str(at), "name", ""))
|
|
632
659
|
config.set_value(RUNNABLE_SECTION, platform, next)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.47",
|
|
4
4
|
"mcpName": "io.github.Aureliolo/gdharness",
|
|
5
5
|
"description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
|
|
6
6
|
"type": "module",
|