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 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 mismatchedRowsIn(printed) {
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 || !MISMATCHED_ROW.test(line)) {
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
- mismatched: mismatchedRowsIn(lines)
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, and a row it skipped for data that does not fit the case under rowsMismatched with the reason gdUnit4 gave; neither run is called passed. Default tests and test, those the project has, which is where gdharness test looks.'
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 _with_external_uids(file_path)
28
+ return _as_the_editor_writes(file_path)
29
29
 
30
30
 
31
- ## Puts back the uid on each external resource the text file at [param file_path] names. Only the
32
- ## editor's file system tells the saver a path's uid, so a headless save writes every reference by
33
- ## path alone, and each breaks when the file it names is moved.
34
- static func _with_external_uids(file_path: String) -> Error:
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 at: int in lines.size():
41
- var line: String = lines[at]
42
- if not line.begins_with("[ext_resource ") or line.contains(' uid="'):
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 uid: String = reference_uid(named.get_string(1))
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
- lines[at] = line.replace(' path="', uid + ' path="')
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
- return _finish(
142
- scene,
143
- Callable(),
144
- files,
145
- { "ok": true, "from": from_state, "to": to_state, "advanceMode": advance_mode }
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, pack: PackedScene, at: String, named: Array[String], source: String
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
- ## The answer says how many frames the wait spanned, how many looks it took and how long they took
789
- ## between them, which is how a caller sees what it cost and whether the game lost its speed to the
790
- ## looks or to something else.
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
- var looking: int = Time.get_ticks_usec()
804
- found = _anything_says(node_path, said, include_hidden)
805
- looks += 1
806
- var took: int = Time.get_ticks_usec() - looking
807
- looked_usec += took
808
- next_look = Time.get_ticks_usec() + took * LOOKS_APART
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: int = Time.get_ticks_usec()
823
- found = _anything_says(node_path, said, include_hidden)
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 += Time.get_ticks_usec() - last
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 not include_hidden and _anything_says(node_path, said, true):
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
- ## Whether anything under [param node_path] says [param said], the node itself included, and where
853
- ## the player can read it unless hidden ones are wanted too. Matched as a find and a click match
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 = not config.has_section_key(RUNNABLE_SECTION, platform)
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 str(config.get_value(RUNNABLE_SECTION, platform, "")) == preset_name:
626
- config.set_value(RUNNABLE_SECTION, platform, null)
627
- answer["runnableNow"] = null
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.46",
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",