85 Commits
Author SHA1 Message Date
SansanandClaude Opus 5.5 8263781abc Merge develop into feature/50-upload-soundtrack
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
The API spec is now formatted like before (2-space indents, sorted keys):
develop's spec with the upload paths and types added, so the diff to
develop is only the upload API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:29:18 +02:00
SansanandClaude Opus 5.5 0862d160e5 Give the sync progress bar colour and animation
Build / test (push) Successful in 1m55s
Build / build (push) Successful in 1m12s
The bar in the Sync popup is now an AnimatedProgressBar: taller, with a
darker track and the theme's accent fill (orange, frost blue in Nord, green
with a gold shine for Christmas). It shows Godot's busy animation until the
server reports progress, glides to each new value, has a soft highlight
sweeping along the filled part inside its rounded corners, and fills and
pops before the result shows. With Reduce animations it only jumps to its
values.

The styles are SyncProgressBar, SyncProgressBarFill and SyncProgressBarText
in every theme. Anim.stop stops a keyed animation without starting an empty
one.

The server now sends sync times as "MM:SS"; the spec is updated from
MusicServer develop (also the linux-package route), and the test data and
screenshots use the new format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:13:43 +02:00
SansanandClaude Opus 5.5 c86dacb890 Add a soundtrack by picking or dropping a folder (#50)
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on
the window, opens a popup that lists the folder's songs and uploads them to
the server, which adds the soundtrack at once (Sansan/MusicServer#33).

- The name comes from the folder and can be changed; characters the server
  refuses are replaced.
- Only MP3s are uploaded, as the server takes no other; other song files
  are listed greyed out as left out. Hidden files are skipped.
- Disc folders are flattened with the disc's number first ("1-01 Title.mp3"),
  as the server wants one folder. With one disc folder, names stay as
  they are.
- Songs are sent one at a time with a progress bar, reading one file at a
  time. A failed song is sent again twice, then the upload is cancelled
  and the server's reason shown. Closing the popup cancels it too.
- Not offered while the server syncs, offline, or without MP3s.
- Afterwards search, the inspiration list and the statistics reload.

The API types come from the server's spec (Upload, UploadFile,
UploadRequest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:43:59 +02:00
SansanandClaude Opus 5.5 f39865fffc Update the game from inside it (#13)
Build / test (push) Successful in 1m53s
Build / build (push) Successful in 1m15s
"Version X is available" in the top bar is now a button that opens an
Update popup:
- Update and restart gets the download link from the server (the redirect
  is not followed, as the Gitea link needs no token), downloads the new game
  next to the running one with a progress bar, checks it (complete, and an
  MZ or ELF file), swaps it in and restarts. The quiz is saved first and
  continues. On Windows the running .exe is renamed to .old, put back if
  the swap fails, and deleted at the next start.
- Download in browser opens the .exe, the Linux package, or the release
  page on other platforms.
- From the editor, an unwritable folder or another platform, only the
  browser download is offered, with the reason.

The message now fits between the tabs and the buttons in every theme; in
Nord and Christmas it ran into Sync. The macOS export preset is removed,
and the unused download_client_* functions are replaced by
fetch_client_download_url.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:23:26 +02:00
SansanandClaude Opus 5.5 c03c0e4efe Add Clear cache to the settings
Build / test (push) Successful in 1m51s
Build / build (push) Successful in 1m14s
A new row, "Downloaded pictures and songs", shows how much space the cached
character pictures, covers and downloaded songs take, with a Clear cache
button. Clearing deletes them, but keeps the songs the current quiz uses,
the settings, config.ini and the saved quiz. The characters are loaded
again at once; covers come back when they are shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:50:58 +02:00
SansanandClaude Opus 5.5 06421bb63e Change soundtrack covers from the app and show the cover with the answer
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s
Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:32:09 +02:00
SansanandClaude Opus 5.5 ef6ac8f1fd Show soundtrack covers and edit alternative names (#49)
Build / test (push) Successful in 1m43s
Build / build (push) Successful in 1m14s
Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.

Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.

The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.

Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:06:30 +02:00
SansanandClaude Opus 5.5 a735c515bf Add Short songs and a choice of how random songs are picked (#30, #51)
Build / test (push) Successful in 1m40s
Build / build (push) Successful in 1m28s
Short songs (#30): a quiz settings switch and a Seconds per song row (30 by
default, 10-120 in steps of 5), saved with the quiz settings. Only part of
each quiz song plays, from a random point 10-60% into it, picked once per song
so restart and repeat play the same part. The progress bar, time and seeking
stay within the part, a hidden beginning counts inside it, and its end counts
as the song ending (repeat, stop or go to the next song as set).

Random songs (#51): the low-played switch in Settings is now a Normal / Low
played / Classic choice, stored as Settings.random_mode and not saved.
fetch_random_song(mode) picks /music/rand, /music/rand/low or
/music/rand/classic.

Closes #30
Closes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:21:20 +02:00
SansanandClaude Opus 5.5 0fc3be79e1 Add animations to popups, points, songs, the winner and the tabs (#48)
Build / test (push) Successful in 1m39s
Build / build (push) Successful in 1m14s
Popups fade in while growing and close the reverse way, with the backdrop
fading along. The winner gets confetti, bouncing pictures and a score graph
that draws itself from the left. Filtered statistics cards and search rows
fade in, the tabs slide in from their side, and every button bounces on hover
and click (ButtonBounce, now added to all buttons).

In the player list the score pops on every point, the first point flashes the
row, and a player on match point glows with a "Match point!" tag. A song
starting fades in the title, flashes its row (kept across list rebuilds) and
pops the play button.

Common/anim.gd holds the helpers. A saved "Reduce animations" switch in
Settings turns all movement off; the tests use it. The flash colours are
theme colours (Palette first_point, match_point, flash) in every theme.

Closes #48

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:49:01 +02:00
SansanandClaude Opus 5.5 ae231975bd Show each player's picture first in the player list
Build / test (push) Successful in 1m30s
Build / build (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:18:19 +02:00
SansanandClaude Opus 5.5 b9fa3c3d63 Add a Christmas theme
Christmas red window and panels with snow white buttons and red text, a thick
white "fur" trim on popups, cards, the lists and the player bar, the tree's
green for the chosen tab and the play button, and gold for the playing song and
highlights. Icons on buttons take the text's colour, and drop-down lists stay
dark red so their arrow shows. It uses Nord's round shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:18:19 +02:00
SansanandClaude Opus 5.5 de94691073 Add a Nord theme and give the player bar room around it (#43)
Build / test (push) Successful in 1m31s
Build / build (push) Successful in 1m14s
Nord (themes/nord_theme.tres) uses the app icon's blues, darkened, with Nord's
Snow Storm text, Frost and Aurora accents and the playing song in Aurora
yellow. Its shapes are round: pill buttons and tabs, 14-16 px corners, soft
shadows on popups, cards and the player bar, rounded scroll bars, sliders and
text fields, and seven Aurora/Frost colours for the score graph. Classic stays
the default.

The player bar now has 16 px to the song buttons, the player list, Quiz
settings and the window's bottom edge, and the right edges line up at 1856.

The volume icon is a flat button with a PlayerIcon style, so each theme colours
it; it was nearly invisible on Original web's light grey bar.

Closes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:06:39 +02:00
SansanandClaude Opus 5.5 94e8dfb602 Add a theme picker and an Original web theme (#42)
Build / test (push) Successful in 1m31s
Build / build (push) Successful in 1m13s
Settings has a Theme row listing Settings.THEMES: Classic (main_theme.tres,
still the default) and Original web (original_web_theme.tres), which looks like
MusicFrontend: orange-bordered buttons, a #333 header and popups, #424242
panels, #4e4e4e alternating rows, a yellow playing song and a light grey
player bar. Picking one sets the root window's theme at once and saves it as
theme_id in settings.cfg, and it is applied at start.

The popup backdrop and score graph follow a theme switch. The top bar has its
own TopBarBackground style. test_theme.gd checks that every theme has
everything main_theme.tres has. The screenshot tool takes a theme id.

Closes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:50:12 +02:00
SansanandClaude Opus 5.5 70e970b24c Make the buttons consistent and rework the player list
Build / test (push) Successful in 1m30s
Build / build (push) Successful in 1m17s
Buttons get 12 px of side padding. The top bar buttons, the quiz toolbar and
Show answer/Next are laid out in rows with 12 px gaps and are all 36 px tall,
and the ±1 buttons use a compact StepButton style. "Randomize new track" is now
"Next song".

Player rows show the name and the score apart, alternate like the other lists
and highlight the character under the mouse. The name field is a LineEdit with
a 10-letter limit; blank and duplicate names are refused, and a full list says
"Up to 7 players". Remove buttons update when editing is switched instead of
every frame, and the spacer panels are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:35:24 +02:00
Sansan e4352a9f34 Save the quiz and continue it after a restart; load characters from a folder
Build / test (push) Successful in 1m25s
Build / build (push) Successful in 1m12s
The quiz is saved to current_quiz.json in the config folder after every
change, and the game goes on where it was when it starts again: the
players with their points and characters, every song with who got a point
for it and whether it was played, answered or is playing, and the songs
downloaded ahead with their files. The song that was playing is ready to
play again. Songs whose files are gone are downloaded again.

On a server, the saved songs are checked against the server's playlist. If
it changed, e.g. after a sync, or another server is used, the quiz starts
over with the same players. A quiz from a music folder continues offline,
including how far a prepared quiz has come. A new game is saved like any
other change, and a file that can't be read starts a new quiz.

Characters can be read from a folder picked in Settings instead of the
server, for this session. A music folder with a characters folder brings
its own characters, e.g. for a themed quiz; that folder is not counted as
a soundtrack. When the characters come from somewhere else, players pick
their characters again.

Closes #11, closes #44
2026-10-09 18:08:50 +02:00
Sansan 4a934840f6 Restyle the music player bar
Build / test (push) Successful in 1m11s
Build / build (push) Successful in 1m20s
The bar is rounded and a little taller, with a round orange play button
and a restart button that lights up orange under the mouse. The icons sit
inside their buttons with room around them, and are one colour, cropped to
their shape. Progress fills in orange, the volume slider gets a speaker
icon, and the buttons say their shortcut keys in their tooltips.

Buttons grow a little under the mouse and shrink while held down, and the
play button pops when it switches between play and pause. ButtonBounce
holds these animations so other buttons can use them too.
2026-10-09 17:38:38 +02:00
Sansan 78e1facaf0 Lay out the song list's pictures and text to fit any number of players
The players' pictures sit on the right in even lines of at most four, each
ending at the row's right edge, so five pictures are 3 + 2 and seven 4 + 3.
The text takes the room they leave and wraps only when it does not fit, and
the row is as tall as the taller of the two, instead of a width fixed by
the number of pictures.

Rows are rounded and alternate like the other lists, are highlighted under
the mouse, and the playing song has an orange edge. Clicking a picture
clicks its song.
2026-10-09 17:25:38 +02:00
Sansan 84e137e16a Only list played songs in the statistics song list
Songs never played are left out of the song list, so the Show menu is
hidden while it is shown.
2026-10-09 17:10:21 +02:00
Sansan a4e6da4c28 Show every song in the statistics, as an alternative to the cards
A Soundtracks / Songs switch in the statistics toolbar shows a list of
every song with its soundtrack and how often it was played. It is searched
on both song and soundtrack names, filtered and sorted like the cards, and
clicking a song shows its soundtrack's popup.

The server has tens of thousands of songs, so the list only makes the rows
on screen and reuses them while scrolling, songs are sorted once per load,
and searching waits for a pause in typing.

Closes #47
2026-10-09 17:02:27 +02:00
Sansan cd4331881c Don't list unplayed songs on the statistics cards
Cards only list songs that have been played. With fewer than six played
songs they are all listed as most played, so no song is listed twice. The
soundtrack popup still lists every song.

Closes #46
2026-10-09 16:55:39 +02:00
Sansan 06f457b027 Add a button in Settings that opens the config folder
Shows the folder with config.ini, the saved settings and the cached
characters and songs in the system's file manager.

Closes #45
2026-10-09 16:54:45 +02:00
Sansan 222d231b1b Add offline mode: play from a music folder instead of the server
Build / build (push) Successful in 1m12s
Build / test (push) Successful in 1m2s
Settings has a Music folder row. A folder with subfolders is played as
soundtracks: Next picks a random subfolder and a random song in it, the
subfolder is the answer's soundtrack and the file name without its type the
song. The inspiration list and Search show the subfolders.

A folder with songs and no subfolders is a prepared quiz, played once in order
(natural sort) or at random, showing only the song title. It has no winning
score: after the last song Next becomes Show winner, and the players with the
most points win, ties included.

While a folder is used nothing but the characters is asked from the server:
Sync, QR, Statistics, the update check and play counting are off. Characters
fall back to the cached pictures when the server can't be reached. mp3, ogg and
wav play; other files and broken ones are skipped.

The old local playback code, the hidden Open button and the hidden Local toggle
are removed. Next now waits for the song to start before preparing the next one.
2026-10-07 23:07:47 +02:00
Sansan b120440404 Open the About popup the first time a new version starts
Build / test (push) Successful in 46s
Build / build (push) Successful in 1m12s
The last version the About popup was shown for is saved as last_seen_version in
settings.cfg. When the game starts with another version, About opens to show
what is new.
2026-10-07 22:12:39 +02:00
Sansan 41974ea5e3 Show in the top bar when a newer version is available
Build / test (push) Successful in 46s
Build / build (push) Successful in 1m12s
At start and after switching server, the game asks the server for the newest
MusicPlayer release and shows "Version X is available" in the top bar when it
is newer than this one.
2026-10-07 22:08:53 +02:00
Sansan 240c331ab2 Cache character pictures and songs on disk
Build / test (push) Successful in 53s
Build / build (push) Successful in 1m13s
Character pictures are kept in characters/ next to the settings, with an
index.json of when the server last changed each one and when it was
downloaded. Only new or changed pictures are downloaded, using the server's new
/character/details. Older servers fall back to the name list, and pictures are
downloaded again after 7 days.

Downloaded songs are written to songs/ and only their paths are kept in memory.
The folder is emptied when the game starts and when the playlist resets.
2026-10-07 21:57:27 +02:00
Sansan 62fa63fd8b Update only the queue ID in settings.cfg
Build / test (push) Successful in 44s
Build / build (push) Successful in 1m12s
Saving a new queue ID wrote every setting from memory, which could also save
a setting that is in use but not saved yet, like the debug winning score.
Now it loads the file, changes only the queue ID and saves it again.
2026-10-07 21:28:02 +02:00
Sansan 4a45e8516d Send the play queue ID to the server
Build / test (push) Successful in 45s
Build / build (push) Successful in 1m12s
The server keeps a play queue per client, identified by the X-Queue-Id
header. The client sends the ID it has for the selected server with every
request and keeps the one in each response, which is new after a reset or a
sync. The IDs are saved per server in the [queues] section of settings.cfg,
so the game goes on with the same queue after a restart.

HttpTransport now passes the response headers on to the client.
2026-10-07 21:23:37 +02:00
Sansan d21b3e2ce0 Save fullscreen and read server, password and debug from config.ini
Build / build (push) Successful in 1m12s
Build / test (push) Successful in 45s
The settings the game saves itself, the quiz settings and now fullscreen, move
to settings.cfg (settings_debug.cfg in debug mode).

config.ini in the same folder is edited by hand: the server to start with, the
API password and debug mode. The game writes it with everything commented out
when it is missing and never changes it after that. IniFile reads it: plain
key = value lines, whole-line # or ; comments and optional quotes.
2026-10-07 20:12:59 +02:00
Sansan 92e3a66075 Save the quiz settings to the config folder
Build / test (push) Successful in 45s
Build / build (push) Successful in 1m20s
The quiz card's settings are saved to quiz_settings.cfg in OS.get_config_dir()
on every change and loaded at start. Debug builds use their own file, and
MUSICPLAYER_CONFIG_DIR moves the file elsewhere so tests never touch it.

Count plays in statistics and Prefer less played soundtracks are not saved and
move from the quiz card to the Settings popup.
2026-10-07 18:13:00 +02:00
Sansan b510aa47f2 Split settings into a quiz card and an application Settings popup
Build / test (push) Successful in 44s
Build / build (push) Successful in 1m13s
- A Quiz settings button under the player bar opens a small card above
  it with the quiz settings, including Auto repeat, which moves there
  from under the player bar. The card does not dim the quiz, so it can
  be changed during a song, and grows to fit its rows.
- A Settings button in the top bar opens the application settings:
  server, fullscreen and songs to download ahead. Quick sync is gone,
  as the server no longer has it, and the server list is in Settings.
- Labels use sentence case, and seconds to hide show as whole numbers.
- Debug builds switch to the local server and a low winning score in
  the Settings autoload, before any scene asks the server for anything.
  The DEBUG label moves to the top bar, so it shows on every tab.
- Search moves from the top bar back to the quiz toolbar, as the
  Statistics tab has its own search.
2026-10-07 16:05:13 +02:00
Sansan 8d21b0617d Fix and tidy the Character select, Shortcuts, Log and QR popups
Build / test (push) Successful in 41s
Build / build (push) Successful in 1m13s
Character select
- A card only picks its character when it is clicked. Before, every card
  watched every click, so clicking below the panel could pick a card
  scrolled out of view there.
- Six columns of fixed-size cards that scroll for any number of
  characters, with a gap between the cards and the scrollbar.
- Pictures are read as PNG, JPG or WebP by their first bytes, so JPGs no
  longer print an error. Names are split into words ("Paper Mario") and
  the card under the mouse is highlighted.

Shortcuts
- Alt+Z restarts the song; it was listed as "Reset".
- Grouped by what the keys do, in sentence case, and the panel fits the
  list. A test checks that every button shortcut is listed.

Log
- Scrolls to the newest row, alternates row colours and says "Nothing
  has happened yet" when empty.

QR
- A title, and the code is set up in one place.
2026-10-07 15:24:43 +02:00
Sansan b2d547b650 Centre every popup in the window
Build / test (push) Successful in 40s
Build / build (push) Successful in 1m13s
Most popups sat where they had been placed by hand, up to 328 px off.
They are now all centred, and test_popups.gd checks it for every popup.
2026-10-07 14:53:51 +02:00
Sansan 845d08c260 Rework the Search popup and highlight what matched
- The search field is a single-line field, so Enter no longer adds a
  line break that made every search come up empty.
- The field, button and list use real font sizes from the theme instead
  of being drawn at half size and scaled up, so the text is sharp, and
  everything fits inside the panel.
- Results have alternating row backgrounds like the other lists, a count
  ("3 of 1045 soundtracks") and a message when nothing matches.
- The letters that matched are highlighted.
- Searches shorter than Settings.LETTERS_FOR_LOOSE_SEARCH (3) only find
  names that contain the text, instead of names with the letters far
  apart, which matched most of the library. The Statistics tab uses the
  same search.
2026-10-07 14:53:51 +02:00
Sansan 13fdb3f68c Show the server's version history in the About popup
Build / test (push) Successful in 38s
Build / build (push) Successful in 1m13s
The About popup is twice as wide and shows the player's and the server's
version history side by side, each in its own scrolling column. The
server's comes from GET /api/v1/version/history every time the popup
opens. The description, which is about both, is centred above the
columns, and each history starts right under its version.
2026-10-07 14:32:31 +02:00
Sansan e1e05f3904 Show a graph of the game on the Winner popup
Build / test (push) Successful in 38s
Build / build (push) Successful in 1m12s
The Winner popup now shows every player's score after each song as a
step line, with the winning score marked, and three facts under it: the
longest streak, the longest standstill and the song most players scored
on. The data comes from the points each song already records, through
the new ScoreHistory. The popup grows to 1600x920 with the graph across
the full width, so a 60-song game with five players stays readable.

Reset points now starts a new game: it clears the points on the songs
and the graph starts after the song that was playing. Songs already on
the server at startup count as an earlier game.

The screenshot tour has a long five-player game, and only the Winner
popup differs from before.
2026-10-07 10:40:46 +02:00
Sansan b40b160fde Show Japanese text and close the gap in the top bar
Build / test (push) Successful in 35s
Build / build (push) Successful in 1m12s
Godot's built-in font has no Japanese, so kanji in song names showed as
boxes. The theme now sets Godot's own Open Sans SemiBold as its default
font, with Noto Sans JP Medium as fallback. The Noto file's line metrics
are set to Open Sans's, so line heights stay the same and every other
screen is pixel-identical; fonts/README.md explains how to redo it.

Search and Sync move right to where the Statistics button was.
2026-10-06 23:57:37 +02:00
Sansan a21b6e9b22 Add a Statistics tab with a card for every soundtrack
Build / test (push) Successful in 40s
Build / build (push) Successful in 1m16s
Part of #41. The top bar has Quiz and Statistics tabs instead of the
Statistics button. The Statistics tab replaces the quiz below the top bar
(the song keeps playing) and shows every soundtrack as a card with its
plays, last played date and most and least played songs, loaded from the
new GET /api/v1/statistics/soundtracks. The cards can be searched (with
the Search popup's matching, now in SoundtrackSearch), filtered on played
or never played, and sorted A-Z, Z-A, most or least played. Clicking a
card opens a popup with all its songs.

Screenshots of the other states only differ in the top bar, and the
layout baseline is taken again with the new statistics states.
2026-10-06 23:33:00 +02:00
Sansan f7619baf34 Use /api/v1 with token authentication and typed responses
Closes #38, #39 and #40; adds the client side of #41.

- Every request goes to /api/v1. MusicServerClient gets a token with
  Settings.api_password the first time one is needed, reuses it, and on a
  401 gets a new one and sends the request once more. Switching server gets
  a new token too. Callers never see the token.
- Sync is started with POST, as MusicServer 5.0 requires.
- The client has every endpoint in the server's OpenAPI spec, including
  statistics, soundtracks and songs by UUID, version, downloads and tokens.
- tools/generate_api_types.gd generates a typed class per schema in
  server/openapi.json into server/generated/. Song info, statistics and
  versions are passed to callers as these classes. A list the server sends
  as null is an empty list.
- FakeHttpTransport hands out tokens and answers 401 without one, so every
  test goes through the token flow. New tests cover the token handling,
  every endpoint and each generated class.
2026-10-06 22:45:28 +02:00
Sansan 8b71723a34 Put the whole look into one project theme
Build / test (pull_request) Successful in 31s
Build / build (pull_request) Skipped
Build / test (push) Successful in 31s
Build / build (push) Successful in 1m13s
themes/main_theme.tres is the project theme. It holds Godot's default look for
every control type the game uses, plus named styles for the game's own look
(popups, the background, spacers, list rows, title and text sizes, highlights)
and a palette for the popup dimming. Scenes and scripts name these styles
instead of setting colours, sizes or style boxes, and the five old theme files
are gone. themes/README.md explains the styles and how to make a new theme.

The screenshot tour now uses the real character pictures, so the layout
baseline is taken again. Screenshots are pixel-identical to before, and
test_theme.gd checks that the look stays in the theme.
2026-10-06 13:33:35 +02:00
Sansan a7bf38c5f9 Version 2.0.0
Build / test (pull_request) Successful in 31s
Build / build (pull_request) Skipped
2026-10-06 12:04:42 +02:00
Sansan 5519afb5f6 Add what's new in 2.0.0 to the About popup
The layout test lets the changelog grow in height with its text, and still
checks its position and width.
2026-10-06 12:02:55 +02:00
Sansan d77b72159d Sort the scripts and scenes into folders
main/ holds the main window and top bar, quiz/ the quiz and its parts,
player/ the music player, popups/ every popup and what they share, data/ the
songs, players, playlist and settings, server/ the MusicServer client,
common/ the list row and GameLog, and themes/ the themes and style boxes.
2026-10-06 11:57:29 +02:00
Sansan a0428e3475 Follow the GDScript style guide in every script
- Types on every parameter, return value and variable, and snake_case names
  (the player name limit is the constant MAX_NAME_LENGTH).
- Spelling fixes: *_triggered, character_selected, separator, caught_errors.
  The server's catched_errors key stays as the server sends it.
- Signals are connected with signal.connect() instead of by name.
- Code is ordered signals, constants, exported vars, vars, @onready vars,
  functions, with @onready on one line and two blank lines between functions.
- Every script starts with a ## description. Template comments, old LOC notes
  and commented-out code are gone, apart from the local play code.
- Lines are at most 100 characters, apart from the local play path.
- The screenshot tour turns off the blinking text cursor so pictures match.
2026-10-06 11:53:26 +02:00
Sansan 674604425f Reach nodes by unique name instead of long paths
Nodes a script uses through a path of several nodes are marked unique in their
scene and found with %Name. The search list is renamed SearchResults, and the
player row's nodes get their real types.
2026-10-06 11:44:47 +02:00
Sansan 390da577d7 Name popups *Popup and give scripts, scenes and classes matching names
Every popup is now called *Popup instead of *Window in its file, root node,
node name, class and variable, and PopupWindow becomes BasePopup. Player is
PlayerRow, SongListObject is SongRow and Character is CharacterCard. Script
files are snake_case and scene files PascalCase, each matching its class or
root node, and LightGrayTheme.tres is light_gray_theme.tres.
2026-10-06 11:22:39 +02:00
Sansan b7d67b573e Give every script a class name and use it for typed references
The popups, the volume slider, the player name field and the main window get
a class_name, so the main window and the quiz hold them by their real type
instead of Control. The classes already use the names the files will get.
2026-10-06 11:16:09 +02:00
Sansan 5831e1e73d Log through a GameLog autoload instead of print()
GameLog.debug() prints only in debug builds, and GameLog.warning() reports
problems such as unexpected server data or failed downloads.
2026-10-06 11:15:03 +02:00
Sansan 5cdec5cc8f Move the log popup to the main window
The quiz reports point changes with an event_logged signal, and the main
window adds them to the log. Alt+L is handled by the main window.
2026-10-06 11:13:11 +02:00
Sansan e94c75e84d Split now playing, the quiz buttons and song loading out of the quiz
NowPlaying, QuizToolbar and SongControls become their own scenes, the auto
repeat switch moves into the music player scene, and downloading songs moves
into a SongLoader node. The quiz only wires the parts together. Every node
keeps its place on screen.

Screenshots of every UI state are pixel-identical to before, the layout test
still matches the original baseline, and each new part has its own tests.
2026-10-06 11:11:18 +02:00
Sansan bad4b3ad8b Indent every script with tabs, like the Godot editor does 2026-10-06 10:28:12 +02:00
Sansan 8c1987807c Make the music list, player list and inspiration list their own scenes
MusicListPanel, PlayersPanel (was Players) and InspirationList (was
ScrollContainer) each get their own scene and script. They report what
happens through signals, and the quiz wires them to the playlist, the
music player, the log and the popups. Every node keeps its place on screen.

Screenshots of every UI state are pixel-identical to before, the layout
test still matches the original baseline, and each part is tested on its
own as well as through the quiz.
2026-10-06 10:27:35 +02:00
Sansan 3c7b4e5c9d Split the main window into TopBar and Quiz scenes
The quiz (song, playlist, players, music player and the popups opened from
it) moves into Quiz.tscn and the top panel into TopBar.tscn. The main window
keeps only the top bar, the quiz and the popups opened from the top bar, so
other views can be added next to the quiz later. Every node keeps its place
on screen.

Screenshots of every UI state before and after are pixel-identical, and a
layout test checks each node against a baseline taken before the split.
2026-10-06 10:02:22 +02:00
Sansan 75c59eb223 Move Sync to the top row and Settings next to the reset buttons 2026-10-06 08:43:05 +02:00
Sansan 10e9e7b2a4 New app icon, packaged with a menu entry for Linux
Build / test (pull_request) Successful in 22s
Build / build (pull_request) Skipped
The icon is a plate wearing headphones with a red Nordic house cookie
on it. It replaces the default Godot icon, so the game window, Windows
and macOS builds use it.

Linux programs can't carry their own icon, so the build now also makes
MusicPlayer_<version>_linux.tar.gz with the game, the icon as a PNG, a
.desktop menu entry and install.sh, which installs them for the current
user. The bare Linux binary is still published.
2026-10-06 08:32:56 +02:00
Sansan 29b11560b5 Show where each item starts in the inspiration and sync lists
Items in the inspiration list and the sync result columns now each have
their own background box, alternating between two shades, with a small
gap between them. When a long name wraps over several lines it is
still clear which lines belong together. The sync columns keep their
alternating white and orange text.
2026-10-06 08:32:56 +02:00
Sansan 2a332c2d19 Make the sync result look right with any number of columns
- Visible columns share the width equally, whether one or five are shown
- Long soundtrack names and errors wrap inside their column instead of
  widening the window, which pushed the title and Restart button out of
  view and cut off text
- Separators only go between columns; the extra one before the first
  column is gone
- Wider, centred panel with inner margins, and spacing above the
  Restart button
- The Re-added header no longer reads ReAdded
2026-10-06 08:32:56 +02:00
Sansan 6cbbfee6a1 Tidy up popup layouts
- Settings: labels in one column and switches and number buttons lined
  up in another, with margins inside the panel
- Log: title and margins; text no longer touches the panel edge
- Sync result: error list centred under its header, which now reads
  Errors instead of CatchedErrors
- About: text wraps inside the panel instead of running under the
  scrollbar
- Character select: wider panel, so the right column of characters is
  no longer right next to the scrollbar
2026-10-06 08:32:56 +02:00
Sansan c7d49bc5cc Rework popups and fix blurry text and player list layout
Popups:
- All nine popups now extend a shared PopupWindow class with open(),
  close() and toggle(), instead of each copying its own close-on-click
  code. Popups that need cleanup override close().
- A click outside a popup closed it and also pressed whatever was
  underneath, e.g. Reset playlist. The closing click is now consumed.
- Popups used Godot's see-through default panel, so the song list and
  titles showed through. They now use an opaque popup theme, and a
  backdrop dims the window behind them.
- The main window root now fills the screen, so the backdrop covers it.

Text and images:
- Many labels and buttons forced nearest filtering, which made text and
  icons jagged when the window is not 1920x1080. They now use the
  default smooth filtering. Only the pixel-art "no character" picture
  keeps nearest filtering when the winner window enlarges it.

Player list:
- A long player name pushed that row's character and buttons to the
  right. Names now take the free space and shorten with an ellipsis, so
  all rows line up and use the full width of the panel.
2026-10-06 08:32:56 +02:00
Sansan 6288739de0 Use the Compatibility renderer
The game is a 2D interface without 3D, shaders or lights, so it does
not need Forward+. With Compatibility it uses about 25% less CPU and
30 MB less memory while running, with no visible difference.
2026-10-06 08:32:56 +02:00
Sansan d79f5f92c5 Remove unused code
- The old character select scene, which was never shown but loaded
  every character image at startup
- Unused SyncResponse and ProgressResponse classes
- Playlist.set_last_played_answered and select/local-loading code in
  the new character select, none of which were called
- Settings.delete_player_children, identical to delete_children
- An unused variable holding a local file path
2026-10-06 08:32:56 +02:00
Sansan 14c166614d Add one log row at a time and pad log times
The log rebuilt every row each time a point was given or taken. It now
adds just the new row, and shows times as 09:05:03 instead of 9:5:3.
2026-10-06 08:32:56 +02:00
Sansan 428decf73a Turn statistics back on for a new game and fix low winning scores
Winning turned statistics off and nothing turned them back on, while
the Settings checkbox still showed them as on. Resetting points now
turns them back on if the win turned them off, and the checkbox shows
the current state when Settings is opened.

A winning score of 1 could never be won and with 2 the first point was
not announced as match point, because the first-point check ran first.
The winning score also could not be kept from going below 1.
2026-10-06 08:32:56 +02:00
Sansan 739d0f7af6 Preload songs only after the song list has loaded
At startup and after resetting the playlist, loading the song list and
preloading the first song started at the same time. When the list
answered last, it cleared the playlist and dropped the preloaded song.
Preloading now starts once the list has loaded, and loading the list is
retried if it fails.
2026-10-06 08:32:56 +02:00
Sansan ed292877ef Ignore Show answer when no song is playing
Before the first song played, currently_playing_song is -1, so pressing
Show answer marked the last song in the list as answered. That is the
preloaded song, so its title was shown as soon as it started playing,
and played?song=-1 was sent to the server. With an empty playlist it
crashed.
2026-10-06 08:32:56 +02:00
Sansan 5a979fca48 Time out requests the server never answers
HTTPRequest has no timeout by default, so a server that accepts the
connection but never responds left song downloads waiting forever.
Requests now fail after 120 seconds, which triggers the normal retry.
2026-10-06 08:32:56 +02:00
Sansan 1acc757981 Fill the song cache when the settings menu is closed
Raising the cache size used to have no effect until the next song was
played. Downloads now start when the settings window closes, so clicking
the number up and down while it is open does not start any downloads.
2026-10-06 08:32:56 +02:00
Sansan 16724922ed Test that the song cache fills up to the cache size setting
Covers a cache size above 1: songs are preloaded until the setting is
reached, playing a song tops the cache up again, and the cache keeps
filling after a failed download once the server answers again.
2026-10-06 08:32:56 +02:00
Sansan c1fc24adf9 Release from the version in project.godot
The version now lives in project.godot (application/config/version)
and Settings.version reads it from there. The Build workflow tests and
exports on every push, and on main creates the tag, uploads the builds
and creates the Gitea release when that version has no tag yet. This
replaces gitea-release.yaml, which released when a branch named after
the version was pushed.

Godot setup moves into a local .gitea/actions/setup-godot action shared
by the test and build jobs, and Godot is updated to 4.7.2 to match the
editor the project is made with.
2026-10-06 08:32:56 +02:00
Sansan f67dccbf52 Retry failed song downloads instead of getting stuck
MusicServerClient functions now take an optional on_failed callback,
called when a request fails or returns invalid JSON. Before, a failed
request was silently dropped, so a failure while preloading a song left
Playlist.is_song_downloading set forever and no more songs were fetched.

Song fetching retries the step that failed (download, queue or info)
every song_fetch_retry_seconds, so the server and the game keep the same
playlist.
2026-10-06 08:32:56 +02:00
Sansan c985a6ce5b 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/
2026-10-06 08:32:56 +02:00
Sansan 56e9fe66d3 Move server calls to MusicServerClient and give players stable ids
Server requests move out of Settings into a new MusicServerClient
autoload with one function per endpoint. It sends requests through a
swappable HttpTransport so tests can fake the server, and it frees its
HTTPRequest nodes after use.

Players now have a UUID that never changes. Their position in
Settings.player_array is only used for the Alt+<number> keybinds.
Previously the position doubled as the id, so removing a player made
points and wins land on the wrong player, and could delete another
player's point from a song.

Also fixes:
- format_time showed 59.6 seconds as "00:60"
- searches containing regex characters such as "Q*bert" broke search
- SongObject and PlayerObject were Nodes that were never freed; they
  are now RefCounted
- search matching is split out of the UI code into find_matches()
2026-10-06 08:32:55 +02:00
Sansan ab338b0696 Add GdUnit4 testing framework with comprehensive test coverage
- Add GdUnit4 v6.2.1 framework in addons/gdUnit4/
- Create test directory structure (tests/unit/, tests/integration/, tests/mocks/)
- Add unit tests for MusicPlayer.gd (8 tests)
- Add unit tests for MainWindow.gd (15+ tests)
- Add unit tests for Settings.gd (5 tests)
- Add unit tests for Playlist.gd (11 tests)
- Add integration tests for full flow (10+ tests)
- Add MockHTTPRequest for HTTP request mocking
- Add TestHelpers with utility functions
- Add Gitea Actions CI workflow (.gitea/workflows/test.yml)
- Add .runsettings for GdUnit4 CLI configuration
- Add run_tests.sh for convenient test execution
- Add TESTING.md with comprehensive documentation
- Add TESTING_PLAN.md with implementation details
- Update .gitignore for test artifacts
- Enable GdUnit4 plugin in project.godot

Features:
- Test button functionality (play, pause, search, sync, etc.)
- Mock REST API requests to server
- Test complete application flows
- Run tests in CI (headless mode)
- Run tests in GUI for visual feedback
- JUnit XML and HTML test reports

Generated by OpenCode AI assistant.
2026-10-06 08:32:55 +02:00
Sansan 9a91d969bd Merge pull request 'Made improvements to the Search window, Made small changes to the inspiration list scrolling, Made small changes to the player list, Changed the term game to soundtrack, #28: Made changes to the sync window' (#37) from develop into main
Build / build (push) Successful in 1m6s
Reviewed-on: #37
2026-10-05 22:53:08 +02:00
Sansan 8da6fc442f Made improvements to the Search window, Made small changes to the inspiration list scrolling, Made small changes to the player list, Changed the term game to soundtrack, #28: Made changes to the sync window
Build / build (push) Successful in 1m14s
Publish / build (push) Successful in 1m10s
2026-09-26 16:21:02 +02:00
Sansan 235bdcbcb5 #34: Fixed bug in Character Select when clicking or scrolling
Build / build (push) Successful in 1m6s
2026-05-17 12:14:07 +02:00
Sansan 6989b53dda Merge pull request 'Fixed workflow' (#35) from 1.8.0 into main
Build / build (push) Successful in 1m5s
Publish / build (push) Successful in 1m13s
Reviewed-on: #35
2026-05-16 17:50:05 +02:00
Sansan 899e99864f #9 #32 #33 : New winner screen, new setting for hide beginning and fixed bug
Build / build (push) Successful in 1m25s
2026-05-16 17:46:09 +02:00
Sansan 84608c95c2 Fixed workflow
Publish / build (push) Successful in 1m13s
2026-05-15 11:03:20 +02:00
Sansan acd8d65b95 #29 Small fixes
Build / build (push) Successful in 1m6s
Publish / build (push) Failing after 1m16s
2026-05-15 10:52:26 +02:00
Sansan d957c02373 #14 #15 #29 #31 : Added qr, added more settings, added keymap, changed about page.
Build / build (push) Successful in 1m5s
2026-05-14 12:24:50 +02:00
Sansan 0316df2f95 #14,#15,#29,#31: Added qr, added more settings, added keymap, changed about page.
Build / build (push) Successful in 1m10s
2026-05-14 12:18:42 +02:00
Sansan b4ba96cf63 #20 #24 Winnersong can be stopped. Inspirationlist speed can be changed. Some other small fixes
Build / build (push) Successful in 1m36s
2025-12-27 14:05:56 +01:00
Sansan 2a32cc34ef Sync result can be seen multiple times
Build / build (push) Successful in 1m19s
Publish / build (push) Successful in 1m33s
2025-11-15 15:02:21 +01:00
Sansan bae5831a3e #22: Now block add player if player name is empty
Build / build (push) Successful in 1m21s
#23: Add a log for when points are given to players
#25: Fixed some graphical stuff
#26: Changed so the same character can be on a song multiple times
#27: Turning off statistics after win
2025-11-15 14:53:51 +01:00
Sansan c6ed3c8e53 #17 #18: Removed Debug text in release and fixed bug with new character select screen
Build / build (push) Successful in 1m31s
Publish / build (push) Successful in 1m32s
2025-11-13 18:17:25 +01:00
952 changed files with 149621 additions and 3671 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
name: "Setup Godot"
description: "Downloads Godot for Linux, puts it on PATH as `godot` and optionally installs the export templates"
inputs:
version:
description: "Godot version, e.g. 4.7.2"
required: true
export-templates:
description: "Also install the export templates ('true' or 'false')"
required: false
default: "false"
runs:
using: "composite"
steps:
- name: Download Godot
shell: bash
run: |
URL="https://github.com/godotengine/godot-builds/releases/download/${{ inputs.version }}-stable"
mkdir -p "$HOME/.godot-bin"
curl --fail -L -o godot.zip "$URL/Godot_v${{ inputs.version }}-stable_linux.x86_64.zip"
unzip -q godot.zip
mv "Godot_v${{ inputs.version }}-stable_linux.x86_64" "$HOME/.godot-bin/godot"
chmod +x "$HOME/.godot-bin/godot"
rm godot.zip
echo "$HOME/.godot-bin" >> "$GITHUB_PATH"
- name: Install Export Templates
if: inputs.export-templates == 'true'
shell: bash
run: |
URL="https://github.com/godotengine/godot-builds/releases/download/${{ inputs.version }}-stable"
TEMPLATES_DIR="$HOME/.local/share/godot/export_templates/${{ inputs.version }}.stable"
curl --fail -L -o export_templates.tpz "$URL/Godot_v${{ inputs.version }}-stable_export_templates.tpz"
unzip -q export_templates.tpz
mkdir -p "$TEMPLATES_DIR"
mv templates/* "$TEMPLATES_DIR"
rm -r templates export_templates.tpz
+88 -36
View File
@@ -1,61 +1,113 @@
#
# .gitea/workflows/gitea-build.yaml
#
# Tests and exports MusicPlayer on every push and pull request to main and develop,
# and releases it from main.
#
# Releases follow the version in project.godot (application/config/version). Bump it,
# push to main, and once the tests and exports pass the builds are uploaded to the
# package registry and the tag and Gitea release are created. If that version already
# has a tag, nothing is released. See RELEASING.md.
#
# All jobs run in this one workflow, one after another: separate workflows started by
# the same push run at the same time on the single runner and can break each other's
# copy of a shared action.
#
name: "Build"
on:
push:
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
GODOT_VERSION: 4.5
PROJECT_PATH: .
#GAME_VERSION: 0.0.8
#GAME_VERSION: ${{gitea.ref_name}}
EXPORT_NAME: test-project
GODOT_VERSION: 4.7.2
PROJECT_NAME: MusicPlayer
jobs:
build:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Download Godot
run: |
#echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip"
curl -L -o godot.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip
unzip godot.zip
mv Godot_v$GODOT_VERSION-stable_linux.x86_64 godot
chmod +x godot
- name: Download Godot Export Templates
run: |
#echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz"
curl -L -o export_templates.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz
unzip export_templates.zip
- name: Setup Godot
uses: ./.gitea/actions/setup-godot
with:
version: ${{ env.GODOT_VERSION }}
- name: Install Export Templates
run: |
mkdir -p ~/.local/share/godot/export_templates/$GODOT_VERSION.stable
mv ./templates/* ~/.local/share/godot/export_templates/$GODOT_VERSION.stable
- name: Run Tests
run: ./run_tests.sh
- name: Create build folder
run: |
mkdir -v -p build
build:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Windows Build
- name: Read Version
id: version
run: |
cd $PROJECT_PATH
./godot --headless --verbose --export-release "Windows Desktop" "build/$EXPORT_NAME.exe"
GAME_VERSION="$(sed -n 's/^config\/version="\(.*\)"$/\1/p' project.godot)"
if [ -z "$GAME_VERSION" ]; then
echo "No application/config/version in project.godot"
exit 1
fi
echo "GAME_VERSION=$GAME_VERSION" >> "$GITHUB_ENV"
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
echo "Not on main, building without releasing"
echo "release=false" >> "$GITHUB_OUTPUT"
elif git rev-parse -q --verify "refs/tags/$GAME_VERSION" > /dev/null; then
echo "$GAME_VERSION is already released, building without releasing"
echo "release=false" >> "$GITHUB_OUTPUT"
else
echo "Releasing $GAME_VERSION"
echo "release=true" >> "$GITHUB_OUTPUT"
fi
- name: Linux Build
- name: Setup Godot
uses: ./.gitea/actions/setup-godot
with:
version: ${{ env.GODOT_VERSION }}
export-templates: "true"
- name: Export
run: |
cd $PROJECT_PATH
./godot --headless --verbose --export-release "Linux" "build/$EXPORT_NAME.x86_64"
mkdir -p build
godot --headless --path . --import
godot --headless --path . --export-release "Windows Desktop" "build/${PROJECT_NAME}_${GAME_VERSION}.exe"
godot --headless --path . --export-release "Linux" "build/${PROJECT_NAME}_${GAME_VERSION}.x86_64"
- name: MacOS Build
# Linux binaries can't hold an icon; the package adds it with a menu entry and installer
- name: Package Linux
run: |
cd $PROJECT_PATH
./godot --headless --verbose --export-release "macOS" "build/$EXPORT_NAME.zip"
linux/package.sh "build/${PROJECT_NAME}_${GAME_VERSION}.x86_64" "$GAME_VERSION" build
ls -la build
- name: Upload Artifacts
if: steps.version.outputs.release == 'true'
run: |
for FILE in build/${PROJECT_NAME}_${GAME_VERSION}*; do
NAME="$(basename "$FILE")"
echo "Uploading $NAME"
curl --fail --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
--upload-file "$FILE" \
https://gitea.sanplex.xyz/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$NAME
done
- name: Create Release
if: steps.version.outputs.release == 'true'
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
tag_name: ${{ env.GAME_VERSION }}
target_commitish: ${{ github.sha }}
files: |-
build/${{ env.PROJECT_NAME }}_*
-90
View File
@@ -1,90 +0,0 @@
#
# .gitea/workflows/gitea-release.yaml
#
#
name: "Publish"
on:
push:
branches:
- '*.*'
env:
GODOT_VERSION: 4.5
PROJECT_PATH: .
PROJECT_NAME: MusicPlayer
GAME_VERSION: ${{gitea.ref_name}}
EXPORT_NAME: MusicPlayer_${{gitea.ref_name}}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Download Godot
run: |
#echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip"
curl -L -o godot.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_linux.x86_64.zip
unzip godot.zip
mv Godot_v$GODOT_VERSION-stable_linux.x86_64 godot
chmod +x godot
- name: Download Godot Export Templates
run: |
#echo "https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz"
curl -L -o export_templates.zip https://github.com/godotengine/godot-builds/releases/download/$GODOT_VERSION-stable/Godot_v$GODOT_VERSION-stable_export_templates.tpz
unzip export_templates.zip
- name: Install Export Templates
run: |
mkdir -p ~/.local/share/godot/export_templates/$GODOT_VERSION.stable
mv ./templates/* ~/.local/share/godot/export_templates/$GODOT_VERSION.stable
- name: Create build folder
run: |
mkdir -v -p build
- name: Windows Build
run: |
cd $PROJECT_PATH
./godot --headless --verbose --export-release "Windows Desktop" "build/$EXPORT_NAME.exe"
- name: Linux Build
run: |
cd $PROJECT_PATH
./godot --headless --verbose --export-release "Linux" "build/$EXPORT_NAME.x86_64"
- name: MacOS Build
run: |
cd $PROJECT_PATH
./godot --headless --verbose --export-release "macOS" "build/$EXPORT_NAME.zip"
- name: Upload Artifact Windows
run: |
#ls -la build
curl --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
--upload-file "build/$EXPORT_NAME.exe" \
https://gitea.sanplex.tech/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$EXPORT_NAME.exe
- name: Upload Artifact Linux
run: |
ls -la build
curl --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
--upload-file "build/$EXPORT_NAME.x86_64" \
https://gitea.sanplex.tech/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$EXPORT_NAME.x86_64
- name: Upload Artifact MacOS
run: |
ls -la build
curl --user ${{ github.repository_owner }}:${{ secrets.TOKEN }} \
--upload-file "build/$EXPORT_NAME.zip" \
https://gitea.sanplex.tech/api/packages/sansan/generic/$PROJECT_NAME/$GAME_VERSION/$EXPORT_NAME.zip
- name: Create release
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
files: |-
build/**
+3
View File
@@ -6,3 +6,6 @@
.idea
.vscode
.DS_Store
# GdUnit4 test reports
reports/
-40
View File
@@ -1,40 +0,0 @@
extends Control
@onready
var character_grid_container := $ScrollContainer2
@onready
var character_grid := $ScrollContainer2/GridContainer
signal character_selected(file_name)
# Called when the node enters the scene tree for the first time.
func _ready():
var characters := DirAccess.open("res://characters/")
if characters:
characters.list_dir_begin()
var file_name = characters.get_next()
while file_name != "":
file_name = file_name.replace('.import', '') # <--- remove the .import
if file_name.ends_with(".png"):
var texture = load("res://characters/" + file_name)
var texture_btn := TextureButton.new()
character_grid.add_child(texture_btn)
texture_btn.custom_minimum_size = Vector2(80, 40)
texture_btn.ignore_texture_size = true
texture_btn.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT
texture_btn.texture_normal = texture
texture_btn.pressed.connect(select_character.bind(file_name))
file_name = characters.get_next()
func show_grid():
character_grid_container.visible = true
func select_character(file_name: String):
print("select_character")
character_selected.emit(file_name)
-1
View File
@@ -1 +0,0 @@
uid://cmxl2w45vvmx6
-25
View File
@@ -1,25 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://b16on0oscg1bv"]
[ext_resource type="Script" uid="uid://cmxl2w45vvmx6" path="res://CharacterSelect.gd" id="1_4bba6"]
[node name="CharacterSelect" type="Control"]
layout_mode = 3
anchors_preset = 0
offset_right = 40.0
offset_bottom = 40.0
script = ExtResource("1_4bba6")
[node name="ScrollContainer2" type="ScrollContainer" parent="."]
clip_contents = false
custom_minimum_size = Vector2(100, 100)
layout_mode = 0
offset_left = 194.0
offset_top = 152.0
offset_right = 670.0
offset_bottom = 375.0
[node name="GridContainer" type="GridContainer" parent="ScrollContainer2"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
columns = 3
-36
View File
@@ -1,36 +0,0 @@
extends ScrollContainer
var SCROLL = 0
var delay = 0.02 #seconds
var wait = 0
var SPEED: int = 1
# Called when the node enters the scene tree for the first time.
func _ready():
wait = delay
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
wait -= delta
if wait < 0:
wait = delay
#SCROLL DOWN
if SCROLL == 1:
if (scroll_vertical + get_v_scroll_bar().page) < get_v_scroll_bar().max_value:
scroll_vertical += SPEED
else:
scroll_back_up()
#SCROLL UP
elif SCROLL == -1:
if scroll_vertical != 0:
scroll_vertical -= SPEED
else:
scroll_to_bottom()
func scroll_back_up():
SCROLL = -1
func scroll_to_bottom():
scroll_vertical = 0 #Reset to top first.
SCROLL = 1
-7
View File
@@ -1,7 +0,0 @@
[gd_resource type="Theme" load_steps=2 format=3 uid="uid://rxexo3ur85as"]
[ext_resource type="StyleBox" uid="uid://blf3g88mugovr" path="res://neutral_gray.tres" id="1_alhp2"]
[resource]
Label/styles/normal = ExtResource("1_alhp2")
PanelContainer/styles/panel = ExtResource("1_alhp2")
-530
View File
@@ -1,530 +0,0 @@
extends Control
# 601 LOC 11/9 - 2023
# 628 LOC 15/9 - 2023
# 631 LOC 16/9 - 2023
# 554 LOC 30/5 - 2024
# 551 LOC 3/6 - 2024
# 386 LOC 12/6 - 2024
##TODO
# 15. Fix for local play
@onready
var open_button: Button = $Open
@onready
var fileDialog: FileDialog = $FileDialog
@onready
var inspiration_scroll: ScrollContainer = $ScrollContainer
@onready
var inspiration_list: VBoxContainer = $ScrollContainer/VBoxContainer
@onready
var game_label: Label = $VBoxContainer/GameLabel
@onready
var song_label: Label = $VBoxContainer/SongLabel
@onready
var add_player_container: HBoxContainer = $Players/VBoxContainer/AddPlayerContainer
@onready
var add_players_button: TextureButton = $Players/VBoxContainer/HBoxContainer/AddPlayersButton
@onready
var add_player_button: Button = $Players/VBoxContainer/AddPlayerContainer/AddPlayerButton
@onready
var new_player_name_field: TextEdit = $Players/VBoxContainer/AddPlayerContainer/PlayerNameField
@onready
var reset_playlist_button: Button = $ResetPlaylistButton
@onready
var reset_points_button: Button = $ResetPointsButton
@onready
var sound_test_button: Button = $SoundTestButton
@onready
var sync_button: Button = $SyncButton
@onready
var sync_window: Control = %SyncWindow
@onready
var settings_button: Button = $SettingsButton
@onready
var settings_window: Control = $SettingsWindow
@onready
var statistics_button: Button = $StatisticsButton
@onready
var statistic_popup: PopupPanel = $StatisticsPopupPanel
@onready
var statistic_label: Label = $StatisticsPopupPanel/StatisticsLabel
@onready
var about_button: Button = $AboutButton
@onready
var about_popup: PopupPanel = $AboutPopupPanel
@onready
var show_answer_button: Button = $ShowAnswerButton
@onready
var next_button: Button = $NextButton
@onready
var music_list_scroll: ScrollContainer = $MusicListPanel/ScrollContainer
@onready
var music_list: VBoxContainer = $MusicListPanel/ScrollContainer/MusicList
@onready
var player_container: VBoxContainer = $Players/VBoxContainer
@onready
var character_select: Control = $CharacterSelect
@onready
var new_character_select: Control = %NewCharacterSelect
@onready
var search_button: Button = $SearchButton
@onready
var search_view: Control= $Search
@onready
var version_label: Label = $AboutPopupPanel/VBoxContainer/VersionLabel
@onready
var whats_new_label: Label = $AboutPopupPanel/VBoxContainer/HBoxContainer/NewLabel
@onready
var shortcut_label: Label = $AboutPopupPanel/VBoxContainer/HBoxContainer/ShortcutsLabel
@onready
var coming_label: Label = $AboutPopupPanel/VBoxContainer/HBoxContainer/CommingLabel
@onready
var winner_popup: PopupPanel = $WinnerPopupPanel
@onready
var winner_label: Label = %WinnerLabel
@onready
var winner_picture: TextureRect = %WinnerPicture
@onready
var auto_repeat_song_button: CheckButton = $RepeatSongCheckButton
@onready
var music_player_container: PanelContainer = $MusicPlayer
@onready
var debug_label: Label = $DebugLabel
var player: PackedScene = preload("res://Player.tscn")
var song_list_object: PackedScene = preload("res://song_list_object.tscn")
var songs: Array= []
var games: Array = []
var current_player: Node
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
var debug: bool = OS.has_feature("debug")
var is_mac: bool = OS.has_feature("macos")
if debug:
print("is_debug")
debug_label.visible = true
Settings.is_debug = true
Settings.default_path = "http://localhost:8080"
Settings.selected_server = 3
Settings.winning_score = 5
if is_mac:
print("is_mac")
next_button.pressed.connect(play_next_song)
show_answer_button.pressed.connect(show_answer_pressed)
search_button.pressed.connect(show_search)
sync_button.pressed.connect(sync_games)
sound_test_button.pressed.connect(music_player_container.get_sound_test_song)
statistics_button.pressed.connect(get_statistics)
about_button.pressed.connect(show_about)
settings_button.pressed.connect(show_settings)
reset_playlist_button.pressed.connect(reset_playlist)
reset_points_button.pressed.connect(reset_points)
character_select.connect("character_selected", _on_character_selected)
new_character_select.connect("new_new_character_selected", _on_new_character_selected)
new_player_name_field.connect("enter_key_pressed", add_player)
music_player_container.connect("play_next_song", play_next_song)
music_player_container.connect("update_song_list", update_song_list)
settings_window.connect("server_changed", server_updated)
sync_window.connect("sync_started", _on_sync_started)
sync_window.connect("sync_finished", _on_sync_finished)
add_players_button.pressed.connect(add_players)
add_player_button.pressed.connect(add_player)
open_button.pressed.connect(open)
auto_repeat_song_button.pressed.connect(repeat_song)
get_suggestion_list()
fetch_song_list_at_start()
fetch_song()
search_view.get_list_of_games()
new_character_select.load_characters()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
if event.alt_pressed && event.keycode == KEY_UP:
print("Alt + UP pressed")
music_player_container.change_volume(0.05)
if event.alt_pressed && event.keycode == KEY_DOWN:
print("Alt + DOWN pressed")
music_player_container.change_volume(-0.05)
if event.alt_pressed && event.ctrl_pressed && event.keycode == KEY_LEFT:
print("Ctrl + Alt + LEFT pressed")
music_player_container.restart()
elif event.alt_pressed && event.keycode == KEY_LEFT:
print("Alt + LEFT pressed")
music_player_container.seek(-5)
if event.alt_pressed && event.keycode == KEY_RIGHT:
print("Alt + RIGHT pressed")
music_player_container.seek(5)
if event.alt_pressed && event.keycode == KEY_ENTER:
print("Alt + Enter pressed")
Settings.fullscreen = !Settings.fullscreen
if Settings.fullscreen == true:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
if Settings.fullscreen == false:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
func server_updated() -> void:
print("server_updated")
update_from_server()
reset_points()
func update_from_server() -> void:
print("update_from_server")
search_view.get_list_of_games()
get_suggestion_list()
reset_playlist()
new_character_select.load_characters()
func repeat_song() -> void:
Settings.auto_repeat_song = !Settings.auto_repeat_song
func show_answer_pressed() -> void:
print("show_answer_pressed")
Playlist.song_is_answered()
update_song_list()
func fetch_song_list_at_start() -> void:
print("fetch_song_list_at_start")
var fetch_song_list_at_start_done: Callable = func fetch_songs_at_start_done(data) -> void:
Playlist.clear_song_list()
if data == null:
Playlist.update_last_played_song()
update_song_list()
return
if typeof(data) == TYPE_ARRAY:
for d: Dictionary in data:
var new_song_object: SongObject = SongObject.new()
new_song_object.song_number = d.SongNo
new_song_object.game_title = d.Game
new_song_object.song_title = d.Song
new_song_object.is_answered = true
new_song_object.has_played = true
Playlist.add_to_playlist(new_song_object)
else:
print("Unexpected data")
Playlist.update_last_played_song()
update_song_list()
Settings.make_request2("/music/list", fetch_song_list_at_start_done, true)
func play_next_song() -> void:
print("play_next_song ", Playlist.latest_played_song)
if Playlist.first_song_played:
Playlist.set_latest_played_song()
Playlist.first_song_played = true
music_player_container.play_song_object(Playlist.latest_played_song)
if !(Playlist.get_preloaded_songs() > 0):
next_button.disabled = true
if !Playlist.is_song_downloading:
fetch_song()
#not first "/music/addPlayed"
#"/music/rand"
#"/music/addQue"
#"/music/info"
func fetch_song() -> void:
print("fetch_song")
Playlist.is_song_downloading = true
var url: String = ""
if Settings.use_low_played_mode:
url = "/music/rand/low"
else:
url = "/music/rand"
var new_song_object: SongObject = SongObject.new()
var fetch_song_done: Callable = func fetch_song_done(body: PackedByteArray) -> void:
print("fetch_song_done")
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
new_song_object.song = sound
var added_to_que_done: Callable = func added_to_que_done() -> void:
print("added_to_que_done")
var fetch_song_info_done: Callable = func fetch_song_info_done(data_received: Dictionary) -> void:
print("fetch_song_info_done")
new_song_object.song_number = data_received.SongNo
new_song_object.game_title = data_received.Game
new_song_object.song_title = data_received.Song
print("new_song_object: ", new_song_object.to_string())
Playlist.add_to_playlist(new_song_object)
next_button.disabled = false
if Playlist.should_preload_more_songs():
fetch_song()
else:
Playlist.is_song_downloading = false
if Settings.is_debug:
update_song_list()
Settings.make_request2("/music/info", fetch_song_info_done, true)
Settings.make_request2("/music/addQue", added_to_que_done, false)
Settings.make_request2(url, fetch_song_done, true)
func update_song_list() -> void:
print("update_song_list")
if Playlist.currently_playing_song >= 0:
game_label.text = Playlist.get_current_game_title()
song_label.text = Playlist.get_current_song_title()
Settings.delete_children(music_list)
for s: SongObject in Playlist.get_playlist():
var new_song_list_object: SongListObject = song_list_object.instantiate()
new_song_list_object.song_object = s
new_song_list_object.gui_input.connect(song_object_clicked.bind(s.song_number))
music_list.add_child(new_song_list_object)
func song_object_clicked(event: InputEvent, song_no: int) -> void:
if (event is InputEventMouseButton && event.pressed && event.button_index == MOUSE_BUTTON_LEFT):
print("Clicked Song Object: ", Playlist.song_object_array[song_no].to_string())
if Settings.character_select_open == false:
if Playlist.get_song(song_no) == null:
print("has no song, need to download")
var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void:
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
Playlist.add_song_to_song_object(song_no, sound)
music_player_container.play_song_object(song_no)
Settings.make_request2("/music?song=" + str(song_no), song_downloaded, true)
else:
print("song already downloaded")
music_player_container.play_song_object(song_no)
func reset_playlist() -> void:
print("reset_playlist")
var playlist_reset_done: Callable = func playlist_reset_done() -> void:
Playlist.reset_playlist()
fetch_song_list_at_start()
fetch_song()
Settings.make_request2("/music/reset", playlist_reset_done, false)
func reset_points() -> void:
print("reset_points")
for player_objects: PlayerObject in Settings.player_array:
player_objects.reset_points()
load_players()
func show_about() -> void:
print("show_about")
about_popup.visible = true
version_label.text = Settings.version
whats_new_label.text = Settings.whats_new
shortcut_label.text = Settings.shortcuts
coming_label.text = Settings.whats_left
func show_settings() -> void:
print("show_settings")
settings_window.visible = true
func get_statistics() -> void:
print("get_statistics")
statistic_popup.visible = true
statistic_label.text = "Total amount of games in the playlist: " + str(games.size())
func sync_games() -> void:
print("sync_games")
sync_window.start_sync()
func _on_sync_started() -> void:
print("_on_sync_started")
sync_button.disabled = false
sound_test_button.disabled = true
reset_playlist_button.disabled = true
show_answer_button.disabled = true
next_button.disabled = true
func _on_sync_finished() -> void:
print("_on_sync_finished")
update_from_server()
sound_test_button.disabled = false
reset_playlist_button.disabled = false
show_answer_button.disabled = false
func get_suggestion_list() -> void:
print("get_suggestion_list")
var populate_list: Callable = func populate_list(array) -> void:
if typeof(array) == TYPE_ARRAY:
games = []
Settings.delete_children(inspiration_list)
games.append_array(array)
for game: String in games:
var inspiration_label: Label = Label.new()
inspiration_label.set_texture_filter(TextureFilter.TEXTURE_FILTER_NEAREST)
inspiration_label.add_theme_font_size_override("font_size", 20)
inspiration_label.text = game
inspiration_label.autowrap_mode = TextServer.AUTOWRAP_WORD
inspiration_list.add_child(inspiration_label)
inspiration_scroll.scroll_to_bottom()
else:
print("Unexpected data")
Settings.make_request2("/music/all", populate_list, true)
func add_players() -> void:
print("add_players")
Settings.edit_players = !Settings.edit_players
add_player_container.visible = !add_player_container.visible
new_player_name_field.grab_focus()
func add_player() -> void:
print("add_player")
var new_player_object: PlayerObject = PlayerObject.new(new_player_name_field.text, Settings.player_array.size())
new_player_name_field.text = ""
Settings.player_array.append(new_player_object)
new_player_object.connect("first_point_triggerd", music_player_container._on_point_triggered.bind("first"))
new_player_object.connect("match_point_triggerd", music_player_container._on_point_triggered.bind("match"))
new_player_object.connect("winner_triggerd", _on_player_won.bind(new_player_object.id))
new_player_object.connect("point_given_sound", _on_make_point_given_sound)
new_player_object.connect("player_point_given", _on_point_given.bind(new_player_object.id))
new_player_object.connect("player_point_taken", _on_point_taken.bind(new_player_object.id))
load_players()
func load_players() -> void:
print("load_players")
Settings.delete_player_children(player_container)
var counter: int = 0
for player_object: PlayerObject in Settings.player_array:
print(player_object.player_name)
var new_player: Player = player.instantiate()
new_player.id = counter
new_player.connect("change_character_clicked", _on_player_change_character_clicked.bind(new_player))
new_player.connect("player_removed", _on_player_removed.bind(counter))
player_container.add_child(new_player)
counter += 1
func _on_point_given(player_given_point: int) -> void:
print("_on_point_given")
if Playlist.currently_playing_song >= 0:
Playlist.add_point(player_given_point)
update_song_list()
func _on_make_point_given_sound() -> void:
music_player_container.play_sound_effect(preload("res://sounds/itemopen.wav"))
func _on_point_taken(player_taken_point: int) -> void:
print("_on_point_taken")
music_player_container.play_sound_effect(preload("res://sounds/itemequip.wav"))
if Playlist.currently_playing_song >= 0:
Playlist.remove_point(player_taken_point)
update_song_list()
func _on_player_won(winning_player_id: int) -> void:
print("_on_player_won")
winner_popup.visible = true
winner_label.text = Settings.player_array[winning_player_id].player_name + " won!!"
winner_picture.custom_minimum_size = Vector2(692, 300)
winner_picture.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
winner_picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
winner_picture.texture = Settings.player_array[winning_player_id].character
music_player_container.play_sound(preload("res://sounds/winning.mp3"))
func _on_player_removed(player_to_remove: int) -> void:
print("_on_player_removed ", player_to_remove)
Settings.player_array.remove_at(player_to_remove)
load_players()
Playlist.remove_player_from_list(player_to_remove)
update_song_list()
func _on_player_change_character_clicked(new_player: Node) -> void:
print("_on_player_change_character_clicked")
current_player = new_player
new_character_select.show_grid()
func _on_character_selected(file_name: String) -> void:
print("_on_character_selected")
character_select.visible = false
current_player.character_selected(file_name)
func _on_new_character_selected(character_texture: ImageTexture) -> void:
print("_on_new_character_selected")
current_player.new_character_selected(character_texture)
func show_search() -> void:
print("show_search")
if search_view.visible == false:
search_view.visible = true
else:
search_view.visible = false
###Local
var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
func open() -> void:
fileDialog.popup()
func _on_file_dialog_dir_selected(path: String) -> void:
print(path)
dir_contents(path)
func dir_contents(path: String) -> void:
var dir: DirAccess = DirAccess.open(path)
if dir:
dir.list_dir_begin()
var file_name: String = dir.get_next()
songs.clear()
games = []
while file_name != "":
if dir.current_is_dir():
#print("Found directory: " + file_name)
games.append(file_name)
else:
#print("Found file: " + file_name)
if file_name.ends_with(".mp3"):
songs.append(path + "/" + file_name)
file_name = dir.get_next()
else:
print("An error occurred when trying to access the path.")
for game: String in games:
var local_inspiration_label: Label = Label.new()
local_inspiration_label.text = game
inspiration_list.add_child(local_inspiration_label)
-550
View File
@@ -1,550 +0,0 @@
[gd_scene load_steps=28 format=3 uid="uid://xwq863o6uvsu"]
[ext_resource type="Script" uid="uid://b8f4g15cas2j2" path="res://MainWindow.gd" id="1_eu0t5"]
[ext_resource type="PackedScene" uid="uid://b16on0oscg1bv" path="res://CharacterSelect.tscn" id="2_76kf4"]
[ext_resource type="Script" uid="uid://elgn7gkxgagq" path="res://InsperationScrollContainer.gd" id="2_gxtxm"]
[ext_resource type="Texture2D" uid="uid://bcfmpd7h512ef" path="res://icons/person_add_light.svg" id="5_31tjv"]
[ext_resource type="PackedScene" uid="uid://ds15cgsf8vpvc" path="res://MusicPlayer.tscn" id="5_emn36"]
[ext_resource type="PackedScene" uid="uid://cdy4kvemwaiom" path="res://new_character_select.tscn" id="6_dhrvg"]
[ext_resource type="Script" uid="uid://gf1bk6xbe3j" path="res://MusicListScrollContainer.gd" id="7_dj026"]
[ext_resource type="Script" uid="uid://cgakje8gb37tf" path="res://PlayerNameField.gd" id="7_qsdfy"]
[ext_resource type="Theme" uid="uid://rxexo3ur85as" path="res://LightGrayTheme.tres" id="7_wxbv6"]
[ext_resource type="PackedScene" uid="uid://bxydgil1yifps" path="res://SearchWindow.tscn" id="9_5ijvr"]
[ext_resource type="PackedScene" uid="uid://dpdem7pdxweb5" path="res://SyncWindow.tscn" id="10_yxw0b"]
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="11_1qef0"]
[ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://SettingsWindow.tscn" id="11_k62u5"]
[sub_resource type="LabelSettings" id="LabelSettings_ychxr"]
font_size = 25
[sub_resource type="LabelSettings" id="LabelSettings_qspbu"]
font_size = 50
[sub_resource type="LabelSettings" id="LabelSettings_3m52w"]
font_size = 35
[sub_resource type="InputEventKey" id="InputEventKey_03bm3"]
device = -1
alt_pressed = true
keycode = 65
unicode = 63743
[sub_resource type="Shortcut" id="Shortcut_jafqj"]
events = [SubResource("InputEventKey_03bm3")]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qk7sj"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_k1ygi"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="InputEventKey" id="InputEventKey_06rl4"]
device = -1
alt_pressed = true
keycode = 86
unicode = 8249
[sub_resource type="Shortcut" id="Shortcut_a7fvb"]
events = [SubResource("InputEventKey_06rl4")]
[sub_resource type="InputEventKey" id="InputEventKey_7dnqw"]
device = -1
alt_pressed = true
keycode = 67
unicode = 231
[sub_resource type="Shortcut" id="Shortcut_d6fml"]
events = [SubResource("InputEventKey_7dnqw")]
[sub_resource type="InputEventKey" id="InputEventKey_ujjlu"]
device = -1
alt_pressed = true
keycode = 83
[sub_resource type="Shortcut" id="Shortcut_fbju4"]
events = [SubResource("InputEventKey_ujjlu")]
[sub_resource type="LabelSettings" id="LabelSettings_hr75l"]
font_size = 35
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 0
script = ExtResource("1_eu0t5")
[node name="TopPanelContainer" type="PanelContainer" parent="."]
layout_mode = 0
offset_right = 1920.0
offset_bottom = 56.0
[node name="Title" type="Label" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 64.0
offset_top = 12.0
offset_right = 369.0
offset_bottom = 50.0
size_flags_horizontal = 0
text = "Music Player Randomizer"
label_settings = SubResource("LabelSettings_ychxr")
[node name="Open" type="Button" parent="."]
visible = false
layout_mode = 0
offset_left = 1296.0
offset_top = 66.0
offset_right = 1346.0
offset_bottom = 97.0
text = "Open"
[node name="FileDialog" type="FileDialog" parent="."]
title = "Open a Directory"
initial_position = 2
size = Vector2i(616, 400)
ok_button_text = "Select Current Folder"
file_mode = 2
access = 2
show_hidden_files = true
[node name="MusicListPanel" type="PanelContainer" parent="."]
layout_mode = 0
offset_left = 64.0
offset_top = 384.0
offset_right = 1152.0
offset_bottom = 896.0
theme = ExtResource("7_wxbv6")
[node name="ScrollContainer" type="ScrollContainer" parent="MusicListPanel"]
layout_mode = 2
follow_focus = true
script = ExtResource("7_dj026")
[node name="MusicList" type="VBoxContainer" parent="MusicListPanel/ScrollContainer"]
texture_filter = 1
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 10
[node name="ScrollContainer" type="ScrollContainer" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1216.0
offset_top = 128.0
offset_right = 1857.0
offset_bottom = 512.0
horizontal_scroll_mode = 0
vertical_scroll_mode = 3
script = ExtResource("2_gxtxm")
[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer"]
texture_filter = 1
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 0
offset_left = 64.0
offset_top = 64.0
offset_right = 1152.0
offset_bottom = 384.0
[node name="GameLabel" type="Label" parent="VBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
label_settings = SubResource("LabelSettings_qspbu")
horizontal_alignment = 1
autowrap_mode = 2
[node name="SongLabel" type="Label" parent="VBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
label_settings = SubResource("LabelSettings_3m52w")
horizontal_alignment = 1
autowrap_mode = 2
[node name="CharacterSelect" parent="." instance=ExtResource("2_76kf4")]
visible = false
top_level = true
layout_mode = 0
offset_left = 616.0
offset_top = 264.0
offset_right = 776.0
offset_bottom = 424.0
[node name="MusicPlayer" parent="." instance=ExtResource("5_emn36")]
layout_mode = 0
anchors_preset = 0
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 65.0
offset_top = 992.0
offset_right = 1856.0
offset_bottom = 1024.0
grow_horizontal = 1
grow_vertical = 1
[node name="Players" type="PanelContainer" parent="."]
layout_mode = 0
offset_left = 1216.0
offset_top = 576.0
offset_right = 1857.0
offset_bottom = 977.0
[node name="VBoxContainer" type="VBoxContainer" parent="Players"]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="Players/VBoxContainer"]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
[node name="Label" type="Label" parent="Players/VBoxContainer/HBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
text = "Players"
horizontal_alignment = 1
vertical_alignment = 1
[node name="AddPlayersButton" type="TextureButton" parent="Players/VBoxContainer/HBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 8
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_jafqj")
texture_normal = ExtResource("5_31tjv")
[node name="AddPlayerContainer" type="HBoxContainer" parent="Players/VBoxContainer"]
visible = false
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
[node name="Panel" type="Panel" parent="Players/VBoxContainer/AddPlayerContainer"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_qk7sj")
[node name="PlayerNameField" type="TextEdit" parent="Players/VBoxContainer/AddPlayerContainer"]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Player name"
script = ExtResource("7_qsdfy")
[node name="AddPlayerButton" type="Button" parent="Players/VBoxContainer/AddPlayerContainer"]
texture_filter = 1
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
text = "Add"
[node name="Panel2" type="Panel" parent="Players/VBoxContainer/AddPlayerContainer"]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 2
size_flags_horizontal = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_k1ygi")
[node name="ResetPlaylistButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1359.0
offset_top = 66.0
offset_right = 1470.0
offset_bottom = 97.0
focus_mode = 0
action_mode = 0
text = "Reset playlist"
[node name="ResetPointsButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1487.0
offset_top = 66.0
offset_right = 1598.0
offset_bottom = 97.0
focus_mode = 0
action_mode = 0
text = "Reset points"
[node name="SyncButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1615.0
offset_top = 66.0
offset_right = 1726.0
offset_bottom = 97.0
focus_mode = 0
action_mode = 0
text = "Sync games"
[node name="SoundTestButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1743.0
offset_top = 66.0
offset_right = 1854.0
offset_bottom = 97.0
focus_mode = 0
action_mode = 0
text = "Sound test"
[node name="ShowAnswerButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 65.0
offset_top = 944.0
offset_right = 176.0
offset_bottom = 975.0
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_a7fvb")
text = "Show answer"
[node name="NextButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 193.0
offset_top = 944.0
offset_right = 368.0
offset_bottom = 975.0
focus_mode = 0
disabled = true
action_mode = 0
shortcut = SubResource("Shortcut_d6fml")
text = "Randomize new track"
[node name="SearchButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1488.0
offset_top = 8.0
offset_right = 1558.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_fbju4")
text = "Search"
[node name="SettingsButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1584.0
offset_top = 8.0
offset_right = 1654.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
text = "Settings"
[node name="StatisticsButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1680.0
offset_top = 8.0
offset_right = 1758.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
text = "Statistics"
[node name="AboutButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1784.0
offset_top = 8.0
offset_right = 1854.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
text = "About"
[node name="Search" parent="." instance=ExtResource("9_5ijvr")]
visible = false
layout_mode = 1
offset_left = 56.0
offset_top = 96.0
offset_right = 56.0
offset_bottom = 96.0
[node name="SyncWindow" parent="." instance=ExtResource("10_yxw0b")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 401.0
offset_top = 153.0
offset_right = 401.0
offset_bottom = 153.0
[node name="SettingsWindow" parent="." instance=ExtResource("11_k62u5")]
visible = false
layout_mode = 1
offset_left = 720.0
offset_top = 153.0
offset_right = 720.0
offset_bottom = 153.0
[node name="NewCharacterSelect" parent="." instance=ExtResource("6_dhrvg")]
unique_name_in_owner = true
visible = false
layout_mode = 1
offset_left = 420.0
offset_top = 165.0
offset_right = 1572.0
offset_bottom = 805.0
[node name="StatisticsPopupPanel" type="PopupPanel" parent="."]
oversampling_override = 1.0
initial_position = 2
size = Vector2i(450, 100)
[node name="Label" type="Label" parent="StatisticsPopupPanel"]
texture_filter = 1
offset_left = 4.0
offset_top = 4.0
offset_right = 446.0
offset_bottom = 96.0
text = "Statistics"
horizontal_alignment = 1
[node name="StatisticsLabel" type="Label" parent="StatisticsPopupPanel"]
texture_filter = 1
offset_left = 4.0
offset_top = 4.0
offset_right = 446.0
offset_bottom = 96.0
text = "Total amount of games in the playlist: 9000"
horizontal_alignment = 1
vertical_alignment = 1
[node name="AboutPopupPanel" type="PopupPanel" parent="."]
oversampling_override = 1.0
initial_position = 2
size = Vector2i(848, 710)
[node name="VBoxContainer" type="VBoxContainer" parent="AboutPopupPanel"]
offset_left = 4.0
offset_top = 4.0
offset_right = 844.0
offset_bottom = 706.0
[node name="Label" type="Label" parent="AboutPopupPanel/VBoxContainer"]
texture_filter = 1
layout_mode = 2
text = "Music Player Randomizer"
horizontal_alignment = 1
[node name="VersionLabel" type="Label" parent="AboutPopupPanel/VBoxContainer"]
texture_filter = 1
layout_mode = 2
horizontal_alignment = 1
[node name="AboutLabel" type="Label" parent="AboutPopupPanel/VBoxContainer"]
texture_filter = 1
layout_mode = 2
text = "Try your video game music knowledge with this VGM randomizer, invite your friends and see who is the best."
horizontal_alignment = 1
[node name="HBoxContainer" type="HBoxContainer" parent="AboutPopupPanel/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="NewLabel" type="Label" parent="AboutPopupPanel/VBoxContainer/HBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 0
text = "0.7-Beta: Can now hop between songs"
autowrap_mode = 2
[node name="ShortcutsLabel" type="Label" parent="AboutPopupPanel/VBoxContainer/HBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
[node name="CommingLabel" type="Label" parent="AboutPopupPanel/VBoxContainer/HBoxContainer"]
texture_filter = 1
custom_minimum_size = Vector2(1, 1)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 0
text = "Fix reset buttons
Fix settings
Fix winner
Fix graphics in lists
Fix layout
Fix for local play
Change some buttons to icons
Add shortcuts"
autowrap_mode = 2
[node name="WinnerPopupPanel" type="PopupPanel" parent="."]
oversampling_override = 1.0
initial_position = 2
size = Vector2i(700, 350)
[node name="Panel" type="Panel" parent="WinnerPopupPanel"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 4.0
offset_top = 4.0
offset_right = -4.0
offset_bottom = -4.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="WinnerPopupPanel/Panel"]
layout_mode = 0
offset_right = 692.0
offset_bottom = 342.0
alignment = 1
[node name="WinnerPicture" type="TextureRect" parent="WinnerPopupPanel/Panel/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(692, 300)
layout_mode = 2
texture = ExtResource("11_1qef0")
expand_mode = 3
stretch_mode = 5
[node name="WinnerLabel" type="Label" parent="WinnerPopupPanel/Panel/VBoxContainer"]
unique_name_in_owner = true
texture_filter = 1
layout_mode = 2
text = "Sansan won!!"
label_settings = SubResource("LabelSettings_hr75l")
horizontal_alignment = 1
vertical_alignment = 2
[node name="RepeatSongCheckButton" type="CheckButton" parent="."]
layout_mode = 0
offset_left = 64.0
offset_top = 1032.0
offset_right = 208.0
offset_bottom = 1063.0
focus_mode = 0
action_mode = 0
text = "Auto repeat"
[node name="DebugLabel" type="Label" parent="."]
layout_mode = 0
offset_left = 974.0
offset_top = 904.0
offset_right = 1014.0
offset_bottom = 927.0
scale = Vector2(3, 3)
text = "DEBUG"
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[connection signal="dir_selected" from="FileDialog" to="." method="_on_file_dialog_dir_selected"]
-8
View File
@@ -1,8 +0,0 @@
extends ScrollContainer
var max_value = 0;
func _draw():
if max_value != self.get_v_scroll_bar().max_value:
max_value = self.get_v_scroll_bar().max_value
self.scroll_vertical = max_value
-208
View File
@@ -1,208 +0,0 @@
class_name MusicPlayer
extends PanelContainer
# LOC 20/1-24: 136
# LOC 12/6-24: 170
@onready
var play_button: TextureButton = $MusicPlayerContainer/PlayTextureButton
@onready
var restart_button: TextureButton = $MusicPlayerContainer/RestartTextureButton
@onready
var audio_player: AudioStreamPlayer = $AudioStreamPlayer
@onready
var sound_player: AudioStreamPlayer = $AudioSoundPlayer
@onready
var sound_effect_player: AudioStreamPlayer = $AudioSoundEffectPlayer
@onready
var progress_slider: HSlider = $MusicPlayerContainer/MusicPlayerSlider
@onready
var volume_slider: HSlider = $MusicPlayerContainer/VolumeSlider
@onready
var music_time_label: Label = $MusicPlayerContainer/MusicTimeLabel
@onready
var path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
var play_icon: Texture = preload("res://icons/play_icon_light.svg")
var pause_icon: Texture = preload("res://icons/pause_icon_light.svg")
var songs: Array = []
var is_changing: bool = false
var playback_position: float
var stream: AudioStream
var song_finished: bool = false
signal update_song_list
signal play_next_song
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
play_button.pressed.connect(play_or_pause)
restart_button.pressed.connect(restart)
progress_slider.drag_started.connect(_on_drag_started)
progress_slider.drag_ended.connect(_on_drag_ended)
audio_player.finished.connect(_on_finished)
func _process(_delta: float) -> void:
if audio_player.has_stream_playback() && !is_changing && !audio_player.stream_paused:
progress_slider.value = audio_player.get_playback_position()
if stream != null:
music_time_label.text = format_text(progress_slider.value, stream.get_length())
else:
music_time_label.text = format_text(progress_slider.value, 0.0)
func format_time(time: float) -> String:
var mins: String = "%02d" % floor(time / 60)
var sec: String = "%02d" % round(fmod(time, 60))
return mins + ":" + sec
func format_text(part: float, total: float) -> String:
return format_time(part) + " / " + format_time(total)
func play_or_pause() -> void:
if song_finished:
restart()
elif audio_player.stream_paused:
play_button.texture_normal = pause_icon
audio_player.stream_paused = false
song_finished = false
audio_player.seek(playback_position)
print("continue")
progress_slider.max_value = round(stream.get_length())
progress_slider.tick_count = round(stream.get_length() / 60)
else:
pause()
func pause() -> void:
audio_player.stream_paused = true
playback_position = audio_player.get_playback_position()
play_button.texture_normal = play_icon
func restart() -> void:
audio_player.stop()
audio_player.stream_paused = false
song_finished = false
progress_slider.value = 0
playback_position = audio_player.get_playback_position()
audio_player.seek(playback_position)
play_button.texture_normal = pause_icon
audio_player.play()
func _on_finished() -> void:
play_button.texture_normal = play_icon
song_finished = true
if !Settings.stop_after_current:
play_next_song.emit()
if Settings.auto_repeat_song:
restart()
func _on_drag_started() -> void:
is_changing = true
func _on_drag_ended(_changed: bool) -> void:
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
is_changing = false
func seek(new_position: float) -> void:
progress_slider.value += new_position
is_changing = true
audio_player.seek(progress_slider.value)
playback_position = progress_slider.value
is_changing = false
func change_volume(value: float) -> void:
volume_slider.value += value
volume_slider.change_volume(volume_slider.value)
func _on_point_triggered(point: String) -> void:
if point == "first":
var value: int = randi_range(0, 10)
if value == 0:
play_sound(preload("res://sounds/sound1.mp3"))
elif value < 5:
play_sound(preload("res://sounds/intro_long.mp3"))
else:
play_sound(preload("res://sounds/intro_short.mp3"))
elif point == "match":
play_sound(preload("res://sounds/sound0.mp3"))
func play_sound_effect(sound_effect_name: AudioStream) -> void:
sound_effect_player.stream = sound_effect_name
sound_effect_player.play()
func play_sound(sound_name: AudioStream) -> void:
sound_player.stream = sound_name
audio_player.stop()
sound_player.play()
song_finished = true
play_button.texture_normal = play_icon
progress_slider.value = 0
if stream != null:
music_time_label.text = format_text(progress_slider.value, stream.get_length())
func play_song(body: PackedByteArray) -> void:
var sound: AudioStream = AudioStreamMP3.new()
sound.data = body
audio_player.stream = sound
audio_player.play()
sound_player.stop()
song_finished = false
play_button.texture_normal = pause_icon
stream = audio_player.stream
progress_slider.max_value = round(stream.get_length())
progress_slider.tick_count = round(stream.get_length() / 60)
func play_song_object(song_object_no: int) -> void:
print("play_song_object")
if audio_player.is_playing():
audio_player.stop()
await get_tree().create_timer(0.5).timeout
audio_player.stream = Playlist.get_song(song_object_no)
sound_player.stop()
audio_player.play()
song_finished = false
play_button.texture_normal = pause_icon
stream = audio_player.stream
progress_slider.max_value = round(stream.get_length())
progress_slider.tick_count = round(stream.get_length() / 60)
Playlist.set_currently_playing_song(song_object_no)
Playlist.unset_is_playing()
if !Settings.hide_next_track:
print("Show answer now!!")
Playlist.song_is_answered(song_object_no)
Playlist.song_has_played(song_object_no)
Playlist.song_is_playing(song_object_no)
update_song_list.emit()
func get_sound_test_song() -> void:
Settings.make_request2("/music/soundTest", play_song, true)
##### LOCAL
var local_path: String = '/Users/sebastian/ResilioSync/Sorterat_test/Metal Gear Solid 4 - Guns of the Patriots/2-16 Metal Gear Saga.mp3'
func load_mp3(_path: String) -> AudioStream:
var file: FileAccess = FileAccess.open(_path, FileAccess.READ)
var sound: AudioStream = AudioStreamMP3.new()
sound.data = file.get_buffer(file.get_length())
return sound
func play_local_song() -> void:
if songs:
local_path = songs[0]
print(local_path)
print(FileAccess.file_exists(local_path))
play_sound(load_mp3(local_path))
func sound_test_local() -> void:
play_sound(preload("res://sounds/01. Opening.mp3"))
-94
View File
@@ -1,94 +0,0 @@
[gd_scene load_steps=10 format=3 uid="uid://ds15cgsf8vpvc"]
[ext_resource type="Script" uid="uid://d1v0rquma6nt8" path="res://MusicPlayer.gd" id="1_t24ra"]
[ext_resource type="AudioStream" uid="uid://n2g8jddr85h2" path="res://sounds/01. Opening.mp3" id="2_xti80"]
[ext_resource type="Texture2D" uid="uid://comxqfiykp54f" path="res://icons/play_icon_light.svg" id="3_6g308"]
[ext_resource type="Texture2D" uid="uid://ccb6rvbldlgdg" path="res://icons/reload_light_icon.svg" id="4_jleuo"]
[ext_resource type="PackedScene" uid="uid://w400rnew7453" path="res://volume_slider.tscn" id="5_iifuj"]
[sub_resource type="InputEventKey" id="InputEventKey_r4qo3"]
device = -1
alt_pressed = true
keycode = 88
physical_keycode = 88
key_label = 88
unicode = 120
[sub_resource type="Shortcut" id="Shortcut_ipcfh"]
events = [SubResource("InputEventKey_r4qo3")]
[sub_resource type="InputEventKey" id="InputEventKey_2nvce"]
device = -1
alt_pressed = true
keycode = 90
unicode = 90
[sub_resource type="Shortcut" id="Shortcut_i7swq"]
events = [SubResource("InputEventKey_2nvce")]
[node name="MusicPlayer" type="PanelContainer"]
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -121.0
offset_top = -15.5
offset_right = 121.0
offset_bottom = 16.5
grow_horizontal = 2
grow_vertical = 2
size_flags_vertical = 4
script = ExtResource("1_t24ra")
[node name="AudioStreamPlayer" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_xti80")
bus = &"music"
[node name="AudioSoundPlayer" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_xti80")
bus = &"music"
[node name="AudioSoundEffectPlayer" type="AudioStreamPlayer" parent="."]
stream = ExtResource("2_xti80")
bus = &"music"
[node name="MusicPlayerContainer" type="HBoxContainer" parent="."]
layout_mode = 2
size_flags_vertical = 4
[node name="PlayTextureButton" type="TextureButton" parent="MusicPlayerContainer"]
custom_minimum_size = Vector2(32, 32)
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_ipcfh")
texture_normal = ExtResource("3_6g308")
ignore_texture_size = true
stretch_mode = 0
[node name="RestartTextureButton" type="TextureButton" parent="MusicPlayerContainer"]
custom_minimum_size = Vector2(32, 32)
layout_mode = 2
focus_mode = 0
action_mode = 0
shortcut = SubResource("Shortcut_i7swq")
texture_normal = ExtResource("4_jleuo")
ignore_texture_size = true
stretch_mode = 0
flip_h = true
[node name="MusicPlayerSlider" type="HSlider" parent="MusicPlayerContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
scrollable = false
[node name="MusicTimeLabel" type="Label" parent="MusicPlayerContainer"]
layout_mode = 2
text = "1:00 / 3:00"
[node name="VolumeSlider" parent="MusicPlayerContainer" instance=ExtResource("5_iifuj")]
custom_minimum_size = Vector2(100, 0)
layout_mode = 2
size_flags_vertical = 4
-79
View File
@@ -1,79 +0,0 @@
class_name Player
extends Control
@onready
var player_name_field := $HBoxContainer/Name
@onready
var add_button := $HBoxContainer/AddPoint
@onready
var minus_button := $HBoxContainer/RemovePoint
@onready
var character := $HBoxContainer/Character
@onready
var remove_player := $HBoxContainer/RemovePlayer
signal change_character_clicked
signal player_removed
@export
var id: int
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
add_button.pressed.connect(add_point)
add_button.set_shortcut(create_button_shortcut(id, false))
minus_button.pressed.connect(minus_point)
minus_button.set_shortcut(create_button_shortcut(id, true))
character.pressed.connect(change_character)
remove_player.pressed.connect(func() -> void: player_removed.emit())
update_score()
set_player_character()
func _process(delta: float) -> void:
remove_player.visible = Settings.edit_players
func create_button_shortcut(scancode : int, ctrl_pressed: bool) -> Shortcut:
var button_shortcut: Shortcut = Shortcut.new()
var button_event : InputEventKey = InputEventKey.new()
button_event.keycode = scancode + 49
button_event.alt_pressed = true
button_event.ctrl_pressed = ctrl_pressed
button_shortcut.events.append(button_event)
return button_shortcut
func add_point() -> void:
Settings.player_array[id].add_point()
update_score()
func minus_point() -> void:
Settings.player_array[id].minus_point()
update_score()
func change_character() -> void:
change_character_clicked.emit()
func character_selected(file_name: String) -> void:
print("Back in player list with: " + file_name)
Settings.player_array[id].character = load("res://characters/" + file_name)
set_player_character()
func new_character_selected(character_texture: ImageTexture) -> void:
print("Back in player list with new")
Settings.player_array[id].character = character_texture
set_player_character()
func set_player_character() -> void:
character.custom_minimum_size = Vector2(80, 40)
character.ignore_texture_size = true
character.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
character.texture_normal = Settings.player_array[id].character
func update_score() -> void:
if OS.has_feature("debug"):
player_name_field.text = str(Settings.player_array[id].id) + " " + Settings.player_array[id].player_name + ": " + str(Settings.player_array[id].player_score)
else:
player_name_field.text = Settings.player_array[id].player_name + ": " + str(Settings.player_array[id].player_score)
-98
View File
@@ -1,98 +0,0 @@
[gd_scene load_steps=9 format=3 uid="uid://cslogy2csnd7a"]
[ext_resource type="Script" uid="uid://dl6no2wr2c86y" path="res://Player.gd" id="1_if4kc"]
[ext_resource type="Texture2D" uid="uid://r4as0nmtoa7p" path="res://noCharacter.png" id="2_hpj3s"]
[ext_resource type="Texture2D" uid="uid://b2kj6m8qpsgb1" path="res://icons/person_remove_light.svg" id="3_j3uxe"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_821k2"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w10e8"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_78a8g"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i56hb"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_b2crt"]
bg_color = Color(0.180392, 0.180392, 0.180392, 1)
[node name="Player" type="Control"]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 3
anchors_preset = 0
offset_bottom = 40.0
size_flags_horizontal = 3
script = ExtResource("1_if4kc")
[node name="HBoxContainer" type="HBoxContainer" parent="."]
custom_minimum_size = Vector2(2.08165e-12, 40)
layout_mode = 0
offset_right = 40.0
offset_bottom = 40.0
[node name="Panel4" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(5, 40)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_821k2")
[node name="Name" type="Label" parent="HBoxContainer"]
custom_minimum_size = Vector2(130, 40)
layout_mode = 2
size_flags_vertical = 1
text = "Sansansans: 100"
vertical_alignment = 1
[node name="Panel3" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(80, 2.08165e-12)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_w10e8")
[node name="Character" type="TextureButton" parent="HBoxContainer"]
custom_minimum_size = Vector2(80, 40)
layout_mode = 2
focus_mode = 0
action_mode = 0
texture_normal = ExtResource("2_hpj3s")
ignore_texture_size = true
stretch_mode = 5
[node name="Panel2" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(30, 40)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_78a8g")
[node name="AddPoint" type="Button" parent="HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="Panel5" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(3, 40)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_i56hb")
[node name="RemovePoint" type="Button" parent="HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="Panel" type="Panel" parent="HBoxContainer"]
custom_minimum_size = Vector2(30, 40)
layout_mode = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_b2crt")
[node name="RemovePlayer" type="TextureButton" parent="HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
size_flags_horizontal = 3
focus_mode = 0
action_mode = 0
texture_normal = ExtResource("3_j3uxe")
stretch_mode = 0
-38
View File
@@ -1,38 +0,0 @@
extends TextEdit
signal enter_key_pressed
signal close_pressed
@export
var LIMIT: int = 10
var current_text = ''
var cursor_line = 0
var cursor_column = 0
func _ready():
text_changed.connect(_on_text_changed)
func _input(event):
if event is InputEventKey and event.pressed:
if event.alt_pressed && event.keycode == KEY_A:
close_pressed.emit()
if has_focus():
if event.keycode == KEY_ENTER || event.keycode == KEY_KP_ENTER:
enter_key_pressed.emit()
func _on_text_changed():
if text.contains("\n"):
text = text.replace("\n", "")
var new_text : String = text
if new_text.length() > LIMIT:
text = current_text
# when replacing the text, the cursor will get moved to the beginning of the
# text, so move it back to where it was
set_caret_line(cursor_line)
set_caret_column(cursor_column)
current_text = text
# save current position of cursor for when we have reached the limit
cursor_line = get_caret_line()
cursor_column = get_caret_column()
-1
View File
@@ -1 +0,0 @@
uid://cgakje8gb37tf
-52
View File
@@ -1,52 +0,0 @@
extends Node
class_name PlayerObject
signal first_point_triggerd
signal match_point_triggerd
signal winner_triggerd
signal point_given_sound
signal player_point_given
signal player_point_taken
@export
var id: int
@export
var player_name: String
@export
var player_score: int
var is_first_point: bool = true
var character: Texture = load("res://noCharacter.png")
func _init(new_name: String, new_id: int) -> void:
player_name = new_name
id = new_id
player_score = 0
func add_point() -> void:
if player_score < Settings.winning_score:
player_score += 1
player_point_given.emit()
if player_score == 1 && is_first_point:
is_first_point = false
first_point_triggerd.emit()
elif player_score == Settings.winning_score - 1:
match_point_triggerd.emit()
elif player_score == Settings.winning_score:
winner_triggerd.emit()
else:
point_given_sound.emit()
func minus_point() -> void:
if player_score > 0:
player_score -= 1
player_point_taken.emit()
if player_score == 0:
is_first_point = true
func reset_points() -> void:
player_score = 0
is_first_point = true
+41
View File
@@ -0,0 +1,41 @@
# Releasing
Releases are made from the version in `project.godot`
(**Project > Project Settings > Application > Config > Version**). The game shows the
same version in the About window.
1. Bump the version, e.g. from `1.9.0` to `1.10.0`, and add a `WHATS_NEW` entry in
`popups/about_popup.gd`.
2. Merge to `main` and push.
The `Build` workflow (`.gitea/workflows/gitea-build.yaml`) then:
1. runs the tests,
2. exports the Windows and Linux builds, and packages Linux as
`MusicPlayer_<version>_linux.tar.gz` with the icon, an app menu entry and `install.sh`
(Linux programs can't carry their own icon the way a Windows .exe does),
3. if no tag exists for the version yet: uploads the builds to the package registry
under `MusicPlayer/<version>/`, and creates the `<version>` tag and Gitea release
with the builds attached.
Pushes that don't change the version, and pushes to `develop`, only test and export.
Don't create tags or `1.x.y` branches by hand; the workflow makes the tag.
## Installing on Linux
Unpack `MusicPlayer_<version>_linux.tar.gz` and run `./install.sh` inside the
`MusicPlayer` folder. It installs the game to `~/.local/bin` and adds it, with its icon,
to the app menu. The bare `MusicPlayer_<version>.x86_64` is still published for anyone
who just wants to run the file.
## Updating
When the server has a newer release, the top bar says "Version X is available". Clicking
it offers **Update and restart**: the game downloads its new file from the release
(`MusicPlayer_<version>.exe` on Windows, `MusicPlayer_<version>.x86_64` on Linux, through
the server's `/download/<platform>` routes), puts it in place of the running file and
starts it, and the quiz continues where it was. On Windows the old file is kept as
`<game>.old` until the next start. So keep the release file names as they are: the server
finds them by their ending. Where the game can't replace itself (run from the editor, a
folder it can't write to, or another platform), it opens the download in the browser
instead.
-130
View File
@@ -1,130 +0,0 @@
extends Control
@onready
var search_list: VBoxContainer = $ScrollContainer/VBoxContainer
@onready
var search_panel: Panel = %SearchPanel
@onready
var clear_button: Button = $ClearButton
@onready
var close_button: Button = $CloseButton
@onready
var search_bar: TextEdit = $Searchbar
var games: Array = []
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
clear_button.pressed.connect(clear)
close_button.pressed.connect(close)
search_bar.grab_focus()
search_bar.text_changed.connect(search)
visibility_changed.connect(focus)
func focus() -> void:
if self.visible == true:
search_bar.grab_focus()
clear()
func close() -> void:
clear()
self.visible = false
func search() -> void:
print(search_bar.text)
Settings.delete_children(search_list)
for game: String in games:
if is_match_exact(search_bar.text, game):
add_game(game)
for game: String in games:
if is_match_contains(clean_term(search_bar.text), clean_term(game)):
add_game(game)
for game: String in games:
if is_match_regex(clean_term(search_bar.text), clean_term(game)):
add_game(game)
func clean_term(term: String) -> String:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and").replace("'n", "and").to_lower()
func is_match_exact(search_term: String, game_name: String) -> bool:
search_term = search_term.to_lower()
game_name = game_name.to_lower()
if search_term == "":
return true
elif game_name.contains(search_term):
return true
else:
return false
func is_match_contains(search_term: String, game_name: String) -> bool:
if search_term == "":
return true
elif game_name.contains(search_term):
return true
else:
return false
func is_match_regex(search_term: String, game_name: String) -> bool:
if search_term == "":
return true
elif compile_regex(search_term).search(game_name):
return true
else:
return false
func add_game(game: String) -> void:
var label := Label.new()
label.text = game
print("game: " + game)
label.autowrap_mode = TextServer.AUTOWRAP_WORD
if !check_if_game_exists(game):
search_list.add_child(label)
func check_if_game_exists(game: String) -> bool:
var game_exists: bool = false
for child: Label in search_list.get_children():
if child.text == game:
game_exists = true
return game_exists
func compile_regex(search_term: String) -> RegEx:
var regex = RegEx.new()
var regText: String = ".*"
for letter in search_term:
regText += letter + ".*"
regex.compile(regText)
return regex
func get_list_of_games() -> void:
print("get_list_of_games")
var handle_games: Callable = func handle_games(array) -> void:
if typeof(array) == TYPE_ARRAY:
games = []
Settings.delete_children(search_list)
games.append_array(array)
for game in games:
var label := Label.new()
label.text = game
label.autowrap_mode = TextServer.AUTOWRAP_WORD
search_list.add_child(label)
else:
print("Unexpected data")
Settings.make_request2("/music/all/order", handle_games, true)
func _input(event: InputEvent) -> void:
if self.visible == true:
if event is InputEventMouseButton && event.is_pressed():
var evLocal: InputEvent = make_input_local(event)
if !Rect2(Vector2(0, 0), search_panel.size).has_point(evLocal.position):
self.visible = false
func clear() -> void:
search_bar.text = ""
search()
search_bar.grab_focus()
-63
View File
@@ -1,63 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://bxydgil1yifps"]
[ext_resource type="Script" uid="uid://bg5yo08638600" path="res://SearchWindow.gd" id="1_m7bij"]
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_m7bij")
[node name="SearchPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 1152.0
offset_bottom = 640.0
[node name="ClearButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 880.0
offset_top = 24.0
offset_right = 944.0
offset_bottom = 64.0
scale = Vector2(2, 2)
focus_mode = 0
action_mode = 0
text = "Clear"
[node name="CloseButton" type="Button" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 1032.0
offset_top = 24.0
offset_right = 1082.0
offset_bottom = 64.0
scale = Vector2(2, 2)
focus_mode = 0
action_mode = 0
text = "Close"
[node name="Searchbar" type="TextEdit" parent="."]
texture_filter = 1
layout_mode = 0
offset_left = 64.0
offset_top = 24.0
offset_right = 448.0
offset_bottom = 64.0
scale = Vector2(2, 2)
placeholder_text = "Search"
[node name="ScrollContainer" type="ScrollContainer" parent="."]
layout_mode = 0
offset_top = 128.0
offset_right = 576.0
offset_bottom = 384.0
scale = Vector2(2, 2)
[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
-139
View File
@@ -1,139 +0,0 @@
extends Node
var default_path: String = "https://music.sanplex.tech"
var selected_server: int = 0
#var default_path: String = "https://tmusic.sanplex.tech"
#var selected_server: int = 1
var is_local: bool = false
var is_debug: bool = false
var stop_after_current: bool = true
var auto_repeat_song: bool = false
var hide_next_track: bool = true
var add_to_stats: bool = true
var use_low_played_mode: bool = false
var winning_score: int = 20
var fullscreen: bool = false
var play_local: bool = false
var player_array: Array[PlayerObject]
var edit_players: bool = false
var currently_syncing: bool = false
var character_select_open: bool = false
var version: String = "1.6.0"
var whats_new: String = "Changelog:
1.6.0:
#1: Fixed bug with inspiration list not reloading after sync
#2: New dialog for sync
#4: Now downloads characters from the server
#5: New character select screen
#6: Now show progress during sync
#7: Blocking all requests to the server during sync
#8: Added functionality to cache more than one song
1.5.0: Made big changes to players and the song list and how the local song list works
0.9.0-Beta: Fixed settings and updated the player view
0.8.0-Beta: Fixed reset buttons and some other small things
0.7.8-Beta: Added shortcuts. Added dialog for winner. Started cleaning code.
0.7.5-Beta: Added settings menu, most things don't do anything yet
0.7-Beta: Can now hop between songs"
var whats_left: String = "Things left to do:
Fix layout
Fix for local play"
var shortcuts: String = "Shortcuts:
Alt + S = Search
Alt + A = Add Players
Alt + Z = Reset
Alt + X = Play/Pause
Alt + C = Next Song
Alt + V = Show Answer
Alt + Enter = Fullscreen
Alt + UP = Volume up
Alt + DOWN = Volume down
Alt + LEFT = Jump back in song
Alt + Ctrl = Restart song
Alt + RIGHT = Jump forward in song
Alt + 1, 2, 3, 4, 5, 6 = Give player point
Alt + Ctrl + 1, 2, 3, 4, 5, 6 = Take point from player"
func make_request2(address: String, func_name: Callable, expect_data: bool) -> void:
var error_handling: Callable = func(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
print("request done to address: ", default_path + address)
if response_code == 200:
print("func_name: ", func_name.get_method())
if !expect_data:
func_name.call()
else:
var json: JSON = JSON.new()
var error: int = json.parse(body.get_string_from_utf8())
if error == OK:
var data_received = json.get_data()
print("data_received type: ", type_string(typeof(data_received)))
if typeof(data_received) == TYPE_ARRAY:
func_name.call(data_received)
elif func_name != null:
func_name.call(data_received)
else:
print("data_received type: ", type_string(typeof(body)))
func_name.call(body)
var http_request: HTTPRequest = HTTPRequest.new()
add_child(http_request)
http_request.request_completed.connect(error_handling)
# Perform a GET request. The URL below returns JSON as of writing.
print("address: ", default_path + address)
var request_error: int = http_request.request(default_path + address)
if request_error != OK:
push_error("An error occurred in the HTTP request.")
func make_request3(address: String) -> void:
var error_handling: Callable = func(_result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void:
print("request done to address: ", default_path + address)
if response_code != 200:
print("Error: " + str(response_code))
var http_request: HTTPRequest = HTTPRequest.new()
add_child(http_request)
http_request.request_completed.connect(error_handling)
# Perform a GET request. The URL below returns JSON as of writing.
var request_error: int = http_request.request(default_path + address)
if request_error != OK:
push_error("An error occurred in the HTTP request.")
func make_put_request(address: String) -> void:
var error_handling: Callable = func(_result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray) -> void:
print("request done to address: ", default_path + address)
if response_code != 200:
print("Error: " + str(response_code))
var http_request: HTTPRequest = HTTPRequest.new()
add_child(http_request)
http_request.request_completed.connect(error_handling)
# Perform a GET request. The URL below returns JSON as of writing.
var headers: PackedStringArray = ["Content-Type: application/json"]
var request_error: int = http_request.request(default_path + address, headers, HTTPClient.METHOD_PUT)
if request_error != OK:
push_error("An error occurred in the HTTP request.")
func delete_children(node: Node) -> void:
for n: Node in node.get_children():
node.remove_child(n)
n.queue_free()
func delete_player_children(node: Node) -> void:
for n: Node in node.get_children():
print(n)
print(n.name)
if n.name == "HBoxContainer":
pass
elif n.name == "AddPlayerContainer":
pass
else:
node.remove_child(n)
n.queue_free()
-130
View File
@@ -1,130 +0,0 @@
extends Control
@onready
var settings_panel: Panel = %SettingsPanel
@onready
var stop_after_current_button: CheckButton = %StopAfterCurrentCheckButton
@onready
var hide_next_track_button: CheckButton = %HideNextTrackCheckButton
@onready
var add_to_database_button: CheckButton = %AddToDatabaseCheckButton
@onready
var low_played_button: CheckButton = %LowPlayedCheckButton
@onready
var score_label: Label = %ScoreLabel
@onready
var lower_winning_score_button: Button = %LowerButton
@onready
var increase_winning_score_button: Button = %IncreaseButton
@onready
var cache_label: Label = %CacheLabel
@onready
var lower_cache_button: Button = %LowerCacheButton
@onready
var increase_cache_button: Button = %IncreaseCacheButton
@onready
var select_server_button: OptionButton = %SelectServerButton
@onready
var fullscreen_button: CheckButton = %FullscreenButton
#@onready
#var quick_sync_button := $QuickSyncButton
@onready
var local_button: CheckButton = %LocalButton
signal server_changed
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
fullscreen_button.button_pressed = Settings.fullscreen
select_server_button.selected = Settings.selected_server
stop_after_current_button.pressed.connect(stop_after_current)
hide_next_track_button.pressed.connect(hide_next_track)
add_to_database_button.pressed.connect(add_to_database)
low_played_button.pressed.connect(low_played)
lower_winning_score_button.pressed.connect(lower_winning_score)
increase_winning_score_button.pressed.connect(increase_winning_score)
lower_cache_button.pressed.connect(lower_cache)
increase_cache_button.pressed.connect(increase_cache)
fullscreen_button.pressed.connect(fullscreen)
local_button.pressed.connect(local_play)
stop_after_current_button.button_pressed = Settings.stop_after_current
hide_next_track_button.button_pressed = Settings.hide_next_track
add_to_database_button.button_pressed = Settings.add_to_stats
low_played_button.button_pressed = Settings.use_low_played_mode
score_label.text = str(Settings.winning_score)
cache_label.text = str(Playlist.number_of_tracks_to_preload)
fullscreen_button.button_pressed = Settings.fullscreen
select_server_button.select(Settings.selected_server)
select_server_button.item_selected.connect(select_server)
func _input(event: InputEvent) -> void:
if self.visible == true:
if event is InputEventMouseButton && event.is_pressed():
var evLocal: InputEvent = make_input_local(event)
if !Rect2(Vector2(0, 0), settings_panel.size).has_point(evLocal.position):
self.visible = false
func fullscreen() -> void:
Settings.fullscreen = !Settings.fullscreen
if Settings.fullscreen == true:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
if Settings.fullscreen == false:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
func local_play() -> void:
Settings.play_local = !Settings.play_local
func stop_after_current() -> void:
Settings.stop_after_current = !Settings.stop_after_current
func hide_next_track() -> void:
Settings.hide_next_track = !Settings.hide_next_track
func add_to_database() -> void:
Settings.add_to_stats = !Settings.add_to_stats
func low_played() -> void:
Settings.use_low_played_mode = !Settings.use_low_played_mode
func lower_winning_score() -> void:
Settings.winning_score -= 1
score_label.text = str(Settings.winning_score)
func increase_winning_score() -> void:
Settings.winning_score += 1
score_label.text = str(Settings.winning_score)
func lower_cache() -> void:
Playlist.number_of_tracks_to_preload -= 1
if Playlist.number_of_tracks_to_preload < 1:
Playlist.number_of_tracks_to_preload = 1
cache_label.text = str(Playlist.number_of_tracks_to_preload)
func increase_cache() -> void:
Playlist.number_of_tracks_to_preload += 1
cache_label.text = str(Playlist.number_of_tracks_to_preload)
func select_server(new_server: int) -> void:
Settings.default_path = select_server_button.get_item_text(new_server)
Settings.selected_server = new_server
print("Settings.default_path: " + Settings.default_path)
print("Settings.selected_server: " + str(Settings.selected_server))
print("new_server: " + str(new_server))
print("select_server_button.get_item_text(new_server): " + select_server_button.get_item_text(new_server))
server_changed.emit()
-162
View File
@@ -1,162 +0,0 @@
[gd_scene load_steps=3 format=3 uid="uid://dldpeo5y3l5hq"]
[ext_resource type="Script" uid="uid://dukqyjoduf1af" path="res://SettingsWindow.gd" id="1_bt55j"]
[sub_resource type="LabelSettings" id="LabelSettings_3xrlm"]
font_size = 25
[node name="SettingsControl" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_bt55j")
[node name="SettingsPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 500.0
offset_bottom = 640.0
[node name="ScrollContainer" type="ScrollContainer" parent="SettingsPanel"]
layout_mode = 0
offset_right = 500.0
offset_bottom = 640.0
[node name="VBoxContainer" type="VBoxContainer" parent="SettingsPanel/ScrollContainer"]
clip_contents = true
layout_mode = 2
size_flags_horizontal = 3
[node name="Label" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 0
text = "Settings"
label_settings = SubResource("LabelSettings_3xrlm")
horizontal_alignment = 1
[node name="StopAfterCurrentCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
button_pressed = true
action_mode = 0
text = "Stop after current"
[node name="HideNextTrackCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
button_pressed = true
action_mode = 0
text = "Hide next track"
[node name="AddToDatabaseCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
action_mode = 0
text = "Turn on played to database"
[node name="LowPlayedCheckButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
action_mode = 0
text = "Use low played mode"
[node name="HBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
layout_mode = 2
[node name="Label2" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer"]
layout_mode = 2
text = "Winning Score: "
[node name="ScoreLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "20"
[node name="LowerButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="IncreaseButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="CacheHBoxContainer" type="HBoxContainer" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
layout_mode = 2
[node name="CacheTitleLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer"]
layout_mode = 2
text = "Predownload songs: "
[node name="CacheLabel" type="Label" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "1"
[node name="LowerCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "-1"
[node name="IncreaseCacheButton" type="Button" parent="SettingsPanel/ScrollContainer/VBoxContainer/CacheHBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(30, 2.08165e-12)
layout_mode = 2
focus_mode = 0
action_mode = 0
text = "+1"
[node name="SelectServerButton" type="OptionButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
selected = 0
item_count = 5
popup/item_0/text = "https://music.sanplex.tech"
popup/item_0/id = 1
popup/item_1/text = "https://tmusic.sanplex.tech"
popup/item_1/id = 2
popup/item_2/text = "https://rmusic.sanplex.tech"
popup/item_2/id = 3
popup/item_3/text = "https://old-music.sanplex.tech"
popup/item_3/id = 4
popup/item_4/text = "http://localhost:8080"
popup/item_4/id = 5
[node name="FullscreenButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
text = "Fullscreen"
[node name="QuickSyncButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
visible = false
layout_mode = 2
size_flags_horizontal = 0
button_pressed = true
action_mode = 0
text = "Quick sync"
[node name="LocalButton" type="CheckButton" parent="SettingsPanel/ScrollContainer/VBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_horizontal = 0
disabled = true
text = "Local"
-57
View File
@@ -1,57 +0,0 @@
extends Node
class_name SongObject
var song_number: int
var song_title: String
var game_title: String
var players_given_point: Array[int] = []
var is_answered: bool = false
var has_played: bool = false
var is_playing: bool = false
var song: AudioStream
func get_game_title() -> String:
if is_answered:
return game_title
else:
return "????????"
func get_song_title() -> String:
if is_answered:
return song_title
else:
return "??????"
func get_song_info() -> String:
if Settings.is_debug:
var format_string: String = "%d. %s - %s | played: %s | answered: %s"
return format_string % [(song_number + 1), game_title, song_title, has_played, is_answered]
elif is_answered:
var format_string: String = "%d. %s - %s"
return format_string % [(song_number + 1), game_title, song_title]
else:
return "??? - ???"
func add_point(id: int) -> void:
print("add_point")
if !players_given_point.has(id):
players_given_point.append(id)
players_given_point.sort()
func remove_point(id: int) -> void:
print("remove_point")
if players_given_point.has(id):
players_given_point.remove_at(players_given_point.find(id))
players_given_point.sort()
func _to_string() -> String:
var format_string: String = "song_number: %d | game_title: %s | song_title: %s | is_answered: %s | has_played: %s | is_playing: %s"
return format_string % [(song_number), game_title, song_title, is_answered, has_played, is_playing]
-150
View File
@@ -1,150 +0,0 @@
[gd_scene load_steps=3 format=3 uid="uid://dpdem7pdxweb5"]
[ext_resource type="Script" uid="uid://c3ejukwth512a" path="res://sync_window.gd" id="1_8ba47"]
[sub_resource type="LabelSettings" id="LabelSettings_anbfi"]
font_size = 25
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_8ba47")
[node name="SyncPanel" type="Panel" parent="."]
unique_name_in_owner = true
layout_mode = 0
offset_right = 1152.0
offset_bottom = 640.0
[node name="ScrollContainer" type="ScrollContainer" parent="SyncPanel"]
layout_mode = 0
offset_right = 1152.0
offset_bottom = 640.0
[node name="SyncVBoxContainer" type="VBoxContainer" parent="SyncPanel/ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
[node name="SyncTitleLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 0
text = "Sync"
label_settings = SubResource("LabelSettings_anbfi")
horizontal_alignment = 1
[node name="NoChangesLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_vertical = 0
text = "No changes since last sync"
label_settings = SubResource("LabelSettings_anbfi")
horizontal_alignment = 1
[node name="ProgressHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="SyncProgressBar" type="ProgressBar" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/ProgressHBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(1000, 0)
layout_mode = 2
size_flags_horizontal = 6
step = 1.0
rounded = true
[node name="SyncHBoxContainer" type="HBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_horizontal = 4
theme_override_constants/separation = 30
[node name="VSeparator6" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
layout_mode = 2
[node name="GamesAddedVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesAddedLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/GamesAddedVBox"]
layout_mode = 2
text = "GamesAdded"
horizontal_alignment = 1
[node name="GamesAddedSeparator" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesReAddedVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesReAddedLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/GamesReAddedVBox"]
layout_mode = 2
text = "GamesReAdded"
[node name="GamesReAddedSeparator" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesChangedTitleVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesChangedTitleLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/GamesChangedTitleVBox"]
layout_mode = 2
text = "GamesChangedTitle"
[node name="GamesChangedTitleSeparator" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesChangedContentVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesChangedContentLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/GamesChangedContentVBox"]
layout_mode = 2
text = "GamesChangedContent"
[node name="GamesChangedContentSeparator" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesRemovedVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="GamesRemovedLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer/GamesRemovedVBox"]
layout_mode = 2
text = "GamesRemoved"
[node name="GamesRemovedSeparator" type="VSeparator" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/SyncHBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="SyncTime" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Sync time: "
horizontal_alignment = 1
[node name="CatchedErrorsVBox" type="VBoxContainer" parent="SyncPanel/ScrollContainer/SyncVBoxContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="CatchedErrorsLabel" type="Label" parent="SyncPanel/ScrollContainer/SyncVBoxContainer/CatchedErrorsVBox"]
layout_mode = 2
text = "CatchedErrors"
horizontal_alignment = 1
[node name="SyncTimer" type="Timer" parent="."]
unique_name_in_owner = true
+83
View File
@@ -0,0 +1,83 @@
# Testing
Tests use [GdUnit4](https://github.com/godot-gdunit-labs/gdUnit4) 6.2.1, committed in
`addons/gdUnit4/`. The whole suite runs headless in about 10 seconds.
## Running the tests
```bash
./run_tests.sh # everything
./run_tests.sh res://tests/unit # only unit tests
./run_tests.sh res://tests/unit/test_search.gd # one suite
```
The script finds Godot through `$GODOT`, then `godot` on your PATH, then
`/Applications/Godot.app` or `/Applications/Godot_mono.app`. Reports are written to
`reports/` (ignored by git).
In the editor, open the GdUnit4 panel or right-click the `tests` folder and choose
**Run Tests**.
CI runs the suite in the `test` job of `.gitea/workflows/gitea-build.yaml` on every push
and pull request to `main` and `develop`. The `build` job only starts once the tests
pass.
## Layout
| Folder | Contents |
|---|---|
| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client and the API classes |
| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds and the Statistics tab |
| `tests/fakes/` | `FakeHttpTransport`, `AutoloadSnapshot` and the `MainWindowTestSuite` base class |
| `tests/data/` | Files the fake server sends, e.g. a short MP3 |
## Faking the MusicServer
All server calls go through the `MusicServerClient` autoload, which sends requests with
a swappable `transport`. Tests install a `FakeHttpTransport` that answers from canned
responses and records what was requested:
```gdscript
var server: FakeHttpTransport = FakeHttpTransport.new().install()
server.respond_json("/music/list", [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Frog's Theme"}])
server.respond_bytes("/music/rand", mp3_bytes)
# ... run the code under test ...
assert_int(server.count("/music/rand")).is_equal(1)
server.uninstall()
```
Paths are relative to the API (`/music/list` is `<server>/api/v1/music/list`). Paths
without a response get a 404, so the code under test sees a failed request.
Like the real server, the fake hands out tokens on `POST /token` and answers 401 to
requests without a valid one, so the client's token handling runs in every test. Token
requests are counted in `server.token_requests`, not in `requested_paths`. Use
`server.expire_tokens()` to make the client renew its token, `server.hold("/token")` to
keep it waiting for one, and `server.respond_json("/token", {...}, 401)` for a refused
password.
## API classes
The classes in `server/generated/` (`SongInfo`, `StatisticsSummary`, ...) are generated
from the server's OpenAPI spec, committed as `server/openapi.json`. To update them, copy
a new spec there (`just openapi` in MusicServer, or `<server>/openapi.json`) and run:
```bash
godot --headless --path . -s tools/generate_api_types.gd
```
`tests/unit/test_api_types.gd` parses a sample response with each class.
## Writing tests
- Settings and Playlist are global. Tests that change them restore them afterwards, either
by hand or with `AutoloadSnapshot.take([Settings, Playlist])` and `restore()`.
- Main window tests extend `MainWindowTestSuite`, which starts the window against the fake
server and has helpers such as `_add_player()`, `_remove_player()` and
`_give_point_with_keybind()`.
- Player ids are UUID strings that never change. A player's position in
`Settings.player_array` only decides their keybind (Alt+1 for the first row and so on),
so tests check both: that points land on the right player id, and that keybinds follow
the rows when players are added or removed.
+4
View File
@@ -0,0 +1,4 @@
# GdUnit4 specific ignores
*.gdunit
*.gdunit.*
/test_results.*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Mike Schulze
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env -S godot -s
extends SceneTree
var _cli_runner: GdUnitTestCIRunner
func _initialize() -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MINIMIZED)
_cli_runner = GdUnitTestCIRunner.new()
root.add_child(_cli_runner)
# do not use print statements on _finalize it results in random crashes
func _finalize() -> void:
queue_delete(_cli_runner)
if OS.is_stdout_verbose():
prints("Finallize ..")
prints("-Orphan nodes report-----------------------")
Window.print_orphan_nodes()
prints("Finallize .. done")
+1
View File
@@ -0,0 +1 @@
uid://bipm0dv6p07il
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env -S godot -s
extends MainLoop
const GdUnitTools := preload("res://addons/gdUnit4/src/core/GdUnitTools.gd")
# gdlint: disable=max-line-length
const LOG_FRAME_TEMPLATE = """
<!DOCTYPE html>
<html style="display: inline-grid;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Godot Logging</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body style="background-color: #eee;">
<div class="godot-report-frame"">
${content}
</div>
</body>
</html>
"""
const NO_LOG_MESSAGE = """
<h3>No logging available!</h3>
</br>
<p>In order for logging to take place, you must activate the Activate file logging option in the project settings.</p>
<p>You can enable the logging under:
<b>Project Settings</b> > <b>Debug</b> > <b>File Logging</b> > <b>Enable File Logging</b> in the project settings.</p>
"""
#warning-ignore-all:return_value_discarded
var _cmd_options := CmdOptions.new([
CmdOption.new(
"-rd, --report-directory",
"-rd <directory>",
"Specifies the output directory in which the reports are to be written. The default is res://reports/.",
TYPE_STRING,
true
)
])
var _report_root_path: String
var _current_report_path: String
var _debug_cmd_args := PackedStringArray()
func _init() -> void:
set_report_directory(GdUnitFileAccess.current_dir() + "reports")
set_current_report_path()
func _process(_delta: float) -> bool:
# check if reports exists
if not reports_available():
prints("no reports found")
return true
# only process if godot logging is enabled
if not GdUnitSettings.is_log_enabled():
write_report(NO_LOG_MESSAGE, "")
return true
# parse possible custom report path,
var cmd_parser := CmdArgumentParser.new(_cmd_options, "GdUnitCmdTool.gd")
# ignore erros and exit quitly
if cmd_parser.parse(get_cmdline_args(), true).is_error():
return true
CmdCommandHandler.new(_cmd_options).register_cb("-rd", set_report_directory)
var godot_log_file := scan_latest_godot_log()
var result := read_log_file_content(godot_log_file)
if result.is_error():
write_report(result.error_message(), godot_log_file)
return true
write_report(result.value_as_string(), godot_log_file)
return true
func set_current_report_path() -> void:
# scan for latest report directory
var iteration := GdUnitFileAccess.find_last_path_index(
_report_root_path, GdUnitConstants.REPORT_DIR_PREFIX
)
_current_report_path = "%s/%s%d" % [_report_root_path, GdUnitConstants.REPORT_DIR_PREFIX, iteration]
func set_report_directory(path: String) -> void:
_report_root_path = path
func get_log_report_html() -> String:
return _current_report_path + "/godot_report_log.html"
func reports_available() -> bool:
return DirAccess.dir_exists_absolute(_report_root_path)
func scan_latest_godot_log() -> String:
var path := GdUnitSettings.get_log_path().get_base_dir()
var files_sorted := Array()
for file in GdUnitFileAccess.scan_dir(path):
var file_name := "%s/%s" % [path, file]
files_sorted.append(file_name)
# sort by name, the name contains the timestamp so we sort at the end by timestamp
files_sorted.sort()
return files_sorted.back()
func read_log_file_content(log_file: String) -> GdUnitResult:
var file := FileAccess.open(log_file, FileAccess.READ)
if file == null:
return GdUnitResult.error(
"Can't find log file '%s'. Error: %s"
% [log_file, error_string(FileAccess.get_open_error())]
)
var content := "<pre>" + file.get_as_text()
# patch out console format codes
for color_index in range(0, 256):
var to_replace := "[38;5;%dm" % color_index
content = content.replace(to_replace, "")
content += "</pre>"
content = content\
.replace("", "")\
.replace(GdUnitCSIMessageWriter.CSI_BOLD, "")\
.replace(GdUnitCSIMessageWriter.CSI_ITALIC, "")\
.replace(GdUnitCSIMessageWriter.CSI_UNDERLINE, "")
return GdUnitResult.success(content)
func write_report(content: String, godot_log_file: String) -> GdUnitResult:
var file := FileAccess.open(get_log_report_html(), FileAccess.WRITE)
if file == null:
return GdUnitResult.error(
"Can't open to write '%s'. Error: %s"
% [get_log_report_html(), error_string(FileAccess.get_open_error())]
)
var report_html := LOG_FRAME_TEMPLATE.replace("${content}", content)
file.store_string(report_html)
_update_index_html(godot_log_file)
return GdUnitResult.success(file)
func _update_index_html(godot_log_file: String) -> void:
var index_path := "%s/index.html" % _current_report_path
var index_file := FileAccess.open(index_path, FileAccess.READ_WRITE)
if index_file == null:
push_error(
"Can't add log path '%s' to `%s`. Error: %s"
% [godot_log_file, index_path, error_string(FileAccess.get_open_error())]
)
return
var content := index_file.get_as_text()\
.replace("${log_report}", get_log_report_html())\
.replace("${godot_log_file}", godot_log_file)
# overide it
index_file.seek(0)
index_file.store_string(content)
func get_cmdline_args() -> PackedStringArray:
if _debug_cmd_args.is_empty():
return OS.get_cmdline_args()
return _debug_cmd_args
+1
View File
@@ -0,0 +1 @@
uid://cyft2xqn77ffl
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="gdUnit4"
description="Unit Testing Framework for Godot Scripts"
author="Mike Schulze"
version="6.2.1"
script="plugin.gd"
+90
View File
@@ -0,0 +1,90 @@
@tool
extends EditorPlugin
var _gd_inspector: Control
var _gd_console: Control
var _filesystem_context_menu: EditorContextMenuPlugin
var _editor_context_menu: EditorContextMenuPlugin
var _editor_code_context_menu: EditorContextMenuPlugin
func _enter_tree() -> void:
var inferred_declaration := GdUnitSettings.validate_is_inferred_declaration_enabled()
if inferred_declaration.is_error():
printerr(inferred_declaration.error_message())
printerr("Loading GdUnit4 Plugin failed.")
return
if check_running_in_test_env():
@warning_ignore("return_value_discarded")
GdUnitCSIMessageWriter.new().prints_warning("It was recognized that GdUnit4 is running in a test environment, therefore the GdUnit4 plugin will not be executed!")
return
if Engine.get_version_info().hex < 0x40500:
prints("This GdUnit4 plugin version '%s' requires Godot version '4.5' or higher to run." % GdUnit4Version.current())
return
GdUnitSettings.setup()
GdUnitEditorColorTheme.setup()
# Install the GdUnit Inspector
_gd_inspector = (load("res://addons/gdUnit4/src/ui/GdUnitInspector.tscn") as PackedScene).instantiate()
_add_context_menus()
add_control_to_dock(EditorPlugin.DOCK_SLOT_LEFT_UR, _gd_inspector)
# Install the GdUnit Console
_gd_console = (load("res://addons/gdUnit4/src/ui/GdUnitConsole.tscn") as PackedScene).instantiate()
var control: Control = add_control_to_bottom_panel(_gd_console, "gdUnitConsole")
@warning_ignore("unsafe_method_access")
await _gd_console.setup_update_notification(control)
if GdUnit4CSharpApiLoader.is_api_loaded():
prints("GdUnit4Net version '%s' loaded." % GdUnit4CSharpApiLoader.version())
else:
prints("No GdUnit4Net found.")
# Connect to be notified for script changes to be able to discover new tests
GdUnitTestDiscoverGuard.instance()
@warning_ignore("return_value_discarded")
resource_saved.connect(_on_resource_saved)
prints("Loading GdUnit4 Plugin success")
func _exit_tree() -> void:
if check_running_in_test_env():
return
if is_instance_valid(_gd_inspector):
remove_control_from_docks(_gd_inspector)
_gd_inspector.free()
_remove_context_menus()
if is_instance_valid(_gd_console):
remove_control_from_bottom_panel(_gd_console)
_gd_console.free()
var gdUnitTools: GDScript = load("res://addons/gdUnit4/src/core/GdUnitTools.gd")
@warning_ignore("unsafe_method_access")
gdUnitTools.dispose_all(true)
prints("Unload GdUnit4 Plugin success")
func check_running_in_test_env() -> bool:
var args: PackedStringArray = OS.get_cmdline_args()
args.append_array(OS.get_cmdline_user_args())
return DisplayServer.get_name() == "headless" or args.has("--selftest") or args.has("--add") or args.has("-a") or args.has("--quit-after") or args.has("--import")
func _add_context_menus() -> void:
_filesystem_context_menu = preload("res://addons/gdUnit4/src/ui/menu/GdUnitEditorFileSystemContextMenuHandler.gd").new()
_editor_context_menu = preload("res://addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd").new()
_editor_code_context_menu = preload("res://addons/gdUnit4/src/ui/menu/GdUnitScriptEditorContextMenuHandler.gd").new()
add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_FILESYSTEM, _filesystem_context_menu)
add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_SCRIPT_EDITOR, _editor_context_menu)
add_context_menu_plugin(EditorContextMenuPlugin.CONTEXT_SLOT_SCRIPT_EDITOR_CODE, _editor_code_context_menu)
func _remove_context_menus() -> void:
if is_instance_valid(_filesystem_context_menu):
remove_context_menu_plugin(_filesystem_context_menu)
if is_instance_valid(_editor_context_menu):
remove_context_menu_plugin(_editor_context_menu)
if is_instance_valid(_editor_code_context_menu):
remove_context_menu_plugin(_editor_code_context_menu)
func _on_resource_saved(resource: Resource) -> void:
if resource is Script:
await GdUnitTestDiscoverGuard.instance().discover(resource as Script)
+1
View File
@@ -0,0 +1 @@
uid://qmkf30a5bmap
+65
View File
@@ -0,0 +1,65 @@
@echo off
setlocal enabledelayedexpansion
:: Initialize variables
set "godot_binary="
set "filtered_args="
:: Process all arguments
set "i=0"
:parse_args
if "%~1"=="" goto end_parse_args
if "%~1"=="--godot_binary" (
set "godot_binary=%~2"
shift
shift
) else (
set "filtered_args=!filtered_args! %~1"
shift
)
goto parse_args
:end_parse_args
:: If --godot_binary wasn't provided, fallback to environment variable
if "!godot_binary!"=="" (
set "godot_binary=%GODOT_BIN%"
)
:: Check if we have a godot_binary value from any source
if "!godot_binary!"=="" (
echo Godot binary path is not specified.
echo Please either:
echo - Set the environment variable: set GODOT_BIN=C:\path\to\godot.exe
echo - Or use the --godot_binary argument: --godot_binary C:\path\to\godot.exe
exit /b 1
)
:: Check if the Godot binary exists
if not exist "!godot_binary!" (
echo Error: The specified Godot binary '!godot_binary!' does not exist.
exit /b 1
)
:: Get Godot version and check if it's a mono build
for /f "tokens=*" %%i in ('"!godot_binary!" --version') do set GODOT_VERSION=%%i
echo !GODOT_VERSION! | findstr /I "mono" >nul
if !errorlevel! equ 0 (
echo Godot .NET detected
echo Compiling c# classes ... Please Wait
dotnet build --debug
echo done !errorlevel!
)
:: Run the tests with the filtered arguments.
:: --remote-debug tcp://127.0.0.1:0 prevents Godot from activating its local interactive
:: CLI debugger, which would cause an endless 'debug>' loop on script parse errors.
:: Port 0 is used intentionally as it is never bound, so the connection is always refused.
"!godot_binary!" --path . -s -d --remote-debug tcp://127.0.0.1:0 res://addons/gdUnit4/bin/GdUnitCmdTool.gd !filtered_args!
set exit_code=%ERRORLEVEL%
echo Run tests ends with %exit_code%
:: Run the copy log command
"!godot_binary!" --headless --path . --quiet -s res://addons/gdUnit4/bin/GdUnitCopyLog.gd !filtered_args! > nul
set exit_code2=%ERRORLEVEL%
exit /b %exit_code%
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# Check for command-line argument
godot_binary=""
filtered_args=""
# Process all arguments with a more compatible approach
while [ $# -gt 0 ]; do
if [ "$1" = "--godot_binary" ] && [ $# -gt 1 ]; then
# Get the next argument as the value
godot_binary="$2"
shift 2
else
# Keep non-godot_binary arguments for passing to Godot
filtered_args="$filtered_args $1"
shift
fi
done
# If --godot_binary wasn't provided, fallback to environment variable
if [ -z "$godot_binary" ]; then
godot_binary="$GODOT_BIN"
fi
# Check if we have a godot_binary value from any source
if [ -z "$godot_binary" ]; then
echo "Godot binary path is not specified."
echo "Please either:"
echo " - Set the environment variable: export GODOT_BIN=/path/to/godot"
echo " - Or use the --godot_binary argument: --godot_binary /path/to/godot"
exit 1
fi
# Check if the Godot binary exists and is executable
if [ ! -f "$godot_binary" ]; then
echo "Error: The specified Godot binary '$godot_binary' does not exist."
exit 1
fi
if [ ! -x "$godot_binary" ]; then
echo "Error: The specified Godot binary '$godot_binary' is not executable."
exit 1
fi
# Get Godot version and check if it's a .NET build
GODOT_VERSION=$("$godot_binary" --version)
if echo "$GODOT_VERSION" | grep -i "mono" > /dev/null; then
echo "Godot .NET detected"
echo "Compiling c# classes ... Please Wait"
dotnet build --debug
echo "done $?"
fi
# Run the tests with the filtered arguments.
# --remote-debug tcp://127.0.0.1:0 prevents Godot from activating its local interactive
# CLI debugger, which would cause an endless 'debug>' loop on script parse errors.
# Port 0 is used intentionally as it is never bound, so the connection is always refused.
"$godot_binary" --path . -s -d --remote-debug tcp://127.0.0.1:0 res://addons/gdUnit4/bin/GdUnitCmdTool.gd $filtered_args
exit_code=$?
echo "Run tests ends with $exit_code"
# Run the copy log command
"$godot_binary" --headless --path . --quiet -s res://addons/gdUnit4/bin/GdUnitCopyLog.gd $filtered_args > /dev/null
exit_code2=$?
exit $exit_code
+12
View File
@@ -0,0 +1,12 @@
class_name Comparator
extends Resource
enum {
EQUAL,
LESS_THAN,
LESS_EQUAL,
GREATER_THAN,
GREATER_EQUAL,
BETWEEN_EQUAL,
NOT_BETWEEN_EQUAL,
}
+1
View File
@@ -0,0 +1 @@
uid://dapcyf8uxkfwt
+80
View File
@@ -0,0 +1,80 @@
## Factory class providing convenient static methods to create various fuzzer instances.[br]
##
## Fuzzers is a utility class that simplifies the creation of different fuzzer types
## for testing purposes. It provides static factory methods that create pre-configured
## fuzzers with sensible defaults, making it easier to set up fuzz testing in your
## test suites without manually instantiating each fuzzer type.[br]
##
## This class acts as a central access point for all fuzzer types, improving code
## readability and reducing boilerplate in test cases.[br]
##
## @tutorial(Fuzzing Testing): https://en.wikipedia.org/wiki/Fuzzing
class_name Fuzzers
extends Resource
## Generates random strings with length between [param min_length] and
## [param max_length] (inclusive), using characters from [param charset].
## See [StringFuzzer] for detailed documentation and examples.
static func rand_str(min_length: int, max_length: int, charset := StringFuzzer.DEFAULT_CHARSET) -> StringFuzzer:
return StringFuzzer.new(min_length, max_length, charset)
## Creates a [BoolFuzzer] for generating random boolean values.[br]
##
## See [BoolFuzzer] for detailed documentation and examples.
static func boolean() -> BoolFuzzer:
return BoolFuzzer.new()
## Creates an [IntFuzzer] for generating random integers within a range.[br]
##
## Generates random integers between [param from] and [param to] (inclusive)
## using [constant IntFuzzer.NORMAL] mode.
## See [IntFuzzer] for detailed documentation and examples.
static func rangei(from: int, to: int) -> IntFuzzer:
return IntFuzzer.new(from, to)
## Creates a [FloatFuzzer] for generating random floats within a range.[br]
##
## Generates random float values between [param from] and [param to] (inclusive).
## See [FloatFuzzer] for detailed documentation and examples.
static func rangef(from: float, to: float) -> FloatFuzzer:
return FloatFuzzer.new(from, to)
## Creates a [Vector2Fuzzer] for generating random 2D vectors within a range.[br]
##
## Generates random Vector2 values where each component is bounded by
## [param from] and [param to] (inclusive).
## See [Vector2Fuzzer] for detailed documentation and examples.
static func rangev2(from: Vector2, to: Vector2) -> Vector2Fuzzer:
return Vector2Fuzzer.new(from, to)
## Creates a [Vector3Fuzzer] for generating random 3D vectors within a range.[br]
##
## Generates random Vector3 values where each component is bounded by
## [param from] and [param to] (inclusive).
## See [Vector3Fuzzer] for detailed documentation and examples.
static func rangev3(from: Vector3, to: Vector3) -> Vector3Fuzzer:
return Vector3Fuzzer.new(from, to)
## Creates an [IntFuzzer] that generates only even integers.[br]
##
## Generates random even integers between [param from] and [param to] (inclusive)
## using [constant IntFuzzer.EVEN] mode.
## See [IntFuzzer] for detailed documentation about even number generation.
static func eveni(from: int, to: int) -> IntFuzzer:
return IntFuzzer.new(from, to, IntFuzzer.EVEN)
## Creates an [IntFuzzer] that generates only odd integers.[br]
##
## Generates random odd integers between [param from] and [param to] (inclusive)
## using [constant IntFuzzer.ODD] mode.
## See [IntFuzzer] for detailed documentation about odd number generation.
static func oddi(from: int, to: int) -> IntFuzzer:
return IntFuzzer.new(from, to, IntFuzzer.ODD)
+1
View File
@@ -0,0 +1 @@
uid://b1ctmh8qmi5d0
+122
View File
@@ -0,0 +1,122 @@
## An Assertion Tool to verify array values
@abstract class_name GdUnitArrayAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitArrayAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitArrayAssert
## Verifies that the current Array is equal to the given one.
@abstract func is_equal(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array is equal to the given one, ignoring case considerations.
@abstract func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array is not equal to the given one.
@abstract func is_not_equal(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array is not equal to the given one, ignoring case considerations.
@abstract func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitArrayAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitArrayAssert
## Verifies that the current Array is empty, it has a size of 0.
@abstract func is_empty() -> GdUnitArrayAssert
## Verifies that the current Array is not empty, it has a size of minimum 1.
@abstract func is_not_empty() -> GdUnitArrayAssert
## Verifies that the current Array is the same. [br]
## Compares the current by object reference equals
@abstract func is_same(expected: Variant) -> GdUnitArrayAssert
## Verifies that the current Array is NOT the same. [br]
## Compares the current by object reference equals
@abstract func is_not_same(expected: Variant) -> GdUnitArrayAssert
## Verifies that the current Array has a size of given value.
@abstract func has_size(expectd: int) -> GdUnitArrayAssert
## Verifies that the current Array contains the given values, in any order.[br]
## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same]
@abstract func contains(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array contains exactly only the given values and nothing else, in same order.[br]
## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_exactly]
@abstract func contains_exactly(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array contains exactly only the given values and nothing else, in any order.[br]
## The values are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_exactly_in_any_order]
@abstract func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array contains the given values, in any order.[br]
## The values are compared by object reference, for deep parameter comparision use [method contains]
@abstract func contains_same(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array contains exactly only the given values and nothing else, in same order.[br]
## The values are compared by object reference, for deep parameter comparision use [method contains_exactly]
@abstract func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array contains exactly only the given values and nothing else, in any order.[br]
## The values are compared by object reference, for deep parameter comparision use [method contains_exactly_in_any_order]
@abstract func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array do NOT contains the given values, in any order.[br]
## The values are compared by deep parameter comparision, for object reference compare you have to use [method not_contains_same]
## [b]Example:[/b]
## [codeblock]
## # will succeed
## assert_array([1, 2, 3, 4, 5]).not_contains(6)
## # will fail
## assert_array([1, 2, 3, 4, 5]).not_contains(2, 6)
## [/codeblock]
@abstract func not_contains(...expected: Array) -> GdUnitArrayAssert
## Verifies that the current Array do NOT contains the given values, in any order.[br]
## The values are compared by object reference, for deep parameter comparision use [method not_contains]
## [b]Example:[/b]
## [codeblock]
## # will succeed
## assert_array([1, 2, 3, 4, 5]).not_contains(6)
## # will fail
## assert_array([1, 2, 3, 4, 5]).not_contains(2, 6)
## [/codeblock]
@abstract func not_contains_same(...expected: Array) -> GdUnitArrayAssert
## Extracts all values by given function name and optional arguments into a new ArrayAssert.
## If the elements not accessible by `func_name` the value is converted to `"n.a"`, expecting null values
@abstract func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert
## Extracts all values by given extractor's into a new ArrayAssert.
## If the elements not extractable than the value is converted to `"n.a"`, expecting null values
## -- The argument type is Array[GdUnitValueExtractor]
@abstract func extractv(...extractors: Array) -> GdUnitArrayAssert
@@ -0,0 +1 @@
uid://cdmo2fwnaxtpe
+47
View File
@@ -0,0 +1,47 @@
## Base interface of all GdUnit asserts
@abstract class_name GdUnitAssert
extends RefCounted
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitAssert
## Verifies that the current value is equal to expected one.
@abstract func is_equal(expected: Variant) -> GdUnitAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitAssert
## Overrides the default failure message by given custom message.[br]
## This function allows you to replace the automatically generated failure message with a more specific
## or user-friendly message that better describes the test failure context.[br]
## Usage:
## [codeblock]
## # Override with custom context-specific message
## func test_player_inventory():
## assert_that(player.get_item_count("sword"))\
## .override_failure_message("Player should have exactly one sword")\
## .is_equal(1)
## [/codeblock]
@abstract func override_failure_message(message: String) -> GdUnitAssert
## Appends a custom message to the failure message.[br]
## This can be used to add additional information to the generated failure message
## while keeping the original assertion details for better debugging context.[br]
## Usage:
## [codeblock]
## # Add context to existing failure message
## func test_player_health():
## assert_that(player.health)\
## .append_failure_message("Player was damaged by: %s" % last_damage_source)\
## .is_greater(0)
## [/codeblock]
@abstract func append_failure_message(message: String) -> GdUnitAssert
+1
View File
@@ -0,0 +1 @@
uid://doph0d61vwg81
+73
View File
@@ -0,0 +1,73 @@
class_name GdUnitAwaiter
extends RefCounted
# Waits for a specified signal in an interval of 50ms sent from the <source>, and terminates with an error after the specified timeout has elapsed.
# source: the object from which the signal is emitted
# signal_name: signal name
# args: the expected signal arguments as an array
# timeout: the timeout in ms, default is set to 2000ms
func await_signal_on(source :Object, signal_name :String, args :Array = [], timeout_millis :int = 2000) -> Variant:
# fail fast if the given source instance invalid
var assert_that := GdUnitAssertImpl.new(signal_name)
var stack_trace := GdUnitStackTrace.new()
var line_number := stack_trace.get_line_number()
if not is_instance_valid(source):
@warning_ignore("return_value_discarded")
assert_that.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number)
return await (Engine.get_main_loop() as SceneTree).process_frame
# fail fast if the given source instance invalid
if not is_instance_valid(source):
@warning_ignore("return_value_discarded")
assert_that.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number)
return await await_idle_frame()
var awaiter := GdUnitSignalAwaiter.new(timeout_millis)
var value :Variant = await awaiter.on_signal(source, signal_name, args)
if awaiter.is_interrupted():
var failure := "await_signal_on(%s, %s) timed out after %sms" % [signal_name, args, timeout_millis]
@warning_ignore("return_value_discarded")
assert_that.report_error(failure, line_number)
return value
# Waits for a specified signal sent from the <source> between idle frames and aborts with an error after the specified timeout has elapsed
# source: the object from which the signal is emitted
# signal_name: signal name
# args: the expected signal arguments as an array
# timeout: the timeout in ms, default is set to 2000ms
func await_signal_idle_frames(source :Object, signal_name :String, args :Array = [], timeout_millis :int = 2000) -> Variant:
var line_number := GdUnitStackTrace.new().get_line_number()
# fail fast if the given source instance invalid
if not is_instance_valid(source):
@warning_ignore("return_value_discarded")
GdUnitAssertImpl.new(signal_name)\
.report_error(GdAssertMessages.error_await_signal_on_invalid_instance(source, signal_name, args), line_number)
return await await_idle_frame()
var awaiter := GdUnitSignalAwaiter.new(timeout_millis, true)
var value :Variant = await awaiter.on_signal(source, signal_name, args)
if awaiter.is_interrupted():
var failure := "await_signal_idle_frames(%s, %s) timed out after %sms" % [signal_name, args, timeout_millis]
@warning_ignore("return_value_discarded")
GdUnitAssertImpl.new(signal_name).report_error(failure, line_number)
return value
# Waits for for a given amount of milliseconds
# example:
# # waits for 100ms
# await GdUnitAwaiter.await_millis(myNode, 100).completed
# use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out
func await_millis(milliSec :int) -> void:
var timer :Timer = Timer.new()
timer.set_name("gdunit_await_millis_timer_%d" % timer.get_instance_id())
(Engine.get_main_loop() as SceneTree).root.add_child(timer)
timer.add_to_group("GdUnitTimers")
timer.set_one_shot(true)
timer.start(milliSec / 1000.0)
await timer.timeout
timer.queue_free()
# Waits until the next idle frame
func await_idle_frame() -> void:
await (Engine.get_main_loop() as SceneTree).process_frame
+1
View File
@@ -0,0 +1 @@
uid://do0d4vult83qq
+35
View File
@@ -0,0 +1,35 @@
## An Assertion Tool to verify boolean values
@abstract class_name GdUnitBoolAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitBoolAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitBoolAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitBoolAssert
## Verifies that the current value is not equal to the given one.
@abstract func is_not_equal(expected: Variant) -> GdUnitBoolAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitBoolAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitBoolAssert
## Verifies that the current value is true.
@abstract func is_true() -> GdUnitBoolAssert
## Verifies that the current value is false.
@abstract func is_false() -> GdUnitBoolAssert
@@ -0,0 +1 @@
uid://yucqgte8f3lr
+10
View File
@@ -0,0 +1,10 @@
class_name GdUnitConstants
extends RefCounted
const NO_ARG :Variant = "<--null-->"
const EXPECT_ASSERT_REPORT_FAILURES := "expect_assert_report_failures"
## The maximum number of report history files to store
const DEFAULT_REPORT_HISTORY_COUNT = 20
const REPORT_DIR_PREFIX = "report_"
@@ -0,0 +1 @@
uid://b8wk88u0ki4n7
@@ -0,0 +1,79 @@
## An Assertion Tool to verify dictionary
@abstract class_name GdUnitDictionaryAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitDictionaryAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitDictionaryAssert
## Verifies that the current dictionary is equal to the given one, ignoring order.
@abstract func is_equal(expected: Variant) -> GdUnitDictionaryAssert
## Verifies that the current dictionary is not equal to the given one, ignoring order.
@abstract func is_not_equal(expected: Variant) -> GdUnitDictionaryAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitDictionaryAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitDictionaryAssert
## Verifies that the current dictionary is empty, it has a size of 0.
@abstract func is_empty() -> GdUnitDictionaryAssert
## Verifies that the current dictionary is not empty, it has a size of minimum 1.
@abstract func is_not_empty() -> GdUnitDictionaryAssert
## Verifies that the current dictionary is the same. [br]
## Compares the current by object reference equals
@abstract func is_same(expected: Variant) -> GdUnitDictionaryAssert
## Verifies that the current dictionary is NOT the same. [br]
## Compares the current by object reference equals
@abstract func is_not_same(expected: Variant) -> GdUnitDictionaryAssert
## Verifies that the current dictionary has a size of given value.
@abstract func has_size(expected: int) -> GdUnitDictionaryAssert
## Verifies that the current dictionary contains the given key(s).[br]
## The keys are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_keys]
@abstract func contains_keys(...expected: Array) -> GdUnitDictionaryAssert
## Verifies that the current dictionary contains the given key and value.[br]
## The key and value are compared by deep parameter comparision, for object reference compare you have to use [method contains_same_key_value]
@abstract func contains_key_value(key: Variant, value: Variant) -> GdUnitDictionaryAssert
## Verifies that the current dictionary not contains the given key(s).[br]
## The keys are compared by deep parameter comparision, for object reference compare you have to use [method not_contains_same_keys]
@abstract func not_contains_keys(...expected: Array) -> GdUnitDictionaryAssert
## Verifies that the current dictionary contains the given key(s).[br]
## The keys are compared by object reference, for deep parameter comparision use [method contains_keys]
@abstract func contains_same_keys(expected: Array) -> GdUnitDictionaryAssert
## Verifies that the current dictionary contains the given key and value.[br]
## The key and value are compared by object reference, for deep parameter comparision use [method contains_key_value]
@abstract func contains_same_key_value(key: Variant, value: Variant) -> GdUnitDictionaryAssert
## Verifies that the current dictionary not contains the given key(s).
## The keys are compared by object reference, for deep parameter comparision use [method not_contains_keys]
@abstract func not_contains_same_keys(...expected: Array) -> GdUnitDictionaryAssert
@@ -0,0 +1 @@
uid://c52l1pf0xf6kd
+56
View File
@@ -0,0 +1,56 @@
## An assertion tool to verify GDUnit asserts.
## This assert is for internal use only, to verify that failed asserts work as expected.
@abstract class_name GdUnitFailureAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitFailureAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitFailureAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitFailureAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitFailureAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitFailureAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitFailureAssert
## Verifies if the executed assert was successful
@abstract func is_success() -> GdUnitFailureAssert
## Verifies if the executed assert has failed
@abstract func is_failed() -> GdUnitFailureAssert
## Verifies the failure line is equal to expected one.
@abstract func has_line(expected: int) -> GdUnitFailureAssert
## Verifies the failure stack trace is equal to expected one.
@abstract func has_stack_trace(stack_trace: Array[GdUnitStackTraceElement]) -> GdUnitFailureAssert
## Verifies the failure message is equal to expected one.
@abstract func has_message(expected: String) -> GdUnitFailureAssert
## Verifies that the failure message starts with the expected message.
@abstract func starts_with_message(expected: String) -> GdUnitFailureAssert
## Verifies that the failure message contains the expected message.
@abstract func contains_message(expected: String) -> GdUnitFailureAssert
@@ -0,0 +1 @@
uid://b7u30qhxenurx
+38
View File
@@ -0,0 +1,38 @@
@abstract class_name GdUnitFileAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitFileAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitFileAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitFileAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitFileAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitFileAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitFileAssert
@abstract func is_file() -> GdUnitFileAssert
@abstract func exists() -> GdUnitFileAssert
@abstract func is_script() -> GdUnitFileAssert
@abstract func contains_exactly(expected_rows :Array) -> GdUnitFileAssert
@@ -0,0 +1 @@
uid://oiy1n0qmt45d
+75
View File
@@ -0,0 +1,75 @@
## An Assertion Tool to verify float values
@abstract class_name GdUnitFloatAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitFloatAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitFloatAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitFloatAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitFloatAssert
## Verifies that the current and expected value are approximately equal.
@abstract func is_equal_approx(expected: float, approx: float) -> GdUnitFloatAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitFloatAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitFloatAssert
## Verifies that the current value is less than the given one.
@abstract func is_less(expected: float) -> GdUnitFloatAssert
## Verifies that the current value is less than or equal the given one.
@abstract func is_less_equal(expected: float) -> GdUnitFloatAssert
## Verifies that the current value is greater than the given one.
@abstract func is_greater(expected: float) -> GdUnitFloatAssert
## Verifies that the current value is greater than or equal the given one.
@abstract func is_greater_equal(expected: float) -> GdUnitFloatAssert
## Verifies that the current value is negative.
@abstract func is_negative() -> GdUnitFloatAssert
## Verifies that the current value is not negative.
@abstract func is_not_negative() -> GdUnitFloatAssert
## Verifies that the current value is equal to zero.
@abstract func is_zero() -> GdUnitFloatAssert
## Verifies that the current value is not equal to zero.
@abstract func is_not_zero() -> GdUnitFloatAssert
## Verifies that the current value is in the given set of values.
@abstract func is_in(expected: Array) -> GdUnitFloatAssert
## Verifies that the current value is not in the given set of values.
@abstract func is_not_in(expected: Array) -> GdUnitFloatAssert
## Verifies that the current value is between the given boundaries (inclusive).
@abstract func is_between(from: float, to: float) -> GdUnitFloatAssert
@@ -0,0 +1 @@
uid://c82pxwpofcaa3
+42
View File
@@ -0,0 +1,42 @@
## An Assertion Tool to verify function callback values
@abstract class_name GdUnitFuncAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitFuncAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitFuncAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitFuncAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitFuncAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitFuncAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitFuncAssert
## Verifies that the current value is true.
@abstract func is_true() -> GdUnitFuncAssert
## Verifies that the current value is false.
@abstract func is_false() -> GdUnitFuncAssert
## Sets the timeout in ms to wait the function returnd the expected value, if the time over a failure is emitted.[br]
## e.g.[br]
## do wait until 5s the function `is_state` is returns 10 [br]
## [code]assert_func(instance, "is_state").wait_until(5000).is_equal(10)[/code]
@abstract func wait_until(timeout: int) -> GdUnitFuncAssert
@@ -0,0 +1 @@
uid://bf4ugqv5d4oy1
@@ -0,0 +1,59 @@
## An assertion tool to verify for Godot runtime errors like assert() and push notifications like push_error().
@abstract class_name GdUnitGodotErrorAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitGodotErrorAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitGodotErrorAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitGodotErrorAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitGodotErrorAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitGodotErrorAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitGodotErrorAssert
## Verifies if the executed code runs without any runtime errors
## Usage:
## [codeblock]
## await assert_error(<callable>).is_success()
## [/codeblock]
@abstract func is_success() -> GdUnitGodotErrorAssert
## Verifies if the executed code runs into a runtime error
## Usage:
## [codeblock]
## await assert_error(<callable>).is_runtime_error(<expected error message>)
## [/codeblock]
@abstract func is_runtime_error(expected_error: Variant) -> GdUnitGodotErrorAssert
## Verifies if the executed code has a push_warning() used
## Usage:
## [codeblock]
## await assert_error(<callable>).is_push_warning(<expected push warning message>)
## [/codeblock]
@abstract func is_push_warning(expected_warning: Variant) -> GdUnitGodotErrorAssert
## Verifies if the executed code has a push_error() used
## Usage:
## [codeblock]
## await assert_error(<callable>).is_push_error(<expected push error message>)
## [/codeblock]
@abstract func is_push_error(expected_error: Variant) -> GdUnitGodotErrorAssert
@@ -0,0 +1 @@
uid://dkub4b1kkhi86
+79
View File
@@ -0,0 +1,79 @@
## An Assertion Tool to verify integer values
@abstract class_name GdUnitIntAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitIntAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitIntAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitIntAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitIntAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitIntAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitIntAssert
## Verifies that the current value is less than the given one.
@abstract func is_less(expected: int) -> GdUnitIntAssert
## Verifies that the current value is less than or equal the given one.
@abstract func is_less_equal(expected: int) -> GdUnitIntAssert
## Verifies that the current value is greater than the given one.
@abstract func is_greater(expected: int) -> GdUnitIntAssert
## Verifies that the current value is greater than or equal the given one.
@abstract func is_greater_equal(expected: int) -> GdUnitIntAssert
## Verifies that the current value is even.
@abstract func is_even() -> GdUnitIntAssert
## Verifies that the current value is odd.
@abstract func is_odd() -> GdUnitIntAssert
## Verifies that the current value is negative.
@abstract func is_negative() -> GdUnitIntAssert
## Verifies that the current value is not negative.
@abstract func is_not_negative() -> GdUnitIntAssert
## Verifies that the current value is equal to zero.
@abstract func is_zero() -> GdUnitIntAssert
## Verifies that the current value is not equal to zero.
@abstract func is_not_zero() -> GdUnitIntAssert
## Verifies that the current value is in the given set of values.
@abstract func is_in(expected: Array) -> GdUnitIntAssert
## Verifies that the current value is not in the given set of values.
@abstract func is_not_in(expected: Array) -> GdUnitIntAssert
## Verifies that the current value is between the given boundaries (inclusive).
@abstract func is_between(from: int, to: int) -> GdUnitIntAssert
@@ -0,0 +1 @@
uid://cb8iji5xfd6mw
+61
View File
@@ -0,0 +1,61 @@
## An Assertion Tool to verify Object values
@abstract class_name GdUnitObjectAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitObjectAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitObjectAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitObjectAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitObjectAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitObjectAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitObjectAssert
## Verifies that the current object is the same as the given one.
@abstract func is_same(expected: Variant) -> GdUnitObjectAssert
## Verifies that the current object is not the same as the given one.
@abstract func is_not_same(expected: Variant) -> GdUnitObjectAssert
## Verifies that the current object is an instance of the given type.
@abstract func is_instanceof(type: Variant) -> GdUnitObjectAssert
## Verifies the current object is a valid instance (e.g. has not been freed from memory).[br]
## [codeblock]
## var node := Node.new()
## assert_object(node).is_valid() # passes — node is alive
## node.free()
## assert_object(node).is_valid() # fails — node has been freed
## [/codeblock]
@abstract func is_valid() -> GdUnitObjectAssert
## Verifies that the current object is not an instance of the given type.
@abstract func is_not_instanceof(type: Variant) -> GdUnitObjectAssert
## Checks whether the current object inherits from the specified type.
@abstract func is_inheriting(type: Variant) -> GdUnitObjectAssert
## Checks whether the current object does NOT inherit from the specified type.
@abstract func is_not_inheriting(type: Variant) -> GdUnitObjectAssert
@@ -0,0 +1 @@
uid://dvu6dibmft8cm
+51
View File
@@ -0,0 +1,51 @@
## An Assertion Tool to verify Results
@abstract class_name GdUnitResultAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitResultAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitResultAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitResultAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitResultAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitResultAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitResultAssert
## Verifies that the result is ends up with empty
@abstract func is_empty() -> GdUnitResultAssert
## Verifies that the result is ends up with success
@abstract func is_success() -> GdUnitResultAssert
## Verifies that the result is ends up with warning
@abstract func is_warning() -> GdUnitResultAssert
## Verifies that the result is ends up with error
@abstract func is_error() -> GdUnitResultAssert
## Verifies that the result contains the given message
@abstract func contains_message(expected: String) -> GdUnitResultAssert
## Verifies that the result contains the given value
@abstract func is_value(expected: Variant) -> GdUnitResultAssert
@@ -0,0 +1 @@
uid://djbyj2882ht2s
+350
View File
@@ -0,0 +1,350 @@
## The Scene Runner is a tool used for simulating interactions on a scene.
## With this tool, you can simulate input events such as keyboard or mouse input and/or simulate scene processing over a certain number of frames.
## This tool is typically used for integration testing a scene.
@abstract class_name GdUnitSceneRunner
extends RefCounted
## Simulates that an action has been pressed.[br]
## [member action] : the action e.g. [code]"ui_up"[/code][br]
## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br]
@abstract func simulate_action_pressed(action: String, event_index := -1) -> GdUnitSceneRunner
## Simulates that an action is pressed.[br]
## [member action] : the action e.g. [code]"ui_up"[/code][br]
## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br]
@abstract func simulate_action_press(action: String, event_index := -1) -> GdUnitSceneRunner
## Simulates that an action has been released.[br]
## [member action] : the action e.g. [code]"ui_up"[/code][br]
## [member event_index] : [url=https://docs.godotengine.org/en/4.4/classes/class_inputeventaction.html#class-inputeventaction-property-event-index]default=-1[/url][br]
@abstract func simulate_action_release(action: String, event_index := -1) -> GdUnitSceneRunner
## Simulates that a key has been pressed.[br]
## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0
## [member key_code] : the key code e.g. [constant KEY_ENTER][br]
## [codeblock]
## func test_key_presssed():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## await runner.simulate_key_pressed(KEY_SPACE)
## [/codeblock]
@abstract func simulate_key_pressed(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner
## Simulates that a key is pressing.[br]
## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0[br]See `test_key_shift_and_A_presssing` for example using key combinations
## [member key_code] : the key code e.g. [constant KEY_ENTER][br]
## [codeblock]
## # Do simulate key pressing A
## func test_key_A_presssing():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## await runner.simulate_key_press(KEY_A)
##
##
## # Do simulate keycombination pressing shift+A
## func test_key_shift_and_A_presssing():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## runner.simulate_key_press(KEY_SHIFT)
## runner.simulate_key_press(KEY_A)
## await _runner.await_input_processed()
## [/codeblock]
@abstract func simulate_key_press(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner
## Simulates that a key has been released.[br]
## [member key_code] : the key code e.g. [constant KEY_ENTER][br]
## [codeblock]
## # Do simulate releasing key A
## func test_key_A_releasing():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## await runner.simulate_key_release(KEY_A)
##
##
## # Do simulate keycombination pressing shift+A
## func test_key_shift_and_A_releasing(():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## runner.simulate_key_release(KEY_SHIFT)
## runner.simulate_key_release(KEY_A)
## await _runner.await_input_processed()
## [/codeblock]
## @deprecated: the modifier [b]shift_pressed[/b] and [b]ctrl_pressed[/b] will be removed in v7.0[br]See `test_key_shift_and_A_releasing` for example using key combinations
@abstract func simulate_key_release(key_code: int, shift_pressed := false, ctrl_pressed := false) -> GdUnitSceneRunner
## Sets the mouse position to the specified vector, provided in pixels and relative to an origin at the upper left corner of the currently focused Window Manager game window.[br]
## [member position] : The absolute position in pixels as Vector2
@abstract func set_mouse_position(position: Vector2) -> GdUnitSceneRunner
## Returns the mouse's position in this Viewport using the coordinate system of this Viewport.
@abstract func get_mouse_position() -> Vector2
## Gets the current global mouse position of the current window
@abstract func get_global_mouse_position() -> Vector2
## Simulates a mouse moved to final position.[br]
## [member position] : The final mouse position
@abstract func simulate_mouse_move(position: Vector2) -> GdUnitSceneRunner
## Simulates a mouse move to the relative coordinates (offset).[br]
## [color=yellow]You must use [b]await[/b] to wait until the simulated mouse movement is complete.[/color][br]
## [br]
## [member relative] : The relative position, indicating the mouse position offset.[br]
## [member time] : The time to move the mouse by the relative position in seconds (default is 1 second).[br]
## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
## [codeblock]
## func test_move_mouse():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## await runner.simulate_mouse_move_relative(Vector2(100,100))
## [/codeblock]
@abstract func simulate_mouse_move_relative(relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
## Simulates a mouse move to the absolute coordinates.[br]
## [color=yellow]You must use [b]await[/b] to wait until the simulated mouse movement is complete.[/color][br]
## [br]
## [member position] : The final position of the mouse.[br]
## [member time] : The time to move the mouse to the final position in seconds (default is 1 second).[br]
## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
## [codeblock]
## func test_move_mouse():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## await runner.simulate_mouse_move_absolute(Vector2(100,100))
## [/codeblock]
@abstract func simulate_mouse_move_absolute(position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
## Simulates a mouse button pressed.[br]
## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants.
## [member double_click] : Set to true to simulate a double-click
@abstract func simulate_mouse_button_pressed(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner
## Simulates a mouse button press (holding)[br]
## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants.
## [member double_click] : Set to true to simulate a double-click
@abstract func simulate_mouse_button_press(button_index: MouseButton, double_click := false) -> GdUnitSceneRunner
## Simulates a mouse button released.[br]
## [member button_index] : The mouse button identifier, one of the [enum MouseButton] or button wheel constants.
@abstract func simulate_mouse_button_release(button_index: MouseButton) -> GdUnitSceneRunner
## Simulates a screen touch is pressed.[br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member position] : The position to touch the screen.[br]
## [member double_tap] : If true, the touch's state is a double tab.
@abstract func simulate_screen_touch_pressed(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner
## Simulates a screen touch press without releasing it immediately, effectively simulating a "hold" action.[br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member position] : The position to touch the screen.[br]
## [member double_tap] : If true, the touch's state is a double tab.
@abstract func simulate_screen_touch_press(index: int, position: Vector2, double_tap := false) -> GdUnitSceneRunner
## Simulates a screen touch is released.[br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member double_tap] : If true, the touch's state is a double tab.
@abstract func simulate_screen_touch_release(index: int, double_tap := false) -> GdUnitSceneRunner
## Simulates a touch drag and drop event to a relative position.[br]
## [color=yellow]You must use [b]await[/b] to wait until the simulated drag&drop is complete.[/color][br]
## [br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member relative] : The relative position, indicating the drag&drop position offset.[br]
## [member time] : The time to move to the relative position in seconds (default is 1 second).[br]
## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
## [codeblock]
## func test_touch_drag_drop():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## # start drag at position 50,50
## runner.simulate_screen_touch_drag_begin(1, Vector2(50, 50))
## # and drop it at final at 150,50 relative (50,50 + 100,0)
## await runner.simulate_screen_touch_drag_relative(1, Vector2(100,0))
## [/codeblock]
@abstract func simulate_screen_touch_drag_relative(index: int, relative: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
## Simulates a touch screen drop to the absolute coordinates (offset).[br]
## [color=yellow]You must use [b]await[/b] to wait until the simulated drop is complete.[/color][br]
## [br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member position] : The final position, indicating the drop position.[br]
## [member time] : The time to move to the final position in seconds (default is 1 second).[br]
## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
## [codeblock]
## func test_touch_drag_drop():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## # start drag at position 50,50
## runner.simulate_screen_touch_drag_begin(1, Vector2(50, 50))
## # and drop it at 100,50
## await runner.simulate_screen_touch_drag_absolute(1, Vector2(100,50))
## [/codeblock]
@abstract func simulate_screen_touch_drag_absolute(index: int, position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
## Simulates a complete drag and drop event from one position to another.[br]
## This is ideal for testing complex drag-and-drop scenarios that require a specific start and end position.[br]
## [color=yellow]You must use [b]await[/b] to wait until the simulated drop is complete.[/color][br]
## [br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member position] : The drag start position, indicating the drag position.[br]
## [member drop_position] : The drop position, indicating the drop position.[br]
## [member time] : The time to move to the final position in seconds (default is 1 second).[br]
## [member trans_type] : Sets the type of transition used (default is TRANS_LINEAR).[br]
## [codeblock]
## func test_touch_drag_drop():
## var runner = scene_runner("res://scenes/simple_scene.tscn")
## # start drag at position 50,50 and drop it at 100,50
## await runner.simulate_screen_touch_drag_drop(1, Vector2(50, 50), Vector2(100,50))
## [/codeblock]
@abstract func simulate_screen_touch_drag_drop(index: int, position: Vector2, drop_position: Vector2, time: float = 1.0, trans_type: Tween.TransitionType = Tween.TRANS_LINEAR) -> GdUnitSceneRunner
## Simulates a touch screen drag event to given position.[br]
## [member index] : The touch index in the case of a multi-touch event.[br]
## [member position] : The drag start position, indicating the drag position.[br]
@abstract func simulate_screen_touch_drag(index: int, position: Vector2) -> GdUnitSceneRunner
## Returns the actual position of the touchscreen drag position by given index.
## [member index] : The touch index in the case of a multi-touch event.[br]
@abstract func get_screen_touch_drag_position(index: int) -> Vector2
## Sets how fast or slow the scene simulation is processed (clock ticks versus the real).[br]
## It defaults to 1.0. A value of 2.0 means the game moves twice as fast as real life,
## whilst a value of 0.5 means the game moves at half the regular speed.
## [member time_factor] : A float representing the simulation speed.[br]
## - Default is 1.0, meaning the simulation runs at normal speed.[br]
## - A value of 2.0 means the simulation runs twice as fast as real time.[br]
## - A value of 0.5 means the simulation runs at half the regular speed.[br]
@abstract func set_time_factor(time_factor: float = 1.0) -> GdUnitSceneRunner
## Simulates scene processing for a certain number of frames.[br]
## [member frames] : amount of frames to process[br]
## [member delta_milli] : the time delta between a frame in milliseconds
@abstract func simulate_frames(frames: int, delta_milli: int = -1) -> GdUnitSceneRunner
## Simulates scene processing until the given signal is emitted by the scene.[br]
## [member signal_name] : the signal to stop the simulation[br]
## [member args] : optional signal arguments to be matched for stop[br]
@abstract func simulate_until_signal(signal_name: String, ...args: Array) -> GdUnitSceneRunner
## Simulates scene processing until the given signal is emitted by the given object.[br]
## [member source] : the object that should emit the signal[br]
## [member signal_name] : the signal to stop the simulation[br]
## [member args] : optional signal arguments to be matched for stop
@abstract func simulate_until_object_signal(source: Object, signal_name: String, ...args: Array) -> GdUnitSceneRunner
## Waits for all input events to be processed by flushing any buffered input events
## and then awaiting a full cycle of both the process and physics frames.[br]
## [br]
## This is typically used to ensure that any simulated or queued inputs are fully
## processed before proceeding with the next steps in the scene.[br]
## It's essential for reliable input simulation or when synchronizing logic based
## on inputs.[br]
##
## Usage Example:
## [codeblock]
## await await_input_processed() # Ensure all inputs are processed before continuing
## [/codeblock]
@abstract func await_input_processed() -> void
## The await_func function pauses execution until a specified function in the scene returns a value.[br]
## It returns a [GdUnitFuncAssert], which provides a suite of assertion methods to verify the returned value.[br]
## [member func_name] : The name of the function to wait for.[br]
## [member args] : Optional function arguments
## [br]
## Usage Example:
## [codeblock]
## # Waits for 'calculate_score' function and verifies the result is equal to 100.
## await_func("calculate_score").is_equal(100)
## [/codeblock]
@abstract func await_func(func_name: String, ...args: Array) -> GdUnitFuncAssert
## The await_func_on function extends the functionality of await_func by allowing you to specify a source node within the scene.[br]
## It waits for a specified function on that node to return a value and returns a [GdUnitFuncAssert] object for assertions.[br]
## [member source] : The object where implements the function.[br]
## [member func_name] : The name of the function to wait for.[br]
## [member args] : optional function arguments
## [br]
## Usage Example:
## [codeblock]
## # Waits for 'calculate_score' function and verifies the result is equal to 100.
## var my_instance := ScoreCalculator.new()
## await_func(my_instance, "calculate_score").is_equal(100)
## [/codeblock]
@abstract func await_func_on(source: Object, func_name: String, ...args: Array) -> GdUnitFuncAssert
## Waits for the specified signal to be emitted by the scene. If the signal is not emitted within the given timeout, the operation fails.[br]
## [member signal_name] : The name of the signal to wait for[br]
## [member args] : The signal arguments as an array[br]
## [member timeout] : The maximum duration (in milliseconds) to wait for the signal to be emitted before failing
@abstract func await_signal(signal_name: String, args := [], timeout := 2000 ) -> void
## Waits for the specified signal to be emitted by a particular source node. If the signal is not emitted within the given timeout, the operation fails.[br]
## [member source] : the object from which the signal is emitted[br]
## [member signal_name] : The name of the signal to wait for[br]
## [member args] : The signal arguments as an array[br]
## [member timeout] : tThe maximum duration (in milliseconds) to wait for the signal to be emitted before failing
@abstract func await_signal_on(source: Object, signal_name: String, args := [], timeout := 2000 ) -> void
## Restores the scene window to a windowed mode and brings it to the foreground.[br]
## This ensures that the scene is visible and active during testing, making it easier to observe and interact with.
@abstract func move_window_to_foreground() -> GdUnitSceneRunner
## Minimizes the scene window to a windowed mode and brings it to the background.[br]
## This ensures that the scene is hidden during testing.
@abstract func move_window_to_background() -> GdUnitSceneRunner
## Return the current value of the property with the name <name>.[br]
## [member name] : name of property[br]
## [member return] : the value of the property
@abstract func get_property(name: String) -> Variant
## Set the value <value> of the property with the name <name>.[br]
## [member name] : name of property[br]
## [member value] : value of property[br]
## [member return] : true|false depending on valid property name.
@abstract func set_property(name: String, value: Variant) -> bool
## executes the function specified by <name> in the scene and returns the result.[br]
## [member name] : the name of the function to execute[br]
## [member args] : optional function arguments[br]
## [member return] : the function result
@abstract func invoke(name: String, ...args: Array) -> Variant
## Searches for the specified node with the name in the current scene and returns it, otherwise null.[br]
## [member name] : the name of the node to find[br]
## [member recursive] : enables/disables seraching recursive[br]
## [member return] : the node if find otherwise null
@abstract func find_child(name: String, recursive: bool = true, owned: bool = false) -> Node
## Access to current running scene
@abstract func scene() -> Node
@@ -0,0 +1 @@
uid://cd83xyqmgeb3n
+158
View File
@@ -0,0 +1,158 @@
## An Assertion Tool to verify for emitted signals until a waiting time
@abstract class_name GdUnitSignalAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitSignalAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitSignalAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitSignalAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitSignalAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitSignalAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitSignalAssert
## Verifies that the specified signal is emitted with the expected arguments.[br]
##
## This assertion waits for a signal to be emitted from the object under test and
## validates that it was emitted with the correct arguments. The function supports
## both typed signals (Signal type) and string-based signal names for flexibility
## in different testing scenarios.[br]
## [br]
## [b]Parameters:[/b][br]
## [param signal_name]: The signal to monitor. Can be either:[br]
## • A [Signal] reference (recommended for type safety)[br]
## • A [String] with the signal name
## [param signal_args]: Optional expected signal arguments.[br]
## When provided, verifies the signal was emitted with exactly these values.[br]
## [br]
## [b]Returns:[/b][br]
## [GdUnitSignalAssert] - Returns self for method chaining.[br]
## [br]
## [b]Examples:[/b]
## [codeblock]
## signal signal_a(value: int)
## signal signal_b(name: String, count: int)
##
## # Wait for signal emission without checking arguments
## # Using Signal reference (type-safe)
## await assert_signal(instance).is_emitted(signal_a)
## # Using string name (dynamic)
## await assert_signal(instance).is_emitted("signal_a")
##
## # Wait for signal emission with specific argument
## await assert_signal(instance).is_emitted(signal_a, 10)
##
## # Wait for signal with multiple arguments
## await assert_signal(instance).is_emitted(signal_b, "test", 42)
##
## # Wait max 500ms for signal with argument 10
## await assert_signal(instance).wait_until(500).is_emitted(signal_a, 10)
## [/codeblock]
## [br]
## [b]Note:[/b] This is an async operation - use [code]await[/code] when calling.[br]
## The assertion fails if the signal is not emitted within the timeout period.
@abstract func is_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert
## Verifies that the specified signal is NOT emitted with the expected arguments.[br]
##
## This assertion waits for a specified time period and validates that a signal
## was not emitted with the given arguments. Useful for ensuring certain conditions
## don't trigger unwanted signals or for verifying signal filtering logic.[br]
## [br]
## [b]Parameters:[/b][br]
## [param signal_name]: The signal to monitor. Can be either:[br]
## • A [Signal] reference (recommended for type safety)[br]
## • A [String] with the signal name
## [param signal_args]: Optional expected signal arguments.[br]
## When provided, verifies the signal was not emitted with these specific values.[br]
## If omitted, verifies the signal was not emitted at all.[br]
## [br]
## [b]Returns:[/b][br]
## [GdUnitSignalAssert] - Returns self for method chaining.[br]
## [br]
## [b]Examples:[/b]
## [codeblock]
## signal signal_a(value: int)
## signal signal_b(name: String, count: int)
##
## # Verify signal is not emitted at all (without checking arguments)
## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a)
## await assert_signal(instance).wait_until(500).is_not_emitted("signal_a")
##
## # Verify signal is not emitted with specific argument
## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a, 10)
##
## # Verify signal is not emitted with multiple arguments
## await assert_signal(instance).wait_until(500).is_not_emitted(signal_b, "test", 42)
##
## # Can be emitted with different arguments (this passes)
## instance.emit_signal("signal_a", 20) # Emits with 20, not 10
## await assert_signal(instance).wait_until(500).is_not_emitted(signal_a, 10)
## [/codeblock]
## [br]
## [b]Note:[/b] This is an async operation - use [code]await[/code] when calling.[br]
## The assertion fails if the signal IS emitted with the specified arguments within the timeout period.
@abstract func is_not_emitted(signal_name: Variant, ...signal_args: Array) -> GdUnitSignalAssert
## Verifies that the specified signal exists on the emitter object.[br]
##
## This assertion checks if a signal is defined on the object under test,
## regardless of whether it has been emitted. Useful for validating that
## objects have the expected signals before testing their emission.[br]
## [br]
## [b]Parameters:[/b][br]
## [param signal_name]: The signal to check. Can be either:[br]
## • A [Signal] reference (recommended for type safety)[br]
## • A [String] with the signal name
## [br]
## [b]Returns:[/b][br]
## [GdUnitSignalAssert] - Returns self for method chaining.[br]
## [br]
## [b]Examples:[/b]
## [codeblock]
## signal my_signal(value: int)
## signal another_signal()
##
## # Verify signal exists using Signal reference
## assert_signal(instance).is_signal_exists(my_signal)
##
## # Verify signal exists using string name
## assert_signal(instance).is_signal_exists("my_signal")
##
## # Chain with other assertions
## assert_signal(instance) \
## .is_signal_exists(my_signal) \
## .is_emitted(my_signal, 42)
##
## [/codeblock]
## [br]
## [b]Note:[/b] This only checks signal definition, not emission.[br]
## The assertion fails if the signal is not defined on the object.
@abstract func is_signal_exists(signal_name: Variant) -> GdUnitSignalAssert
## Sets the assert signal timeout in ms, if the time over a failure is reported.[br]
## Example:
## [codeblock]
## do wait until 5s the instance has emitted the signal `signal_a`[br]
## assert_signal(instance).wait_until(5000).is_emitted("signal_a")
## [/codeblock]
@abstract func wait_until(timeout: int) -> GdUnitSignalAssert
@@ -0,0 +1 @@
uid://dnno7sv5ruwws
+71
View File
@@ -0,0 +1,71 @@
## An Assertion Tool to verify String values
@abstract class_name GdUnitStringAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitStringAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitStringAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitStringAssert
## Verifies that the current String is equal to the given one, ignoring case considerations.
@abstract func is_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitStringAssert
## Verifies that the current String is not equal to the given one, ignoring case considerations.
@abstract func is_not_equal_ignoring_case(expected: Variant) -> GdUnitStringAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitStringAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitStringAssert
## Verifies that the current String is empty, it has a length of 0.
@abstract func is_empty() -> GdUnitStringAssert
## Verifies that the current String is not empty, it has a length of minimum 1.
@abstract func is_not_empty() -> GdUnitStringAssert
## Verifies that the current String contains the given String.
@abstract func contains(expected: String) -> GdUnitStringAssert
## Verifies that the current String does not contain the given String.
@abstract func not_contains(expected: String) -> GdUnitStringAssert
## Verifies that the current String does not contain the given String, ignoring case considerations.
@abstract func contains_ignoring_case(expected: String) -> GdUnitStringAssert
## Verifies that the current String does not contain the given String, ignoring case considerations.
@abstract func not_contains_ignoring_case(expected: String) -> GdUnitStringAssert
## Verifies that the current String starts with the given prefix.
@abstract func starts_with(expected: String) -> GdUnitStringAssert
## Verifies that the current String ends with the given suffix.
@abstract func ends_with(expected: String) -> GdUnitStringAssert
## Verifies that the current String has the expected length by used comparator.
@abstract func has_length(length: int, comparator: int = Comparator.EQUAL) -> GdUnitStringAssert
@@ -0,0 +1 @@
uid://c0l4juv45771a
+774
View File
@@ -0,0 +1,774 @@
## The main class for all GdUnit test suites[br]
## This class is the main class to implement your unit tests[br]
## You have to extend and implement your test cases as described[br]
## e.g MyTests.gd [br]
## [codeblock]
## extends GdUnitTestSuite
## # testcase
## func test_case_a():
## assert_that("value").is_equal("value")
## [/codeblock]
## @tutorial: https://mikeschulze.github.io/gdUnit4/faq/test-suite/
@icon("res://addons/gdUnit4/src/ui/settings/logo.png")
class_name GdUnitTestSuite
extends Node
### internal runtime variables that must not be overwritten!!!
@warning_ignore("unused_private_class_variable")
var __is_skipped := false
@warning_ignore("unused_private_class_variable")
var __skip_reason := "Unknow."
var __active_test_case: String
var __awaiter := __gdunit_awaiter()
### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading"
### in order to noticeably reduce the loading time of the test suite.
# We go this hard way to increase the loading performance to avoid reparsing all the used scripts
# for more detailed info -> https://github.com/godotengine/godot/issues/67400
static func __lazy_load(script_path: String) -> GDScript:
return GdUnitAssertions.__lazy_load(script_path)
func __gdunit_assert() -> GDScript:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd")
func __gdunit_tools() -> GDScript:
return __lazy_load("res://addons/gdUnit4/src/core/GdUnitTools.gd")
func __gdunit_file_access() -> GDScript:
return __lazy_load("res://addons/gdUnit4/src/core/GdUnitFileAccess.gd")
func __gdunit_awaiter() -> Object:
return __lazy_load("res://addons/gdUnit4/src/GdUnitAwaiter.gd").new()
func __gdunit_argument_matchers() -> GDScript:
return __lazy_load("res://addons/gdUnit4/src/matchers/GdUnitArgumentMatchers.gd")
func __gdunit_object_interactions() -> GDScript:
return __lazy_load("res://addons/gdUnit4/src/doubler/GdUnitObjectInteractions.gd")
## This function is called before a test suite starts[br]
## You can overwrite to prepare test data or initalizize necessary variables
func before() -> void:
pass
## This function is called at least when a test suite is finished[br]
## You can overwrite to cleanup data created during test running
func after() -> void:
pass
## This function is called before a test case starts[br]
## You can overwrite to prepare test case specific data
func before_test() -> void:
pass
## This function is called after the test case is finished[br]
## You can overwrite to cleanup your test case specific data
func after_test() -> void:
pass
func is_failure() -> bool:
return Engine.get_meta("GD_TEST_FAILURE") if Engine.has_meta("GD_TEST_FAILURE") else false
func set_active_test_case(test_case: String) -> void:
__active_test_case = test_case
# === Tools ====================================================================
# Mapps Godot error number to a readable error message. See at ERROR
# https://docs.godotengine.org/de/stable/classes/class_@globalscope.html#enum-globalscope-error
func error_as_string(error_number: int) -> String:
return error_string(error_number)
## A litle helper to auto freeing your created objects after test execution
func auto_free(obj: Variant) -> Variant:
var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
# If the execution context is null, we are not in the execution call stack, so we mark the object as “queued free.”
if execution_context == null:
if obj is Node:
@warning_ignore("unsafe_method_access")
obj.queue_free()
return obj
return execution_context.register_auto_free(obj)
## Saves the current project settings into the active execution context.[br]
## Useful when the "Auto Save Project Settings" setting is disabled and a test needs settings isolation on demand.[br]
## [br]
## Call this in [code]before()[/code]/[code]before_test()[/code] to snapshot the settings a test
## is about to change, to restore see [method restore_project_settings].
func save_project_settings() -> void:
var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
if execution_context == null:
return
execution_context.save_project_settings()
## Restores the project settings previously captured by [method save_project_settings].[br]
## [br]
## Call this in [code]after()[/code]/[code]after_test()[/code] to undo any settings a test changed.
func restore_project_settings() -> void:
var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
if execution_context == null:
return
execution_context.restore_project_settings()
@warning_ignore("native_method_override")
func add_child(node: Node, force_readable_name := false, internal := Node.INTERNAL_MODE_DISABLED) -> void:
super.add_child(node, force_readable_name, internal)
var execution_context := GdUnitThreadManager.get_current_context().get_execution_context()
if execution_context != null:
execution_context.orphan_monitor_start()
## Discard the error message triggered by a timeout (interruption).[br]
## By default, an interrupted test is reported as an error.[br]
## This function allows you to change the message to Success when an interrupted error is reported.
func discard_error_interupted_by_timeout() -> void:
@warning_ignore("unsafe_method_access")
__gdunit_tools().register_expect_interupted_by_timeout(self, __active_test_case)
## Creates a new directory under the temporary directory *user://tmp*[br]
## Useful for storing data during test execution. [br]
## The directory is automatically deleted after test suite execution
func create_temp_dir(relative_path: String) -> String:
@warning_ignore("unsafe_method_access")
return __gdunit_file_access().create_temp_dir(relative_path)
## Deletes the temporary base directory[br]
## Is called automatically after each execution of the test suite
func clean_temp_dir() -> void:
@warning_ignore("unsafe_method_access")
__gdunit_file_access().clear_tmp()
## Creates a new file under the temporary directory *user://tmp* + <relative_path>[br]
## with given name <file_name> and given file <mode> (default = File.WRITE)[br]
## If success the returned File is automatically closed after the execution of the test suite
func create_temp_file(relative_path: String, file_name: String, mode := FileAccess.WRITE) -> FileAccess:
@warning_ignore("unsafe_method_access")
return __gdunit_file_access().create_temp_file(relative_path, file_name, mode)
## Reads a resource by given path <resource_path> into a PackedStringArray.
func resource_as_array(resource_path: String) -> PackedStringArray:
@warning_ignore("unsafe_method_access")
return __gdunit_file_access().resource_as_array(resource_path)
## Reads a resource by given path <resource_path> and returned the content as String.
func resource_as_string(resource_path: String) -> String:
@warning_ignore("unsafe_method_access")
return __gdunit_file_access().resource_as_string(resource_path)
## Reads a resource by given path <resource_path> and return Variand translated by str_to_var
func resource_as_var(resource_path: String) -> Variant:
@warning_ignore("unsafe_method_access", "unsafe_cast")
return str_to_var(__gdunit_file_access().resource_as_string(resource_path) as String)
## Waits for given signal to be emitted by <source> until a specified timeout to fail[br]
## source: the object from which the signal is emitted[br]
## signal_name: signal name[br]
## args: the expected signal arguments as an array[br]
## timeout: the timeout in ms, default is set to 2000ms
func await_signal_on(source: Object, signal_name: String, args: Array = [], timeout: int = 2000) -> Variant:
@warning_ignore("unsafe_method_access")
return await __awaiter.await_signal_on(source, signal_name, args, timeout)
## Waits until the next idle frame
func await_idle_frame() -> void:
@warning_ignore("unsafe_method_access")
await __awaiter.await_idle_frame()
## Waits for a given amount of milliseconds[br]
## example:[br]
## [codeblock]
## # waits for 100ms
## await await_millis(myNode, 100).completed
## [/codeblock][br]
## use this waiter and not `await get_tree().create_timer().timeout to prevent errors when a test case is timed out
func await_millis(timeout: int) -> void:
@warning_ignore("unsafe_method_access")
await __awaiter.await_millis(timeout)
## Collects detailed information about orphaned nodes for debugging purposes.[br]
##
## This function gathers comprehensive details about nodes that remain in memory
## after test execution (orphans). It provides debugging information to help
## identify the source of memory leaks in tests. Must be manually called in
## tests when orphan nodes are detected.[br]
## [br]
## [b]When to Use:[/b][br]
## - When GdUnit4 reports orphan nodes after test execution[br]
## - For debugging memory leaks in test scenarios[br]
## - To get detailed information about unreleased nodes[br]
## [br]
## [b]Usage Pattern:[/b][br]
## Add this call at the end of tests that are suspected to create orphans,
## or when the test runner reports orphan detection.[br]
## [br]
## [b]Examples:[/b]
## [codeblock]
## func test_scene_management():
## # Test code that might create orphan nodes
## var scene = preload("res://TestScene.tscn").instantiate()
## add_child(scene)
##
## # Do test operations
## scene.some_method()
##
## # Clean up (but might miss some nodes)
## scene.queue_free()
##
## # Collect orphan details if any are detected
## collect_orphan_node_details()
## [/codeblock]
## [br]
## [b]Note:[/b] This is a debugging utility function that should be removed
## or commented out once orphan issues are resolved.
func collect_orphan_node_details() -> void:
GdUnitThreadManager.get_current_context().get_execution_context().orphan_monitor_collect()
## Creates a new scene runner to allow simulate interactions checked a scene.[br]
## The runner will manage the scene instance and release after the runner is released[br]
## example:[br]
## [codeblock]
## # creates a runner by using a instanciated scene
## var scene = load("res://foo/my_scne.tscn").instantiate()
## var runner := scene_runner(scene)
##
## # or simply creates a runner by using the scene resource path
## var runner := scene_runner("res://foo/my_scne.tscn")
## [/codeblock]
func scene_runner(scene: Variant, verbose := false) -> GdUnitSceneRunner:
return auto_free(__lazy_load("res://addons/gdUnit4/src/core/GdUnitSceneRunnerImpl.gd").new(scene, verbose))
# === Mocking & Spy ===========================================================
## do return a default value for primitive types or null
const RETURN_DEFAULTS = GdUnitMock.RETURN_DEFAULTS
## do call the real implementation
const CALL_REAL_FUNC = GdUnitMock.CALL_REAL_FUNC
## do return a default value for primitive types and a fully mocked value for Object types
## builds full deep mocked object
const RETURN_DEEP_STUB = GdUnitMock.RETURN_DEEP_STUB
## Creates a mock for given class name
func mock(clazz: Variant, mock_mode := RETURN_DEFAULTS) -> Variant:
@warning_ignore("unsafe_method_access")
return __lazy_load("res://addons/gdUnit4/src/mocking/GdUnitMockBuilder.gd").build(clazz, mock_mode)
## Creates a spy checked given object instance
func spy(instance: Variant) -> Variant:
@warning_ignore("unsafe_method_access")
return __lazy_load("res://addons/gdUnit4/src/spy/GdUnitSpyBuilder.gd").build(instance)
## Configures a return value for the specified function and used arguments.[br]
## [b]Example:
## [codeblock]
## # overrides the return value of myMock.is_selected() to false
## do_return(false).on(myMock).is_selected()
## [/codeblock]
func do_return(value: Variant) -> GdUnitMock:
return GdUnitMock.new(value)
## Verifies certain behavior happened at least once or exact number of times
func verify(obj: Variant, times := 1) -> Variant:
@warning_ignore("unsafe_method_access")
return __gdunit_object_interactions().verify(obj, times)
## Verifies no interactions is happen checked this mock or spy
func verify_no_interactions(obj: Variant) -> GdUnitAssert:
@warning_ignore("unsafe_method_access")
return __gdunit_object_interactions().verify_no_interactions(obj)
## Verifies the given mock or spy has any unverified interaction.
func verify_no_more_interactions(obj: Variant) -> GdUnitAssert:
@warning_ignore("unsafe_method_access")
return __gdunit_object_interactions().verify_no_more_interactions(obj)
## Resets the saved function call counters checked a mock or spy
func reset(obj: Variant) -> void:
@warning_ignore("unsafe_method_access")
__gdunit_object_interactions().reset(obj)
## Starts monitoring the specified source to collect all transmitted signals.[br]
## The collected signals can then be checked with 'assert_signal'.[br]
## By default, the specified source is automatically released when the test ends.
## You can control this behavior by setting auto_free to false if you do not want the source to be automatically freed.[br]
## Usage:
## [codeblock]
## var emitter := monitor_signals(MyEmitter.new())
## # call the function to send the signal
## emitter.do_it()
## # verify the signial is emitted
## await assert_signal(emitter).is_emitted('my_signal')
## [/codeblock]
func monitor_signals(source: Object, _auto_free := true) -> Object:
@warning_ignore("unsafe_method_access")
__lazy_load("res://addons/gdUnit4/src/core/thread/GdUnitThreadManager.gd")\
.get_current_context()\
.get_signal_collector()\
.register_emitter(source, true) # force recreate to start with a fresh monitoring
return auto_free(source) if _auto_free else source
# === Argument matchers ========================================================
## Argument matcher to match any argument
func any() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().any()
## Argument matcher to match any boolean value
func any_bool() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_BOOL)
## Argument matcher to match any integer value
func any_int() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_INT)
## Argument matcher to match any float value
func any_float() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_FLOAT)
## Argument matcher to match any String value
func any_string() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_STRING)
## Argument matcher to match any Color value
func any_color() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_COLOR)
## Argument matcher to match any Vector typed value
func any_vector() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_types([
TYPE_VECTOR2,
TYPE_VECTOR2I,
TYPE_VECTOR3,
TYPE_VECTOR3I,
TYPE_VECTOR4,
TYPE_VECTOR4I,
])
## Argument matcher to match any Vector2 value
func any_vector2() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_VECTOR2)
## Argument matcher to match any Vector2i value
func any_vector2i() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_VECTOR2I)
## Argument matcher to match any Vector3 value
func any_vector3() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_VECTOR3)
## Argument matcher to match any Vector3i value
func any_vector3i() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_VECTOR3I)
## Argument matcher to match any Vector4 value
func any_vector4() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_VECTOR4)
## Argument matcher to match any Vector4i value
func any_vector4i() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_VECTOR4I)
## Argument matcher to match any Rect2 value
func any_rect2() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_RECT2)
## Argument matcher to match any Plane value
func any_plane() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PLANE)
## Argument matcher to match any Quaternion value
func any_quat() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_QUATERNION)
## Argument matcher to match any AABB value
func any_aabb() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_AABB)
## Argument matcher to match any Basis value
func any_basis() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_BASIS)
## Argument matcher to match any Transform2D value
func any_transform_2d() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM2D)
## Argument matcher to match any Transform3D value
func any_transform_3d() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_TRANSFORM3D)
## Argument matcher to match any NodePath value
func any_node_path() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_NODE_PATH)
## Argument matcher to match any RID value
func any_rid() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_RID)
## Argument matcher to match any Object value
func any_object() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_OBJECT)
## Argument matcher to match any Dictionary value
func any_dictionary() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_DICTIONARY)
## Argument matcher to match any Array value
func any_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_ARRAY)
## Argument matcher to match any PackedByteArray value
func any_packed_byte_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_BYTE_ARRAY)
## Argument matcher to match any PackedInt32Array value
func any_packed_int32_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT32_ARRAY)
## Argument matcher to match any PackedInt64Array value
func any_packed_int64_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_INT64_ARRAY)
## Argument matcher to match any PackedFloat32Array value
func any_packed_float32_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT32_ARRAY)
## Argument matcher to match any PackedFloat64Array value
func any_packed_float64_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_FLOAT64_ARRAY)
## Argument matcher to match any PackedStringArray value
func any_packed_string_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_STRING_ARRAY)
## Argument matcher to match any PackedVector2Array value
func any_packed_vector2_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR2_ARRAY)
## Argument matcher to match any PackedVector3Array value
func any_packed_vector3_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_VECTOR3_ARRAY)
## Argument matcher to match any PackedColorArray value
func any_packed_color_array() -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().by_type(TYPE_PACKED_COLOR_ARRAY)
## Argument matcher to match any instance of given class
func any_class(clazz :Object) -> GdUnitArgumentMatcher:
@warning_ignore("unsafe_method_access")
return __gdunit_argument_matchers().any_class(clazz)
# === value extract utils ======================================================
## Builds an extractor by given function name and optional arguments
func extr(func_name: String, args := Array()) -> GdUnitValueExtractor:
return __lazy_load("res://addons/gdUnit4/src/extractors/GdUnitFuncValueExtractor.gd").new(func_name, args)
## Creates a GdUnitTuple from the provided arguments for use in test assertions.
## [br]
## This is the primary helper function for creating tuples in GdUnit4 tests.
## It provides a convenient way to group multiple expected values when using
## [method extractv] assertions. The function enforces that tuples must contain
## at least two values, as single-value extractions don't require tuple grouping.
## [br]
## [b]Parameters:[/b] [br]
## - [code]...args[/code]: Variable number of arguments (minimum 2) to group into a tuple.
## Each argument represents a value to be compared in assertions.
## [br]
## [b]Returns:[/b] [br]
## A [GdUnitTuple] containing the provided values, or an empty tuple if fewer than
## 2 arguments are provided (with an error message).
## [br]
## [b]Error Handling:[/b] [br]
## [codeblock]
## # This will push an error and return empty tuple
## var invalid = tuple("single_value") # Error: requires at least 2 arguments
## [br]
## # Correct usage - minimum 2 arguments
## var valid = tuple("name", "value")
## var valid_multi = tuple(1, 2, 3, 4, 5) # Can have many values
## [/codeblock]
func tuple(...args: Array) -> GdUnitTuple:
if args.size() < 2:
push_error("Tuple requires at least two arguments.")
return GdUnitTuple.new()
return GdUnitTuple.new.callv(args)
# === Asserts ==================================================================
## The common assertion tool to verify values.
## It checks the given value by type to fit to the best assert
func assert_that(current: Variant) -> GdUnitAssert:
match typeof(current):
TYPE_BOOL:
return assert_bool(current)
TYPE_INT:
return assert_int(current)
TYPE_FLOAT:
return assert_float(current)
TYPE_STRING:
return assert_str(current)
TYPE_VECTOR2, TYPE_VECTOR2I, TYPE_VECTOR3, TYPE_VECTOR3I, TYPE_VECTOR4, TYPE_VECTOR4I:
return assert_vector(current, false)
TYPE_DICTIONARY:
return assert_dict(current)
TYPE_ARRAY, TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,\
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,\
TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY:
return assert_array(current, false)
TYPE_OBJECT, TYPE_NIL:
return assert_object(current)
_:
return __gdunit_assert().new(current)
## An assertion tool to verify boolean values.
static func assert_bool(current: Variant) -> GdUnitBoolAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd").new(current)
## An assertion tool to verify String values.
func assert_str(current: Variant) -> GdUnitStringAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd").new(current)
## An assertion tool to verify integer values.
func assert_int(current: Variant) -> GdUnitIntAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd").new(current)
## An assertion tool to verify float values.
func assert_float(current: Variant) -> GdUnitFloatAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd").new(current)
## An assertion tool to verify Vector values.[br]
## This assertion supports all vector types.[br]
## Usage:
## [codeblock]
## assert_vector(Vector2(1.2, 1.000001)).is_equal(Vector2(1.2, 1.000001))
## [/codeblock]
func assert_vector(current: Variant, type_check := true) -> GdUnitVectorAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd").new(current, type_check)
## An assertion tool to verify arrays.
func assert_array(current: Variant, type_check := true) -> GdUnitArrayAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd").new(current, type_check)
## An assertion tool to verify dictionaries.
func assert_dict(current: Variant) -> GdUnitDictionaryAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd").new(current)
## An assertion tool to verify FileAccess.
func assert_file(current: Variant) -> GdUnitFileAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd").new(current)
## An assertion tool to verify Objects.
func assert_object(current: Variant) -> GdUnitObjectAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd").new(current)
func assert_result(current: Variant) -> GdUnitResultAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd").new(current)
## An assertion tool that waits until a certain time for an expected function return value
func assert_func(instance: Object, func_name: String, args := Array()) -> GdUnitFuncAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd").new(instance, func_name, args)
## An assertion tool to verify for emitted signals until a certain time.
func assert_signal(instance: Object) -> GdUnitSignalAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd").new(instance)
## An assertion tool to test for failing assertions.[br]
## This assert is only designed for internal use to verify failing asserts working as expected.[br]
## Usage:
## [codeblock]
## assert_failure(func(): assert_bool(true).is_not_equal(true)) \
## .has_message("Expecting:\n 'true'\n not equal to\n 'true'")
## [/codeblock]
func assert_failure(assertion: Callable) -> GdUnitFailureAssert:
@warning_ignore("unsafe_method_access")
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute(assertion)
## An assertion tool to test for failing assertions.[br]
## This assert is only designed for internal use to verify failing asserts working as expected.[br]
## Usage:
## [codeblock]
## await assert_failure_await(func(): assert_bool(true).is_not_equal(true)) \
## .has_message("Expecting:\n 'true'\n not equal to\n 'true'")
## [/codeblock]
func assert_failure_await(assertion: Callable) -> GdUnitFailureAssert:
@warning_ignore("unsafe_method_access")
return await __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd").new().execute_and_await(assertion)
## An assertion tool to verify Godot errors.[br]
## You can use to verify certain Godot errors like failing assertions, push_error, push_warn.[br]
## Usage:
## [codeblock]
## # tests no error occurred during execution of the code
## await assert_error(func (): return 0 )\
## .is_success()
##
## # tests a push_error('test error') occured during execution of the code
## await assert_error(func (): push_error('test error') )\
## .is_push_error('test error')
## [/codeblock]
func assert_error(current: Callable) -> GdUnitGodotErrorAssert:
return __lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd").new(current)
## Explicitly fails the current test indicating that the feature is not yet implemented.[br]
## This function is useful during development when you want to write test cases before implementing the actual functionality.[br]
## It provides a clear indication that the test failure is expected because the feature is still under development.[br]
## Usage:
## [codeblock]
## # Test for a feature that will be implemented later
## func test_advanced_ai_behavior():
## assert_not_yet_implemented()
##
## [/codeblock]
func assert_not_yet_implemented() -> void:
@warning_ignore("unsafe_method_access")
__gdunit_assert().new(null).do_fail_not_implemented()
## Explicitly fails the current test with a custom error message.[br]
## This function reports an error but does not terminate test execution automatically.[br]
## You must use 'return' after calling fail() to stop the test since GDScript has no exception support.[br]
## Useful for complex conditional testing scenarios where standard assertions are insufficient.[br]
## Usage:
## [codeblock]
## # Fail test when conditions are not met
## if !custom_check(player):
## fail("Player should be alive but has %d health" % player.health)
## return
##
## # Continue with test if conditions pass
## assert_that(player.health).is_greater(0)
## [/codeblock]
func fail(message: String) -> void:
@warning_ignore("unsafe_method_access")
__gdunit_assert().new(null).do_fail_with_message(message)
# --- internal stuff do not override!!!
func ResourcePath() -> String:
return get_script().resource_path
@@ -0,0 +1 @@
uid://bqh4y8rpdssrx
+86
View File
@@ -0,0 +1,86 @@
## A tuple implementation for GdUnit4 test assertions and value extraction.
## @tutorial(GdUnit4 Array Assertions): https://mikeschulze.github.io/gdUnit4/latest/testing/assert-array/#extractv
## @tutorial(GdUnit4 Testing Framework): https://mikeschulze.github.io/gdUnit4/
## [br]
## The GdUnitTuple class is a utility container designed specifically for the GdUnit4
## testing framework. It enables advanced assertion operations, particularly when
## extracting and comparing multiple values from complex test results.
## [br]
## [b]Primary Use Cases in Testing:[/b] [br]
## - Extracting multiple properties from test objects with [method extractv]## [br]
## - Grouping related assertion values for comparison## [br]
## - Returning multiple values from test helper methods## [br]
## - Organizing expected vs actual value pairs in assertions## [br]
## [br]
## [b]Example Usage in Tests:[/b]
## [codeblock]
## func test_player_stats_after_level_up():
## var player = Player.new()
## player.level_up()
##
## # Extract multiple properties using tuple
## assert_array([player]) \
## .extractv(extr("name"), extr("level"), extr("hp")) \
## .contains(tuple("Hero", 2, 150))
##
## func test_enemy_spawn_positions():
## var enemies: Array = spawn_enemies(3)
##
## # Verify multiple enemies have correct position data
## assert_array(enemies) \
## .extractv(extr("position.x"), extr("position.y")) \
## .contains_exactly([
## tuple(100, 200),
## tuple(150, 200),
## tuple(200, 200)
## ])
## [/codeblock]
## [br]
## [b]Integration with GdUnit4 Assertions:[/b] [br]
## Tuples work seamlessly with array assertion methods like: [br]
## - [code]contains()[/code] - Check if extracted values contain specific tuples [br]
## - [code]contains_exactly()[/code] - Verify exact tuple matches [br]
## - [code]is_equal()[/code] - Compare tuple equality [br]
## [br]
## [b]Note:[/b] This class is part of the GdUnit4 testing framework's internal
## utilities and is primarily intended for use within test assertions rather
## than production code.
class_name GdUnitTuple
extends RefCounted
var _values: Array = []
## Initializes a new GdUnitTuple with test values.
## [br]
## Creates a tuple to hold multiple values extracted from test objects
## or expected values for assertions. Commonly used with the [code]tuple()[/code]
## helper function in GdUnit4 tests.
## [br]
## [b]Parameters:[/b]
## - [code]...args[/code]: Variable number of values to store.
func _init(...args: Array) -> void:
_values = args
## Returns the tuple's values as an array for assertion comparisons.
## [br]
## Provides access to the stored test values. Used internally by GdUnit4's
## assertion system when comparing tuples in test validations.
## [br]
## [b]Returns:[/b]
## An [Array] containing all values stored in the tuple.
func values() -> Array:
return _values
## Returns a string representation for test output and debugging.
## [br]
## Formats the tuple for display in test results, error messages, and debug logs.
## This method is automatically called by GdUnit4 when displaying assertion
## failures involving tuples.
## [br]
## [b]Returns:[/b]
## A [String] in the format "tuple([value1, value2, ...])"
func _to_string() -> String:
return "tuple(%s)" % str(_values)
+1
View File
@@ -0,0 +1 @@
uid://btxdkihimm6t2
@@ -0,0 +1,9 @@
## This is the base interface for value extraction
class_name GdUnitValueExtractor
extends RefCounted
## Extracts a value by given implementation
func extract_value(value :Variant) -> Variant:
push_error("Uninplemented func 'extract_value'")
return value
@@ -0,0 +1 @@
uid://be6abg0ghp37l
+55
View File
@@ -0,0 +1,55 @@
## An Assertion Tool to verify Vector values
@abstract class_name GdUnitVectorAssert
extends GdUnitAssert
## Verifies that the current value is null.
@abstract func is_null() -> GdUnitVectorAssert
## Verifies that the current value is not null.
@abstract func is_not_null() -> GdUnitVectorAssert
## Verifies that the current value is equal to the given one.
@abstract func is_equal(expected: Variant) -> GdUnitVectorAssert
## Verifies that the current value is not equal to expected one.
@abstract func is_not_equal(expected: Variant) -> GdUnitVectorAssert
## Verifies that the current and expected value are approximately equal.
@abstract func is_equal_approx(expected: Variant, approx: Variant) -> GdUnitVectorAssert
## Overrides the default failure message by given custom message.
@abstract func override_failure_message(message: String) -> GdUnitVectorAssert
## Appends a custom message to the failure message.
@abstract func append_failure_message(message: String) -> GdUnitVectorAssert
## Verifies that the current value is less than the given one.
@abstract func is_less(expected: Variant) -> GdUnitVectorAssert
## Verifies that the current value is less than or equal the given one.
@abstract func is_less_equal(expected: Variant) -> GdUnitVectorAssert
## Verifies that the current value is greater than the given one.
@abstract func is_greater(expected: Variant) -> GdUnitVectorAssert
## Verifies that the current value is greater than or equal the given one.
@abstract func is_greater_equal(expected: Variant) -> GdUnitVectorAssert
## Verifies that the current value is between the given boundaries (inclusive).
@abstract func is_between(from: Variant, to: Variant) -> GdUnitVectorAssert
## Verifies that the current value is not between the given boundaries (inclusive).
@abstract func is_not_between(from: Variant, to: Variant) -> GdUnitVectorAssert
@@ -0,0 +1 @@
uid://bchhqrm7gxygm
@@ -0,0 +1,25 @@
# a value provider unsing a callback to get `next` value from a certain function
class_name CallBackValueProvider
extends ValueProvider
var _cb :Callable
var _args :Array
func _init(instance :Object, func_name :String, args :Array = Array(), force_error := true) -> void:
_cb = Callable(instance, func_name);
_args = args
if force_error and not _cb.is_valid():
push_error("Can't find function '%s' checked instance %s" % [func_name, instance])
func get_value() -> Variant:
if not _cb.is_valid():
return null
if _args.is_empty():
return await _cb.call()
return await _cb.callv(_args)
func dispose() -> void:
_cb = Callable()
@@ -0,0 +1 @@
uid://bauwyi0k0fetp
@@ -0,0 +1,13 @@
# default value provider, simple returns the initial value
class_name DefaultValueProvider
extends ValueProvider
var _value: Variant
func _init(value: Variant) -> void:
_value = value
func get_value() -> Variant:
return _value
@@ -0,0 +1 @@
uid://b24r327dm6o6m
@@ -0,0 +1,757 @@
class_name GdAssertMessages
extends Resource
const SUB_COLOR := Color(1, 0, 0, .15)
const ADD_COLOR := Color(0, 1, 0, .15)
const NO_ORPHAN_DETAILS := "⚠️No details available. Run tests in debug mode to collect details."
# Dictionary of control characters and their readable representations
const CONTROL_CHARS = {
"\n": "<LF>", # Line Feed
"\r": "<CR>", # Carriage Return
"\t": "<TAB>", # Tab
"\b": "<BS>", # Backspace
"\f": "<FF>", # Form Feed
"\v": "<VT>", # Vertical Tab
"\a": "<BEL>", # Bell
"": "<ESC>" # Escape
}
static var _warn_color := GdUnitEditorColorTheme.state_warning.to_html()
static var _error_color := GdUnitEditorColorTheme.state_failure.to_html()
static var _value_color := GdUnitEditorColorTheme.value_color.to_html()
static func format_dict(value :Variant) -> String:
if not value is Dictionary:
return str(value)
var dict_value: Dictionary = value
if dict_value.is_empty():
return "{ }"
var as_rows := var_to_str(value).split("\n")
for index in range( 1, as_rows.size()-1):
as_rows[index] = " " + as_rows[index]
as_rows[-1] = " " + as_rows[-1]
return "\n".join(as_rows)
# improved version of InputEvent as text
static func input_event_as_text(event :InputEvent) -> String:
var text := ""
if event is InputEventKey:
var key_event := event as InputEventKey
text += """
InputEventKey : keycode=%s (%s) pressed: %s
physical_keycode: %s
location: %s
echo: %s""" % [
key_event.keycode,
key_event.as_text_keycode(),
key_event.pressed,
key_event.physical_keycode,
key_event.location,
key_event.echo]
else:
text += event.as_text()
if event is InputEventMouse:
var mouse_event := event as InputEventMouse
text += """
global_position: %s""" % mouse_event.global_position
if event is InputEventWithModifiers:
var mouse_event := event as InputEventWithModifiers
text += """
--------
mods: %s
shift: %s
alt: %s
control: %s
meta: %s
command: %s""" % [
mouse_event.get_modifiers_mask(),
mouse_event.shift_pressed,
mouse_event.alt_pressed,
mouse_event.ctrl_pressed,
mouse_event.meta_pressed,
mouse_event.command_or_control_autoremap]
return text.dedent()
static func _colored_string_div(characters: String) -> String:
return colored_array_div(characters.to_utf32_buffer().to_int32_array())
static func colored_array_div(characters: PackedInt32Array) -> String:
if characters.is_empty():
return "<empty>"
var result := PackedInt32Array()
var index := 0
var missing_chars := PackedInt32Array()
var additional_chars := PackedInt32Array()
while index < characters.size():
var character := characters[index]
match character:
GdDiffTool.DIV_ADD:
index += 1
@warning_ignore("return_value_discarded")
additional_chars.append(characters[index])
GdDiffTool.DIV_SUB:
index += 1
@warning_ignore("return_value_discarded")
missing_chars.append(characters[index])
_:
if not missing_chars.is_empty():
result.append_array(format_chars(missing_chars, SUB_COLOR))
missing_chars = PackedInt32Array()
if not additional_chars.is_empty():
result.append_array(format_chars(additional_chars, ADD_COLOR))
additional_chars = PackedInt32Array()
@warning_ignore("return_value_discarded")
result.append(character)
index += 1
result.append_array(format_chars(missing_chars, SUB_COLOR))
result.append_array(format_chars(additional_chars, ADD_COLOR))
return result.to_byte_array().get_string_from_utf32()
static func _typed_value(value :Variant) -> String:
return GdDefaultValueDecoder.decode(value)
static func _warning(error :String) -> String:
return "[color=%s]%s[/color]" % [_warn_color, error]
static func _error(error :String) -> String:
return "[color=%s]%s[/color]" % [_error_color, error]
static func _nerror(number :Variant) -> String:
match typeof(number):
TYPE_INT:
return "[color=%s]%d[/color]" % [_error_color, number]
TYPE_FLOAT:
return "[color=%s]%f[/color]" % [_error_color, number]
_:
return "[color=%s]%s[/color]" % [_error_color, str(number)]
static func _colored(value: Variant, color: Color) -> String:
return "[color=%s]%s[/color]" % [color.to_html(), value]
static func _colored_value(value :Variant) -> String:
match typeof(value):
TYPE_STRING, TYPE_STRING_NAME:
return "'[color=%s]%s[/color]'" % [_value_color, _colored_string_div(str(value))]
TYPE_INT:
return "[color=%s]%d[/color]" % [_value_color, value]
TYPE_FLOAT:
return "[color=%s]%s[/color]" % [_value_color, _typed_value(value)]
TYPE_COLOR:
return "'[color=%s]%s[/color]'" % [_value_color, _typed_value(value)]
TYPE_OBJECT:
if value == null:
return "'[color=%s]<null>[/color]'" % [_value_color]
if value is InputEvent:
var ie: InputEvent = value
return "[color=%s]<%s>[/color]" % [_value_color, input_event_as_text(ie)]
var obj_value: Object = value
if obj_value.has_method("_to_string"):
return "[color=%s]<%s>[/color]" % [_value_color, str(value)]
return "[color=%s]<%s>[/color]" % [_value_color, obj_value.get_class()]
TYPE_DICTIONARY:
return "'[color=%s]%s[/color]'" % [_value_color, format_dict(value)]
_:
if GdArrayTools.is_array_type(value):
return "'[color=%s]%s[/color]'" % [_value_color, _typed_value(value)]
return "'[color=%s]%s[/color]'" % [_value_color, value]
static func _index_report_as_table(index_reports :Array) -> String:
var table := "[table=3]$cells[/table]"
var header := "[cell][right][b]$text[/b][/right]\t[/cell]"
var cell := "[cell][right]$text[/right]\t[/cell]"
var cells := header.replace("$text", "Index") + header.replace("$text", "Current") + header.replace("$text", "Expected")
for report :Variant in index_reports:
var index :String = str(report["index"])
var current :String = str(report["current"])
var expected :String = str(report["expected"])
cells += cell.replace("$text", index) + cell.replace("$text", current) + cell.replace("$text", expected)
return table.replace("$cells", cells)
static func orphan_warning(orphans_count: int, info: String) -> String:
if EngineDebugger.is_active():
return """
%s Detected %s possible orphan nodes.
To capture detailed information insert
[code]%s[/code]
""".dedent().trim_prefix("\n").trim_suffix("\n") % [
_warning("WARNING:"),
_colored_value(orphans_count),
_colored(info, Color.STEEL_BLUE)
]
return """
%s Detected %s possible orphan nodes.
%s
""".dedent().trim_prefix("\n").trim_suffix("\n") % [
_warning("WARNING:"),
_colored_value(orphans_count),
_warning(NO_ORPHAN_DETAILS)
]
static func orphan_detected(orphan_count: int) -> String:
if EngineDebugger.is_active():
return "%s Detected %s orphan nodes." % [
_warning("WARNING:"),
_colored_value(orphan_count)
]
return """
%s Detected %s orphan nodes.
%s""".dedent().trim_prefix("\n") % [
_warning("WARNING:"),
_colored_value(orphan_count),
_warning(NO_ORPHAN_DETAILS)
]
static func orphan_node_info(orphan_info: GdUnitOrphanNodeInfo) -> String:
var message := "<%s> Id:%s" % [
_colored(orphan_info._type, GdUnitEditorColorTheme.engine_type_color),
_colored(orphan_info._id, GdUnitEditorColorTheme.engine_type_color)
]
if orphan_info._stack_element == null:
message += _warning("\n\tNo source info available")
return message
static func fuzzer_interuped(iterations: int, error: String) -> String:
return "%s %s %s\n %s" % [
_error("Found an error after"),
_colored_value(iterations + 1),
_error("test iterations"),
error]
static func test_timeout(timeout :int) -> String:
return "%s\n %s" % [_error("Timeout !"), _colored_value("Test timed out after %s" % LocalTime.elapsed(timeout))]
static func test_session_terminated() -> String:
return "%s" % _error("Test Session Terminated")
# gdlint:disable = mixed-tabs-and-spaces
static func test_suite_skipped(hint :String, skip_count :int) -> String:
return """
%s
Skipped %s tests
Reason: %s
""".dedent().trim_prefix("\n")\
% [_error("The Entire test-suite is skipped!"), _colored_value(skip_count), _colored_value(hint)]
static func test_skipped(hint :String) -> String:
return """
%s
Reason: %s
""".dedent().trim_prefix("\n")\
% [_error("This test is skipped!"), _colored_value(hint)]
static func error_not_implemented() -> String:
return _error("Test not implemented!")
static func error_with_message(message: String) -> String:
return _error(message)
static func error_is_null(current :Variant) -> String:
return "%s %s but was %s" % [_error("Expecting:"), _colored_value(null), _colored_value(current)]
static func error_is_not_null() -> String:
return "%s %s" % [_error("Expecting: not to be"), _colored_value(null)]
static func error_equal(current :Variant, expected :Variant, index_reports :Array = []) -> String:
var report := """
%s
%s
but was
%s""".dedent().trim_prefix("\n") % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
if not index_reports.is_empty():
report += "\n\n%s\n%s" % [_error("Differences found:"), _index_report_as_table(index_reports)]
return report
static func error_not_equal(current :Variant, expected :Variant) -> String:
return "%s\n %s\n not equal to\n %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
static func error_not_equal_case_insensetiv(current :Variant, expected :Variant) -> String:
return "%s\n %s\n not equal to (case insensitiv)\n %s" % [
_error("Expecting:"), _colored_value(expected), _colored_value(current)]
static func error_is_empty(current :Variant) -> String:
return "%s\n must be empty but was\n %s" % [_error("Expecting:"), _colored_value(current)]
static func error_is_not_empty() -> String:
return "%s\n must not be empty" % [_error("Expecting:")]
static func error_is_same(current :Variant, expected :Variant) -> String:
return "%s\n %s\n to refer to the same object\n %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
@warning_ignore("unused_parameter")
static func error_not_same(_current :Variant, expected :Variant) -> String:
return "%s\n %s" % [_error("Expecting not same:"), _colored_value(expected)]
static func error_not_same_error(current :Variant, expected :Variant) -> String:
return "%s\n %s\n but was\n %s" % [_error("Expecting error message:"), _colored_value(expected), _colored_value(current)]
static func error_is_instanceof(current: GdUnitResult, expected :GdUnitResult) -> String:
return "%s\n %s\n But it was %s" % [_error("Expected instance of:"),\
_colored_value(expected.or_else(null)), _colored_value(current.or_else(null))]
# -- Boolean Assert specific messages -----------------------------------------------------
static func error_is_true(current :Variant) -> String:
return "%s %s but is %s" % [_error("Expecting:"), _colored_value(true), _colored_value(current)]
static func error_is_false(current :Variant) -> String:
return "%s %s but is %s" % [_error("Expecting:"), _colored_value(false), _colored_value(current)]
# - Integer/Float Assert specific messages -----------------------------------------------------
static func error_is_even(current :Variant) -> String:
return "%s\n %s must be even" % [_error("Expecting:"), _colored_value(current)]
static func error_is_odd(current :Variant) -> String:
return "%s\n %s must be odd" % [_error("Expecting:"), _colored_value(current)]
static func error_is_negative(current :Variant) -> String:
return "%s\n %s be negative" % [_error("Expecting:"), _colored_value(current)]
static func error_is_not_negative(current :Variant) -> String:
return "%s\n %s be not negative" % [_error("Expecting:"), _colored_value(current)]
static func error_is_zero(current :Variant) -> String:
return "%s\n equal to 0 but is %s" % [_error("Expecting:"), _colored_value(current)]
static func error_is_not_zero() -> String:
return "%s\n not equal to 0" % [_error("Expecting:")]
static func error_is_wrong_type(current_type :Variant.Type, expected_type :Variant.Type) -> String:
return "%s\n Expecting type %s but is %s" % [
_error("Unexpected type comparison:"),
_colored_value(GdObjects.type_as_string(current_type)),
_colored_value(GdObjects.type_as_string(expected_type))]
static func error_is_value(operation :int, current :Variant, expected :Variant, expected2 :Variant = null) -> String:
match operation:
Comparator.EQUAL:
return "%s\n %s but was %s" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
Comparator.LESS_THAN:
return "%s\n %s but was %s" % [_error("Expecting to be less than:"), _colored_value(expected), _colored_value(current)]
Comparator.LESS_EQUAL:
return "%s\n %s but was %s" % [_error("Expecting to be less than or equal:"), _colored_value(expected), _colored_value(current)]
Comparator.GREATER_THAN:
return "%s\n %s but was %s" % [_error("Expecting to be greater than:"), _colored_value(expected), _colored_value(current)]
Comparator.GREATER_EQUAL:
return "%s\n %s but was %s" % [_error("Expecting to be greater than or equal:"), _colored_value(expected), _colored_value(current)]
Comparator.BETWEEN_EQUAL:
return "%s\n %s\n in range between\n %s <> %s" % [
_error("Expecting:"), _colored_value(current), _colored_value(expected), _colored_value(expected2)]
Comparator.NOT_BETWEEN_EQUAL:
return "%s\n %s\n not in range between\n %s <> %s" % [
_error("Expecting:"), _colored_value(current), _colored_value(expected), _colored_value(expected2)]
return "TODO create expected message"
static func error_is_in(current :Variant, expected :Array) -> String:
return "%s\n %s\n is in\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(str(expected))]
static func error_is_not_in(current :Variant, expected :Array) -> String:
return "%s\n %s\n is not in\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(str(expected))]
# - StringAssert ---------------------------------------------------------------------------------
static func error_equal_ignoring_case(current :Variant, expected :Variant) -> String:
return "%s\n %s\n but was\n %s (ignoring case)" % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
static func error_contains(current :Variant, expected :Variant) -> String:
return "%s\n %s\n do contains\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
static func error_not_contains(current :Variant, expected :Variant) -> String:
return "%s\n %s\n not do contain\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
static func error_contains_ignoring_case(current :Variant, expected :Variant) -> String:
return "%s\n %s\n contains\n %s\n (ignoring case)" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
static func error_not_contains_ignoring_case(current :Variant, expected :Variant) -> String:
return "%s\n %s\n not do contains\n %s\n (ignoring case)" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
static func error_starts_with(current :Variant, expected :Variant) -> String:
return "%s\n %s\n to start with\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
static func error_ends_with(current :Variant, expected :Variant) -> String:
return "%s\n %s\n to end with\n %s" % [_error("Expecting:"), _colored_value(current), _colored_value(expected)]
static func error_has_length(current :Variant, expected: int, compare_operator :int) -> String:
@warning_ignore("unsafe_method_access")
var current_length :Variant = current.length() if current != null else null
match compare_operator:
Comparator.EQUAL:
return "%s\n %s but was %s in\n %s" % [
_error("Expecting size:"), _colored_value(expected), _nerror(current_length), _colored_value(current)]
Comparator.LESS_THAN:
return "%s\n %s but was %s in\n %s" % [
_error("Expecting size to be less than:"), _colored_value(expected), _nerror(current_length), _colored_value(current)]
Comparator.LESS_EQUAL:
return "%s\n %s but was %s in\n %s" % [
_error("Expecting size to be less than or equal:"), _colored_value(expected),
_nerror(current_length), _colored_value(current)]
Comparator.GREATER_THAN:
return "%s\n %s but was %s in\n %s" % [
_error("Expecting size to be greater than:"), _colored_value(expected),
_nerror(current_length), _colored_value(current)]
Comparator.GREATER_EQUAL:
return "%s\n %s but was %s in\n %s" % [
_error("Expecting size to be greater than or equal:"), _colored_value(expected),
_nerror(current_length), _colored_value(current)]
return "TODO create expected message"
# - ArrayAssert specific messgaes ---------------------------------------------------
static func error_arr_contains(current: Variant, expected: Variant, not_expect: Variant, not_found: Variant, by_reference: bool) -> String:
var failure_message := "Expecting contains SAME elements:" if by_reference else "Expecting contains elements:"
var error := "%s\n %s\n do contains (in any order)\n %s" % [
_error(failure_message), _colored_value(current), _colored_value(expected)]
if not is_empty(not_expect):
error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect)
if not is_empty(not_found):
var prefix := "but" if is_empty(not_expect) else "and"
error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)]
return error
static func error_arr_contains_exactly(
current: Variant,
expected: Variant,
not_expect: Variant,
not_found: Variant, compare_mode: GdObjects.COMPARE_MODE) -> String:
var failure_message := (
"Expecting contains exactly elements:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
else "Expecting contains SAME exactly elements:"
)
if is_empty(not_expect) and is_empty(not_found):
var arr_current: Array = current
var arr_expected: Array = expected
var diff := _find_first_diff(arr_current, arr_expected)
return "%s\n %s\n do contains (in same order)\n %s\n but has different order %s" % [
_error(failure_message), _colored_value(current), _colored_value(expected), diff]
var error := "%s\n %s\n do contains (in same order)\n %s" % [
_error(failure_message), _colored_value(current), _colored_value(expected)]
if not is_empty(not_expect):
error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect)
if not is_empty(not_found):
var prefix := "but" if is_empty(not_expect) else "and"
error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)]
return error
static func error_arr_contains_exactly_in_any_order(
current: Variant,
expected: Variant,
not_expect: Variant,
not_found: Variant,
compare_mode: GdObjects.COMPARE_MODE) -> String:
var failure_message := (
"Expecting contains exactly elements:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
else "Expecting contains SAME exactly elements:"
)
var error := "%s\n %s\n do contains exactly (in any order)\n %s" % [
_error(failure_message), _colored_value(current), _colored_value(expected)]
if not is_empty(not_expect):
error += "\nbut some elements where not expected:\n %s" % _colored_value(not_expect)
if not is_empty(not_found):
var prefix := "but" if is_empty(not_expect) else "and"
error += "\n%s could not find elements:\n %s" % [prefix, _colored_value(not_found)]
return error
static func error_arr_not_contains(current: Variant, expected: Variant, found: Variant, compare_mode: GdObjects.COMPARE_MODE) -> String:
var failure_message := "Expecting:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST else "Expecting SAME:"
var error := "%s\n %s\n do not contains\n %s" % [
_error(failure_message), _colored_value(current), _colored_value(expected)]
if not is_empty(found):
error += "\n but found elements:\n %s" % _colored_value(found)
return error
# - DictionaryAssert specific messages ----------------------------------------------
static func error_contains_keys(current :Array, expected :Array, keys_not_found :Array, compare_mode :GdObjects.COMPARE_MODE) -> String:
var failure := (
"Expecting contains keys:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
else "Expecting contains SAME keys:"
)
return "%s\n %s\n to contains:\n %s\n but can't find key's:\n %s" % [
_error(failure), _colored_value(current), _colored_value(expected), _colored_value(keys_not_found)]
static func error_not_contains_keys(current :Array, expected :Array, keys_not_found :Array, compare_mode :GdObjects.COMPARE_MODE) -> String:
var failure := (
"Expecting NOT contains keys:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
else "Expecting NOT contains SAME keys"
)
return "%s\n %s\n do not contains:\n %s\n but contains key's:\n %s" % [
_error(failure), _colored_value(current), _colored_value(expected), _colored_value(keys_not_found)]
static func error_contains_key_value(key :Variant, value :Variant, current_value :Variant, compare_mode :GdObjects.COMPARE_MODE) -> String:
var failure := (
"Expecting contains key and value:" if compare_mode == GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST
else "Expecting contains SAME key and value:"
)
return "%s\n %s : %s\n but contains\n %s : %s" % [
_error(failure), _colored_value(key), _colored_value(value), _colored_value(key), _colored_value(current_value)]
# - ResultAssert specific errors ----------------------------------------------------
static func error_result_is_empty(current :GdUnitResult) -> String:
return _result_error_message(current, GdUnitResult.EMPTY)
static func error_result_is_success(current :GdUnitResult) -> String:
return _result_error_message(current, GdUnitResult.SUCCESS)
static func error_result_is_warning(current :GdUnitResult) -> String:
return _result_error_message(current, GdUnitResult.WARN)
static func error_result_is_error(current :GdUnitResult) -> String:
return _result_error_message(current, GdUnitResult.ERROR)
static func error_result_has_message(current :String, expected :String) -> String:
return "%s\n %s\n but was\n %s." % [_error("Expecting:"), _colored_value(expected), _colored_value(current)]
static func error_result_has_message_on_success(expected :String) -> String:
return "%s\n %s\n but the GdUnitResult is a success." % [_error("Expecting:"), _colored_value(expected)]
static func error_result_is_value(current :Variant, expected :Variant) -> String:
return "%s\n %s\n but was\n %s." % [_error("Expecting to contain same value:"), _colored_value(expected), _colored_value(current)]
static func _result_error_message(current :GdUnitResult, expected_type :int) -> String:
if current == null:
return _error("Expecting the result must be a %s but was <null>." % result_type(expected_type))
if current.is_success():
return _error("Expecting the result must be a %s but was SUCCESS." % result_type(expected_type))
var error := "Expecting the result must be a %s but was %s:" % [result_type(expected_type), result_type(current._state)]
return "%s\n %s" % [_error(error), _colored_value(result_message(current))]
static func error_interrupted(func_name :String, expected :Variant, elapsed :String) -> String:
func_name = humanized(func_name)
if expected == null:
return "%s %s but timed out after %s" % [_error("Expected:"), func_name, elapsed]
return "%s %s %s but timed out after %s" % [_error("Expected:"), func_name, _colored_value(expected), elapsed]
static func error_wait_signal(signal_name :String, args :Array, elapsed :String) -> String:
if args.is_empty():
return "%s %s but timed out after %s" % [
_error("Expecting emit signal:"), _colored_value(signal_name + "()"), elapsed]
return "%s %s but timed out after %s" % [
_error("Expecting emit signal:"), _colored_value(signal_name + "(" + str(args) + ")"), elapsed]
static func error_signal_emitted(signal_name :String, args :Array, elapsed :String) -> String:
if args.is_empty():
return "%s %s but is emitted after %s" % [
_error("Expecting do not emit signal:"), _colored_value(signal_name + "()"), elapsed]
return "%s %s but is emitted after %s" % [
_error("Expecting do not emit signal:"), _colored_value(signal_name + "(" + str(args) + ")"), elapsed]
static func error_await_signal_on_invalid_instance(source :Variant, signal_name :String, args :Array) -> String:
return "%s\n await_signal_on(%s, %s, %s)" % [
_error("Invalid source! Can't await on signal:"), _colored_value(source), signal_name, args]
static func result_type(type :int) -> String:
match type:
GdUnitResult.SUCCESS: return "SUCCESS"
GdUnitResult.WARN: return "WARNING"
GdUnitResult.ERROR: return "ERROR"
GdUnitResult.EMPTY: return "EMPTY"
return "UNKNOWN"
static func result_message(result :GdUnitResult) -> String:
match result._state:
GdUnitResult.SUCCESS: return ""
GdUnitResult.WARN: return result.warn_message()
GdUnitResult.ERROR: return result.error_message()
GdUnitResult.EMPTY: return ""
return "UNKNOWN"
# -----------------------------------------------------------------------------------
# - Spy|Mock specific errors ----------------------------------------------------
static func error_no_more_interactions(summary :Dictionary) -> String:
var interactions := PackedStringArray()
for args :Array in summary.keys():
var times :int = summary[args]
@warning_ignore("return_value_discarded")
interactions.append(_format_arguments(args, times))
return "%s\n%s\n%s" % [_error("Expecting no more interactions!"), _error("But found interactions on:"), "\n".join(interactions)]
static func error_validate_interactions(current_interactions: Dictionary, expected_interactions: Dictionary) -> String:
var collected_interactions := PackedStringArray()
for args: Array in current_interactions.keys():
var times: int = current_interactions[args]
@warning_ignore("return_value_discarded")
collected_interactions.append(_format_arguments(args, times))
var arguments: Array = expected_interactions.keys()[0]
var interactions: int = expected_interactions.values()[0]
var expected_interaction := _format_arguments(arguments, interactions)
return "%s\n%s\n%s\n%s" % [
_error("Expecting interaction on:"), expected_interaction, _error("But found interactions on:"), "\n".join(collected_interactions)]
static func _format_arguments(args :Array, times :int) -> String:
var fname :String = args[0]
var fargs := args.slice(1) as Array
var typed_args := _to_typed_args(fargs)
var fsignature := _colored_value("%s(%s)" % [fname, ", ".join(typed_args)])
return " %s %d time's" % [fsignature, times]
static func _to_typed_args(args :Array) -> PackedStringArray:
var typed := PackedStringArray()
for arg :Variant in args:
@warning_ignore("return_value_discarded")
typed.append(_format_arg(arg) + " :" + GdObjects.type_as_string(typeof(arg)))
return typed
static func _format_arg(arg :Variant) -> String:
if arg is InputEvent:
var ie: InputEvent = arg
return input_event_as_text(ie)
return str(arg)
static func _find_first_diff(left :Array, right :Array) -> String:
for index in left.size():
var l :Variant = left[index]
var r :Variant = "<no entry>" if index >= right.size() else right[index]
if not GdObjects.equals(l, r):
return "at position %s\n '%s' vs '%s'" % [_colored_value(index), _typed_value(l), _typed_value(r)]
return ""
static func error_has_size(current :Variant, expected: int) -> String:
@warning_ignore("unsafe_method_access")
var current_size :Variant = null if current == null else current.size()
return "%s\n %s\n but was\n %s" % [_error("Expecting size:"), _colored_value(expected), _colored_value(current_size)]
static func error_contains_exactly(current: Array, expected: Array) -> String:
return "%s\n %s\n but was\n %s" % [_error("Expecting exactly equal:"), _colored_value(expected), _colored_value(current)]
static func format_chars(characters: PackedInt32Array, type: Color) -> PackedInt32Array:
if characters.size() == 0:# or characters[0] == 10:
return characters
# Replace each control character with its readable form
var formatted_text := characters.to_byte_array().get_string_from_utf32()
for control_char: String in CONTROL_CHARS:
var replace_text: String = CONTROL_CHARS[control_char]
formatted_text = formatted_text.replace(control_char, replace_text)
# Handle special ASCII control characters (0x00-0x1F, 0x7F)
var ascii_text := ""
for i in formatted_text.length():
var character := formatted_text[i]
var code := character.unicode_at(0)
if code < 0x20 and not CONTROL_CHARS.has(character): # Control characters not handled above
ascii_text += "<0x%02X>" % code
elif code == 0x7F: # DEL character
ascii_text += "<DEL>"
else:
ascii_text += character
var message := "[bgcolor=#%s][color=white]%s[/color][/bgcolor]" % [
type.to_html(),
ascii_text
]
var result := PackedInt32Array()
result.append_array(message.to_utf32_buffer().to_int32_array())
return result
static func format_invalid(value :String) -> String:
return "[bgcolor=#%s][color=with]%s[/color][/bgcolor]" % [SUB_COLOR.to_html(), value]
static func humanized(value :String) -> String:
return value.replace("_", " ")
static func build_failure_message(failure :String, additional_failure_message: String, custom_failure_message: String) -> String:
var message := failure if custom_failure_message.is_empty() else custom_failure_message
if additional_failure_message.is_empty():
return message
return """
%s
[color=LIME_GREEN][b]Additional info:[/b][/color]
%s""".dedent().trim_prefix("\n") % [message, additional_failure_message]
static func is_empty(value: Variant) -> bool:
var arry_value: Array = value
return arry_value != null and arry_value.is_empty()
@@ -0,0 +1 @@
uid://cdmds6thunddc
@@ -0,0 +1,57 @@
class_name GdAssertReports
extends RefCounted
const LAST_ERROR = "last_assert_error_message"
const LAST_ERROR_LINE = "last_assert_error_line"
static func report_success() -> void:
GdUnitSignals.instance().gdunit_set_test_failed.emit(false)
GdAssertReports.set_last_error_line_number(-1)
Engine.remove_meta(LAST_ERROR)
static func report_warning(message :String, line_number :int) -> void:
GdUnitSignals.instance().gdunit_set_test_failed.emit(false)
send_report(GdUnitReport.new().create(GdUnitReport.WARN, line_number, message))
static func report_error(error: GdUnitError) -> void:
GdUnitSignals.instance().gdunit_set_test_failed.emit(true)
GdAssertReports.set_last_error_line_number(error._line_number)
Engine.set_meta(LAST_ERROR, error._message)
Engine.set_meta("GD_TEST_FAILURE", true)
GdUnitThreadManager.get_current_context().get_execution_context().set_error(error)
# if we expect to fail we handle as success test
if _do_expect_assert_failing():
return
send_report(GdUnitReport.new().from_error(GdUnitReport.FAILURE, error))
static func reset_last_error_line_number() -> void:
Engine.remove_meta(LAST_ERROR_LINE)
static func set_last_error_line_number(line_number :int) -> void:
Engine.set_meta(LAST_ERROR_LINE, line_number)
static func get_last_error_line_number() -> int:
if Engine.has_meta(LAST_ERROR_LINE):
return Engine.get_meta(LAST_ERROR_LINE)
return -1
static func _do_expect_assert_failing() -> bool:
if Engine.has_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES):
return Engine.get_meta(GdUnitConstants.EXPECT_ASSERT_REPORT_FAILURES)
return false
static func current_failure() -> String:
return Engine.get_meta(LAST_ERROR)
static func send_report(report :GdUnitReport) -> void:
GdUnitThreadManager.get_current_context().get_execution_context().add_report(report)
@@ -0,0 +1 @@
uid://d2yux5f864g6e
@@ -0,0 +1,420 @@
class_name GdUnitArrayAssertImpl
extends GdUnitArrayAssert
var _base: GdUnitAssertImpl
var _current_value_provider: ValueProvider
var _type_check: bool
func _init(current: Variant, type_check := true) -> void:
_type_check = type_check
_current_value_provider = DefaultValueProvider.new(current)
_base = GdUnitAssertImpl.new(current)
# save the actual assert instance on the current thread context
GdUnitThreadManager.get_current_context().set_assert(self)
if not _validate_value_type(current):
@warning_ignore("return_value_discarded")
report_error("GdUnitArrayAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
func _notification(event: int) -> void:
if event == NOTIFICATION_PREDELETE:
if _base != null:
_base.notification(event)
_base = null
func report_success() -> GdUnitArrayAssert:
@warning_ignore("return_value_discarded")
_base.report_success()
return self
func report_error(error: String) -> GdUnitArrayAssert:
@warning_ignore("return_value_discarded")
_base.report_error(error)
return self
func override_failure_message(message: String) -> GdUnitArrayAssert:
@warning_ignore("return_value_discarded")
_base.override_failure_message(message)
return self
func append_failure_message(message: String) -> GdUnitArrayAssert:
@warning_ignore("return_value_discarded")
_base.append_failure_message(message)
return self
func _validate_value_type(value: Variant) -> bool:
return value == null or GdArrayTools.is_array_type(value)
func get_current_value() -> Variant:
return _current_value_provider.get_value()
func max_length(left: Variant, right: Variant) -> int:
var ls := str(left).length()
var rs := str(right).length()
return rs if ls < rs else ls
# gdlint: disable=function-name
func _toPackedStringArray(value: Variant) -> PackedStringArray:
if GdArrayTools.is_array_type(value):
@warning_ignore("unsafe_cast")
return PackedStringArray(value as Array)
return PackedStringArray([str(value)])
func _array_equals_div(current: Variant, expected: Variant, case_sensitive: bool = true) -> Array:
var current_value := _toPackedStringArray(current)
var expected_value := _toPackedStringArray(expected)
var index_report := Array()
for index in current_value.size():
var c := current_value[index]
if index < expected_value.size():
var e := expected_value[index]
if not GdObjects.equals(c, e, case_sensitive):
var length := max_length(c, e)
current_value[index] = GdAssertMessages.format_invalid(c.lpad(length))
expected_value[index] = e.lpad(length)
index_report.push_back({"index": index, "current": c, "expected": e})
else:
current_value[index] = GdAssertMessages.format_invalid(c)
index_report.push_back({"index": index, "current": c, "expected": "<N/A>"})
for index in range(current_value.size(), expected_value.size()):
var value := expected_value[index]
expected_value[index] = GdAssertMessages.format_invalid(value)
index_report.push_back({"index": index, "current": "<N/A>", "expected": value})
return [current_value, expected_value, index_report]
func _array_div(compare_mode: GdObjects.COMPARE_MODE, left: Array[Variant], right: Array[Variant], _same_order := false) -> Array[Variant]:
var not_expect := left.duplicate(true)
var not_found := right.duplicate(true)
for index_c in left.size():
var c: Variant = left[index_c]
for index_e in right.size():
var e: Variant = right[index_e]
if GdObjects.equals(c, e, true, compare_mode):
GdArrayTools.erase_value(not_expect, e)
GdArrayTools.erase_value(not_found, c)
break
return [not_expect, not_found]
func _contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
var by_reference := compare_mode == GdObjects.COMPARE_MODE.OBJECT_REFERENCE
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if current_value == null:
return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], expected_value, by_reference))
@warning_ignore("unsafe_cast")
var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant])
#var not_expect := diffs[0] as Array
var not_found: Array = diffs[1]
if not not_found.is_empty():
return report_error(GdAssertMessages.error_arr_contains(current_value, expected_value, [], not_found, by_reference))
return report_success()
func _contains_exactly(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if current_value == null:
return report_error(GdAssertMessages.error_arr_contains_exactly(null, expected_value, [], expected_value, compare_mode))
# has same content in same order
if _is_equal(current_value, expected_value, true, compare_mode):
return report_success()
# check has same elements but in different order
if _is_equals_sorted(current_value, expected_value, true, compare_mode):
return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, [], [], compare_mode))
# find the difference
@warning_ignore("unsafe_cast")
var diffs := _array_div(compare_mode,
current_value as Array[Variant],
expected_value as Array[Variant],
GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
var not_expect: Array[Variant] = diffs[0]
var not_found: Array[Variant] = diffs[1]
return report_error(GdAssertMessages.error_arr_contains_exactly(current_value, expected_value, not_expect, not_found, compare_mode))
func _contains_exactly_in_any_order(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if current_value == null:
return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [],
expected_value, compare_mode))
# find the difference
@warning_ignore("unsafe_cast")
var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant], false)
var not_expect: Array[Variant] = diffs[0]
var not_found: Array[Variant] = diffs[1]
if not_expect.is_empty() and not_found.is_empty():
return report_success()
return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, not_expect,
not_found, compare_mode))
func _not_contains(expected: Array, compare_mode: GdObjects.COMPARE_MODE) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if current_value == null:
return report_error(GdAssertMessages.error_arr_contains_exactly_in_any_order(current_value, expected_value, [],
expected_value, compare_mode))
@warning_ignore("unsafe_cast")
var diffs := _array_div(compare_mode, current_value as Array[Variant], expected_value as Array[Variant])
var found: Array[Variant] = diffs[0]
@warning_ignore("unsafe_cast")
if found.size() == (current_value as Array).size():
return report_success()
@warning_ignore("unsafe_cast")
var diffs2 := _array_div(compare_mode, expected_value as Array[Variant], diffs[1] as Array[Variant])
return report_error(GdAssertMessages.error_arr_not_contains(current_value, expected_value, diffs2[0], compare_mode))
func is_null() -> GdUnitArrayAssert:
@warning_ignore("return_value_discarded")
_base.is_null()
return self
func is_not_null() -> GdUnitArrayAssert:
@warning_ignore("return_value_discarded")
_base.is_not_null()
return self
func is_equal(...expected: Array) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant= _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if current_value == null and expected_value != null:
return report_error(GdAssertMessages.error_equal(null, expected_value))
if not _is_equal(current_value, expected_value):
var diff := _array_equals_div(current_value, expected_value)
var expected_as_list := GdArrayTools.as_string(diff[0], false)
var current_as_list := GdArrayTools.as_string(diff[1], false)
var index_report: Array = diff[2]
return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report))
return report_success()
# Verifies that the current Array is equal to the given one, ignoring case considerations.
func is_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if current_value == null and expected_value != null:
@warning_ignore("unsafe_cast")
return report_error(GdAssertMessages.error_equal(null, GdArrayTools.as_string(expected_value)))
if not _is_equal(current_value, expected_value, false):
@warning_ignore("unsafe_cast")
var diff := _array_equals_div(current_value, expected_value, true)
var expected_as_list := GdArrayTools.as_string(diff[0])
var current_as_list := GdArrayTools.as_string(diff[1])
var index_report: Array = diff[2]
return report_error(GdAssertMessages.error_equal(expected_as_list, current_as_list, index_report))
return report_success()
func is_not_equal(...expected: Array) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if _is_equal(current_value, expected_value):
return report_error(GdAssertMessages.error_not_equal(current_value, expected_value))
return report_success()
func is_not_equal_ignoring_case(...expected: Array) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
var expected_value: Variant = _extract_variadic_value(expected)
if not _validate_value_type(expected_value):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected_value))
if _is_equal(current_value, expected_value, false):
@warning_ignore("unsafe_cast")
var c := GdArrayTools.as_string(current_value as Array)
@warning_ignore("unsafe_cast")
var e := GdArrayTools.as_string(expected_value)
return report_error(GdAssertMessages.error_not_equal_case_insensetiv(c, e))
return report_success()
func is_empty() -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
@warning_ignore("unsafe_cast")
if current_value == null or (current_value as Array).size() > 0:
return report_error(GdAssertMessages.error_is_empty(current_value))
return report_success()
func is_not_empty() -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
@warning_ignore("unsafe_cast")
if current_value != null and (current_value as Array).size() == 0:
return report_error(GdAssertMessages.error_is_not_empty())
return report_success()
@warning_ignore("unused_parameter", "shadowed_global_identifier")
func is_same(expected: Variant) -> GdUnitArrayAssert:
if not _validate_value_type(expected):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected))
var current: Variant = get_current_value()
if not is_same(current, expected):
@warning_ignore("return_value_discarded")
report_error(GdAssertMessages.error_is_same(current, expected))
return self
func is_not_same(expected: Variant) -> GdUnitArrayAssert:
if not _validate_value_type(expected):
return report_error("ERROR: expected value: <%s>\n is not a Array Type!" % GdObjects.typeof_as_string(expected))
var current: Variant = get_current_value()
if is_same(current, expected):
@warning_ignore("return_value_discarded")
report_error(GdAssertMessages.error_not_same(current, expected))
return self
func has_size(expected: int) -> GdUnitArrayAssert:
var current_value: Variant = get_current_value()
@warning_ignore("unsafe_cast")
if current_value == null or (current_value as Array).size() != expected:
return report_error(GdAssertMessages.error_has_size(current_value, expected))
return report_success()
func contains(...expected: Array) -> GdUnitArrayAssert:
return _contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
func contains_exactly(...expected: Array) -> GdUnitArrayAssert:
return _contains_exactly(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
func contains_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert:
return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
func contains_same(...expected: Array) -> GdUnitArrayAssert:
return _contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
func contains_same_exactly(...expected: Array) -> GdUnitArrayAssert:
return _contains_exactly(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
func contains_same_exactly_in_any_order(...expected: Array) -> GdUnitArrayAssert:
return _contains_exactly_in_any_order(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
func not_contains(...expected: Array) -> GdUnitArrayAssert:
return _not_contains(expected, GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST)
func not_contains_same(...expected: Array) -> GdUnitArrayAssert:
return _not_contains(expected, GdObjects.COMPARE_MODE.OBJECT_REFERENCE)
func is_instanceof(expected: Variant) -> GdUnitAssert:
@warning_ignore("unsafe_method_access")
_base.is_instanceof(expected)
return self
func extract(func_name: String, ...func_args: Array) -> GdUnitArrayAssert:
var extracted_elements := Array()
var args: Array = _extract_variadic_value(func_args)
var extractor := GdUnitFuncValueExtractor.new(func_name, args)
var current: Variant = get_current_value()
if current == null:
_current_value_provider = DefaultValueProvider.new(null)
else:
for element: Variant in current:
extracted_elements.append(extractor.extract_value(element))
_current_value_provider = DefaultValueProvider.new(extracted_elements)
return self
func extractv(...extractors: Array) -> GdUnitArrayAssert:
var extracted_elements := Array()
var current: Variant = get_current_value()
if current == null:
_current_value_provider = DefaultValueProvider.new(null)
else:
for element: Variant in current:
var ev: Array[Variant] = []
for index: int in extractors.size():
var extractor: GdUnitValueExtractor = extractors[index]
ev.append(extractor.extract_value(element))
if extractors.size() > 1:
extracted_elements.append(GdUnitTuple.new.callv(ev))
else:
extracted_elements.append(ev[0])
_current_value_provider = DefaultValueProvider.new(extracted_elements)
return self
## Small helper to support the old expected arguments as single array and variadic arguments
func _extract_variadic_value(values: Variant) -> Variant:
@warning_ignore("unsafe_method_access")
if values != null and values.size() == 1 and GdArrayTools.is_array_type(values[0]):
return values[0]
return values
@warning_ignore("incompatible_ternary")
func _is_equal(
left: Variant,
right: Variant,
case_sensitive := true,
compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
@warning_ignore("unsafe_cast")
return GdObjects.equals(
(left as Array) if GdArrayTools.is_array_type(left) else left,
(right as Array) if GdArrayTools.is_array_type(right) else right,
case_sensitive,
compare_mode
)
func _is_equals_sorted(
left: Variant,
right: Variant,
case_sensitive := true,
compare_mode := GdObjects.COMPARE_MODE.PARAMETER_DEEP_TEST) -> bool:
@warning_ignore("unsafe_cast")
return GdObjects.equals_sorted(
left as Array,
right as Array,
case_sensitive,
compare_mode)
@@ -0,0 +1 @@
uid://0kg74xgfwp6k
@@ -0,0 +1,78 @@
class_name GdUnitAssertImpl
extends GdUnitAssert
var _current :Variant
var _custom_failure_message :String = ""
var _additional_failure_message: String = ""
func _init(current :Variant) -> void:
_current = current
# save the actual assert instance on the current thread context
GdUnitThreadManager.get_current_context().set_assert(self)
GdAssertReports.reset_last_error_line_number()
func current_value() -> Variant:
return _current
func report_success() -> GdUnitAssert:
GdAssertReports.report_success()
return self
func report_error(failure :String, failure_line_number: int = -1) -> GdUnitAssert:
var stack_trace := GdUnitStackTrace.new()
var line_number := failure_line_number if failure_line_number != -1 else stack_trace.get_line_number()
GdAssertReports.set_last_error_line_number(line_number)
var failure_message := GdAssertMessages.build_failure_message(failure, _additional_failure_message, _custom_failure_message)
GdAssertReports.report_error(GdUnitError.new(failure_message, line_number, stack_trace))
return self
func do_fail_not_implemented() -> GdUnitAssert:
return report_error(GdAssertMessages.error_not_implemented())
func do_fail_with_message(message: String) -> GdUnitAssert:
return report_error(GdAssertMessages.error_with_message(message))
func override_failure_message(message: String) -> GdUnitAssert:
_custom_failure_message = message
return self
func append_failure_message(message: String) -> GdUnitAssert:
_additional_failure_message = message
return self
func is_null() -> GdUnitAssert:
var current :Variant = current_value()
if current != null:
return report_error(GdAssertMessages.error_is_null(current))
return report_success()
func is_not_null() -> GdUnitAssert:
var current :Variant = current_value()
if current == null:
return report_error(GdAssertMessages.error_is_not_null())
return report_success()
func is_equal(expected: Variant) -> GdUnitAssert:
var current: Variant = current_value()
if not GdObjects.equals(current, expected):
return report_error(GdAssertMessages.error_equal(current, expected))
return report_success()
func is_not_equal(expected: Variant) -> GdUnitAssert:
var current: Variant = current_value()
if GdObjects.equals(current, expected):
return report_error(GdAssertMessages.error_not_equal(current, expected))
return report_success()
@@ -0,0 +1 @@
uid://bdq1ga71d44gr
@@ -0,0 +1,39 @@
# Preloads all GdUnit assertions
class_name GdUnitAssertions
extends RefCounted
@warning_ignore("return_value_discarded")
func _init() -> void:
# preload all gdunit assertions to speedup testsuite loading time
# gdlint:disable=private-method-call
@warning_ignore_start("return_value_discarded")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitBoolAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitStringAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitIntAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFloatAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitVectorAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitArrayAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitDictionaryAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFileAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitObjectAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitResultAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFuncAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitSignalAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitFailureAssertImpl.gd")
GdUnitAssertions.__lazy_load("res://addons/gdUnit4/src/asserts/GdUnitGodotErrorAssertImpl.gd")
@warning_ignore_restore("return_value_discarded")
### We now load all used asserts and tool scripts into the cache according to the principle of "lazy loading"
### in order to noticeably reduce the loading time of the test suite.
# We go this hard way to increase the loading performance to avoid reparsing all the used scripts
# for more detailed info -> https://github.com/godotengine/godot/issues/67400
# gdlint:disable=function-name
static func __lazy_load(script_path :String) -> GDScript:
return ResourceLoader.load(script_path, "GDScript", ResourceLoader.CACHE_MODE_REUSE)
static func validate_value_type(value :Variant, type :Variant.Type) -> bool:
return value == null or typeof(value) == type
@@ -0,0 +1 @@
uid://d0y7ocrqr5ebt
@@ -0,0 +1,83 @@
extends GdUnitBoolAssert
var _base: GdUnitAssertImpl
func _init(current :Variant) -> void:
_base = GdUnitAssertImpl.new(current)
# save the actual assert instance on the current thread context
GdUnitThreadManager.get_current_context().set_assert(self)
if not GdUnitAssertions.validate_value_type(current, TYPE_BOOL):
@warning_ignore("return_value_discarded")
report_error("GdUnitBoolAssert inital error, unexpected type <%s>" % GdObjects.typeof_as_string(current))
func _notification(event :int) -> void:
if event == NOTIFICATION_PREDELETE:
if _base != null:
_base.notification(event)
_base = null
func current_value() -> Variant:
return _base.current_value()
func report_success() -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.report_success()
return self
func report_error(error :String) -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.report_error(error)
return self
func override_failure_message(message: String) -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.override_failure_message(message)
return self
func append_failure_message(message: String) -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.append_failure_message(message)
return self
func is_null() -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.is_null()
return self
func is_not_null() -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.is_not_null()
return self
func is_equal(expected: Variant) -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.is_equal(expected)
return self
func is_not_equal(expected: Variant) -> GdUnitBoolAssert:
@warning_ignore("return_value_discarded")
_base.is_not_equal(expected)
return self
func is_true() -> GdUnitBoolAssert:
if current_value() != true:
return report_error(GdAssertMessages.error_is_true(current_value()))
return report_success()
func is_false() -> GdUnitBoolAssert:
if current_value() == true || current_value() == null:
return report_error(GdAssertMessages.error_is_false(current_value()))
return report_success()
Loaded 100 of 952 files, more files were not shown because too many files have changed in this diff. Show more