Replace the broken test suite with working GdUnit4 tests
The previous tests never compiled: they used GUT's assert functions, Godot 3 APIs and reassigned autoload functions, so no test ran. - 90 tests: unit tests for the playlist, songs, player scoring, time formatting, search and the server client, plus integration tests that run MainWindow and SyncWindow against a fake server - Keybind tests check that Alt+<number> and Ctrl+Alt+<number> keep following the player rows while players are added and removed - run_tests.sh finds Godot and runs the suite headless (~10 seconds) - CI runs the tests before the build job in gitea-build.yaml; the separate test.yml used a GitHub action that does not exist - GdUnit4 is committed instead of ignored, since the project enables it, and .gitea/ is no longer ignored - Exported builds leave out tests/ and addons/gdUnit4/
This commit is contained in:
1 parent
56e9fe66d3
commit
c985a6ce5b
559 files changed
+32474
-2100
No files matched your search
@@ -0,0 +1,27 @@
|
||||
class_name AutoloadSnapshot
|
||||
extends RefCounted
|
||||
## Saves and restores the script variables of autoloads such as Settings and Playlist,
|
||||
## so a test that changes global state does not leak into the next test.
|
||||
|
||||
var _values: Dictionary = {}
|
||||
|
||||
|
||||
## Records the current values of every script variable on [param autoloads].
|
||||
static func take(autoloads: Array[Node]) -> AutoloadSnapshot:
|
||||
var snapshot: AutoloadSnapshot = AutoloadSnapshot.new()
|
||||
for autoload: Node in autoloads:
|
||||
var values: Dictionary = {}
|
||||
for property: Dictionary in autoload.get_property_list():
|
||||
if property.usage & PROPERTY_USAGE_SCRIPT_VARIABLE:
|
||||
var value: Variant = autoload.get(property.name)
|
||||
values[property.name] = value.duplicate() if value is Array else value
|
||||
snapshot._values[autoload] = values
|
||||
return snapshot
|
||||
|
||||
|
||||
## Puts back the values recorded by [method take].
|
||||
func restore() -> void:
|
||||
for autoload: Node in _values:
|
||||
var values: Dictionary = _values[autoload]
|
||||
for property_name: String in values:
|
||||
autoload.set(property_name, values[property_name])
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1l1oehlhm5ss
|
||||
@@ -0,0 +1,56 @@
|
||||
class_name FakeHttpTransport
|
||||
extends HttpTransport
|
||||
## Stands in for the MusicServer in tests.
|
||||
##
|
||||
## Register canned responses per path with [method respond_json] or
|
||||
## [method respond_bytes], then install it with [method install]. Requests to paths
|
||||
## without a response get a 404. Responses are delivered immediately.
|
||||
|
||||
## Paths requested so far, relative to [code]Settings.default_path[/code].
|
||||
var requested_paths: Array[String] = []
|
||||
## HTTP method of each request, in the same order as [member requested_paths].
|
||||
var requested_methods: Array[int] = []
|
||||
|
||||
var _responses: Dictionary = {}
|
||||
var _previous_transport: HttpTransport
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
super(null)
|
||||
|
||||
|
||||
## Replaces the MusicServerClient transport with this fake.
|
||||
func install() -> FakeHttpTransport:
|
||||
_previous_transport = MusicServerClient.transport
|
||||
MusicServerClient.transport = self
|
||||
return self
|
||||
|
||||
|
||||
## Restores the transport that was active before [method install].
|
||||
func uninstall() -> void:
|
||||
MusicServerClient.transport = _previous_transport
|
||||
|
||||
|
||||
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
||||
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
||||
|
||||
|
||||
## Answers requests to [param path] with [param body] as-is.
|
||||
func respond_bytes(path: String, body: PackedByteArray, response_code: int = 200) -> void:
|
||||
_responses[path] = {"code": response_code, "body": body}
|
||||
|
||||
|
||||
## Returns how many times [param path] has been requested.
|
||||
func count(path: String) -> int:
|
||||
return requested_paths.count(path)
|
||||
|
||||
|
||||
func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path)
|
||||
requested_paths.append(path)
|
||||
requested_methods.append(method)
|
||||
if _responses.has(path):
|
||||
on_complete.call(_responses[path].code, _responses[path].body)
|
||||
else:
|
||||
on_complete.call(404, PackedByteArray())
|
||||
@@ -0,0 +1 @@
|
||||
uid://blmtk4sw1iylj
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name MainWindowTestSuite
|
||||
extends GdUnitTestSuite
|
||||
## Base for tests that run the real main window against a fake MusicServer.
|
||||
|
||||
const SONG_INFO: Array[Dictionary] = [
|
||||
{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"},
|
||||
{"SongNo": 1, "Soundtrack": "Final Fantasy VI", "Song": "Dancing Mad"},
|
||||
]
|
||||
|
||||
var _server: FakeHttpTransport
|
||||
var _snapshot: AutoloadSnapshot
|
||||
var _runner: GdUnitSceneRunner
|
||||
var _window: Control
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_snapshot = AutoloadSnapshot.take([Settings, Playlist])
|
||||
Playlist.reset_playlist()
|
||||
Playlist.first_song_played = false
|
||||
Playlist.is_song_downloading = false
|
||||
Settings.player_array.clear()
|
||||
Settings.add_to_stats = true
|
||||
Settings.hide_next_track = true
|
||||
Settings.use_low_played_mode = false
|
||||
Settings.winning_score = 20
|
||||
|
||||
_server = FakeHttpTransport.new().install()
|
||||
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||||
_server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/all/order", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.respond_json("/characters", [])
|
||||
_server.respond_bytes("/music/rand", mp3)
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.respond_json("/music/info", SONG_INFO[0])
|
||||
_server.respond_bytes("/music/reset", PackedByteArray())
|
||||
_server.respond_bytes("/music/played?song=0", PackedByteArray())
|
||||
|
||||
_runner = scene_runner("res://MainWindow.tscn")
|
||||
_window = _runner.scene()
|
||||
# The debug build lowers the winning score on startup; keep it out of reach
|
||||
Settings.winning_score = 20
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
# Let nodes queued for deletion by the window be freed before orphans are counted
|
||||
await await_idle_frame()
|
||||
_server.uninstall()
|
||||
_snapshot.restore()
|
||||
|
||||
|
||||
## Makes the next song the server hands out the one described by [param info].
|
||||
func _next_song_is(info: Dictionary) -> void:
|
||||
_server.respond_json("/music/info", info)
|
||||
|
||||
|
||||
func _press(button_path: String) -> void:
|
||||
(_window.get_node(button_path) as Button).pressed.emit()
|
||||
|
||||
|
||||
## Presses Next and waits until the first song is playing.
|
||||
func _play_first_song() -> void:
|
||||
_press("NextButton")
|
||||
await _runner.simulate_frames(2)
|
||||
|
||||
|
||||
## Adds a player through the name field, the same way the user does.
|
||||
func _add_player(player_name: String) -> PlayerObject:
|
||||
var name_field: TextEdit = _window.get_node(
|
||||
"Players/VBoxContainer/AddPlayerContainer/PlayerNameField")
|
||||
name_field.text = player_name
|
||||
_runner.invoke("add_player")
|
||||
return Settings.player_array.back() if !Settings.player_array.is_empty() else null
|
||||
|
||||
|
||||
## Returns the row in the player list that shows [param player_object].
|
||||
func _player_row(player_object: PlayerObject) -> Player:
|
||||
var rows: Node = _window.get_node(
|
||||
"Players/VBoxContainer/ScrollContainer/PlayersListVBoxContainer")
|
||||
for row: Player in rows.get_children():
|
||||
if row.player_object == player_object:
|
||||
return row
|
||||
return null
|
||||
|
||||
|
||||
## Removes a player with the remove button in its row.
|
||||
func _remove_player(player_object: PlayerObject) -> void:
|
||||
(_player_row(player_object).get_node("HBoxContainer/RemovePlayer") as BaseButton) \
|
||||
.pressed.emit()
|
||||
await _runner.simulate_frames(1)
|
||||
|
||||
|
||||
## Presses [param keys] together, e.g. [code][KEY_ALT, KEY_1][/code], then releases them.
|
||||
func _press_keys(keys: Array[Key]) -> void:
|
||||
for key: Key in keys:
|
||||
_runner.simulate_key_press(key)
|
||||
await _runner.await_input_processed()
|
||||
keys.reverse()
|
||||
for key: Key in keys:
|
||||
_runner.simulate_key_release(key)
|
||||
await _runner.await_input_processed()
|
||||
|
||||
|
||||
## Gives a point with Alt+<number>.
|
||||
func _give_point_with_keybind(number: int) -> void:
|
||||
await _press_keys([KEY_ALT, (KEY_0 + number) as Key])
|
||||
|
||||
|
||||
## Takes a point with Ctrl+Alt+<number>.
|
||||
func _take_point_with_keybind(number: int) -> void:
|
||||
await _press_keys([KEY_CTRL, KEY_ALT, (KEY_0 + number) as Key])
|
||||
|
||||
|
||||
func _scores() -> Array:
|
||||
return Settings.player_array.map(func(p: PlayerObject) -> int: return p.player_score)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjplwhulmw00
|
||||
Reference in new issue
Block a user