diff --git a/docs/CNAME b/docs/CNAME
deleted file mode 100644
index 3d0ec4d52..000000000
--- a/docs/CNAME
+++ /dev/null
@@ -1 +0,0 @@
-funkin.me
\ No newline at end of file
diff --git a/docs/Funkin' Debug Hotkeys.md b/docs/Funkin' Debug Hotkeys.md
new file mode 100644
index 000000000..c560c5422
--- /dev/null
+++ b/docs/Funkin' Debug Hotkeys.md
@@ -0,0 +1,31 @@
+# Funkin' Debug Hotkeys
+
+`F4` (EVERYWHERE) - Leave Current State and move to Main Menu
+`F5` (EVERYWHERE) - Hot Reload Data Files
+
+`Y` (Title Screen) - WOAH
+
+`~` (Main Menu) - Access Debug Menu
+
+`U` (Play) - Open Stage Editor State
+`H` (Play) - Show/Hide HUD
+`1` (Play) - End Song
+`2` (Play) - Add 10% Health
+`3` (Play) - Subtract 5% Health
+`7` (Play) - (NOT WORKING) Open Chart Editor
+`8` (Play) - Open Animation Editor
+`9` (Play) - (Easter Egg) Classic Health Icon
+`PGUP`/`Fn+Up` (Play) - Skip Forward In Time
+`PGDN`/`Fn+Down` (Play) - 🦃 That's right, we're going to go BACK IN TIME
+
+`F` (Freeplay Menu) - Move to Favorites
+`P` (Freeplay Menu) - Switch to Pico (probably doesn't work)
+`T` (Freeplay Menu) - Start typing in search bar
+`Q` (Freeplay Menu) - Back one letter
+`E` (Freeplay Menu) - Forward one letter
+
+`Arrows` (Stage Editor) - Move Prop
+`Ctrl-Z` (Stage Editor) - Undo
+`Y` (Stage Editor) - Leave Stage Editor
+
+`H` (Pause Menu) - Hide the Pause Menu UI (good for screenshots!)
diff --git a/docs/img/skin-funkin-cardbordtoast.jpg b/docs/img/skin-funkin-cardbordtoast.jpg
deleted file mode 100644
index dd0e96e60..000000000
Binary files a/docs/img/skin-funkin-cardbordtoast.jpg and /dev/null differ
diff --git a/docs/index.html b/docs/index.html
deleted file mode 100644
index 11137c674..000000000
--- a/docs/index.html
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
-
-
-
-
- Friday Night Funkin'
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Friday Night Funkin' - Rhythm game extraordinaire
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/docs/readme.md b/docs/readme.md
deleted file mode 100644
index aba261b5f..000000000
--- a/docs/readme.md
+++ /dev/null
@@ -1,7 +0,0 @@
-this is for funkin website stuff
-if you're compiling game, you can ignore this folder completely!
-
-if not, dawg check out [funkin.me](https://funkin.me)
-
-note that i probably won't accept any major pull requests for this
-cuz I wanna make the website as a nice lil project to teach myself HTML and CSS and all that!!
diff --git a/docs/styles.css b/docs/styles.css
deleted file mode 100644
index 5ee1062fb..000000000
--- a/docs/styles.css
+++ /dev/null
@@ -1,97 +0,0 @@
-.coolBox {
- background: #1a1a1aCC;
- margin: 40px 4vw;
- padding: 25px;
- font-size: 120%;
- border-radius: 10px;
-}
-
-#wrapper
-{
- padding: 20px;
- margin: auto;
-}
-
-marquee {
- margin: auto;
- text-align: center;
- display: inline-block;
-}
-
-h1 {
- text-align: center;
-}
-
-body {
- background-image: url('img/skin-funkin-cardbordtoast.jpg');
- background-repeat: no-repeat;
- background-attachment: fixed;
- background-size: cover;
- color:white;
- background-color: #000;
- font-family: Arial, Helvetica, sans-serif;
-
-}
-
-a { color:inherit;
- text-decoration: none;}
-
-
-.hovertext a{
- color:white;
- text-shadow: 0px 0px #00000077;
- transition: color 1s, text-shadow 1s;
-}
-
-.hovertext a:hover
-{
- text-shadow: 2px 2px #00000077;
- color: #ffb50e;
-}
-
-.linktext {
- text-align: center;
- display:block;
- font-size: 130%;
- padding-bottom: 10px;
-}
-
-.linktext a {
- font-weight: bold;
-}
-
-.linktext a:hover {
- border-radius: 5px;
-}
-
-#itch a:hover
-{
- color: #fa5c5c;
-}
-#twitter a:hover
-{
- color: #00acee;
-}
-
-
-#coolervidwrapper {
- padding-left: min(12em, 75%);
- padding-right: min(12em, 75%);
- min-width: 30%;
-}
-
-.videoWrapper {
- position: relative;
- padding-bottom: 56.25%; /* 16:9 */
- height: 0;
-
- max-block-size: 1400px;
-}
-.videoWrapper iframe {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
-
-}
diff --git a/hmm.json b/hmm.json
index 52d5ffad8..150a4f242 100644
--- a/hmm.json
+++ b/hmm.json
@@ -24,7 +24,7 @@
{
"name": "flixel-ui",
"type": "haxelib",
- "version": "2.4.0"
+ "version": "2.5.0"
},
{
"name": "flxanimate",
@@ -38,6 +38,11 @@
"type": "haxelib",
"version": "3.5.0"
},
+ {
+ "name": "hamcrest",
+ "type": "haxelib",
+ "version": "3.0.0"
+ },
{
"name": "haxeui-core",
"type": "git",
@@ -92,12 +97,33 @@
"url": "https://github.com/EliteMasterEric/lime"
},
{
- "name": "mockatoo",
+ "name": "mconsole",
"type": "git",
"dir": null,
"ref": "master",
+ "url": "https://github.com/massive-oss/mconsole"
+ },
+ {
+ "name": "mcover",
+ "type": "git",
+ "dir": "src",
+ "ref": "master",
+ "url": "https://github.com/massive-oss/mcover"
+ },
+ {
+ "name": "mockatoo",
+ "type": "git",
+ "dir": "src",
+ "ref": "master",
"url": "https://github.com/EliteMasterEric/mockatoo"
},
+ {
+ "name": "munit",
+ "type": "git",
+ "dir": "src",
+ "ref": "master",
+ "url": "https://github.com/EliteMasterEric/MassiveUnit"
+ },
{
"name": "openfl",
"type": "git",
@@ -121,6 +147,13 @@
"name": "tink_json",
"type": "haxelib",
"version": "0.11.0"
+ },
+ {
+ "name": "hmm",
+ "type": "git",
+ "dir": null,
+ "ref": "d514d7786cabf18b90e60fcee38399fd44c2ddfb",
+ "url": "https://github.com/andywhite37/hmm"
}
]
}
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index 3c6470373..9bd668b69 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -180,9 +180,15 @@ class Conductor
*/
public static function forceBPM(?bpm:Float = null)
{
- if (bpm != null) trace('[CONDUCTOR] Forcing BPM to ' + bpm);
+ if (bpm != null)
+ {
+ trace('[CONDUCTOR] Forcing BPM to ${bpm}');
+ }
else
- trace('[CONDUCTOR] Resetting BPM to default');
+ {
+ // trace('[CONDUCTOR] Resetting BPM to default');
+ }
+
Conductor.bpmOverride = bpm;
}
@@ -272,9 +278,9 @@ class Conductor
if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0)
{
var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1];
- currentTimeChange.beatTime = prevTimeChange.beatTime
- + ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC)
- + 0.01;
+ currentTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime
+ + ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC),
+ 4);
}
}
}
@@ -282,7 +288,10 @@ class Conductor
timeChanges.push(currentTimeChange);
}
- trace('Done mapping time changes: ' + timeChanges);
+ if (timeChanges.length > 0)
+ {
+ trace('Done mapping time changes: ${timeChanges}' + timeChanges);
+ }
// Update currentStepTime
Conductor.update(Conductor.songPosition);
diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx
index e8a66cb14..81055fb34 100644
--- a/source/funkin/Controls.hx
+++ b/source/funkin/Controls.hx
@@ -50,7 +50,7 @@ enum Control
#end
}
-@:enum
+enum
abstract Action(String) to String from String
{
var UI_UP = "ui_up";
diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx
index 9ad27bfcc..dd87e7d36 100644
--- a/source/funkin/FreeplayState.hx
+++ b/source/funkin/FreeplayState.hx
@@ -492,22 +492,6 @@ class FreeplayState extends MusicBeatSubState
}
}
- // if (regexp != null)
- // tempSongs = songs.filter(item -> regexp.match(item.songName));
-
- // tempSongs.sort(function(a, b):Int
- // {
- // var tempA = a.songName.toUpperCase();
- // var tempB = b.songName.toUpperCase();
-
- // if (tempA < tempB)
- // return -1;
- // else if (tempA > tempB)
- // return 1;
- // else
- // return 0;
- // });
-
for (i in 0...tempSongs.length)
{
var funnyMenu:SongMenuItem = new SongMenuItem(FlxG.width, (i * 150) + 160, tempSongs[i].songName);
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 36b1d26e3..98393fda4 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -2,6 +2,7 @@ package funkin.data;
import openfl.Assets;
import funkin.util.assets.DataAssets;
+import funkin.util.VersionUtil;
import haxe.Constraints.Constructible;
/**
@@ -24,16 +25,23 @@ abstract class BaseRegistry & Constructible;
+ /**
+ * The version rule to use when loading entries.
+ * If the entry's version does not match this rule, migration is needed.
+ */
+ final versionRule:thx.semver.VersionRule;
+
// public abstract static final instance:BaseRegistry = new BaseRegistry<>();
/**
* @param registryId A readable ID for this registry, used when logging.
* @param dataFilePath The path (relative to `assets/data`) to search for JSON files.
*/
- public function new(registryId:String, dataFilePath:String)
+ public function new(registryId:String, dataFilePath:String, versionRule:thx.semver.VersionRule = null)
{
this.registryId = registryId;
this.dataFilePath = dataFilePath;
+ this.versionRule = versionRule == null ? "1.0.x" : versionRule;
this.entries = new Map();
}
@@ -125,6 +133,13 @@ abstract class BaseRegistry & Constructible
+ {
+ var entryStr:String = loadEntryFile(id);
+ var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
+ return entryVersion;
+ }
+
function log(message:String):Void
{
trace('[' + registryId + '] ' + message);
@@ -154,10 +169,36 @@ abstract class BaseRegistry & Constructible;
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object,
+ * accounting for old versions of the data.
+ *
+ * NOTE: Extend this function to handle migration.
+ */
+ public function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null
+ {
+ // If a version rule is not specified, do not check against it.
+ if (versionRule == null || VersionUtil.validateVersion(version, versionRule))
+ {
+ return parseEntryData(id);
+ }
+ else
+ {
+ throw '[${registryId}] Entry ${id} does not support migration.';
+ }
+
+ // Example:
+ // if (VersionUtil.validateVersion(version, "0.1.x")) {
+ // return parseEntryData_v0_1_x(id);
+ // } else {
+ // super.parseEntryDataWithMigration(id, version);
+ // }
+ }
+
/**
* Retrieve the list of scripted class names to load.
* @return An array of scripted class names.
diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx
index 54ed81093..36ce883ea 100644
--- a/source/funkin/data/level/LevelRegistry.hx
+++ b/source/funkin/data/level/LevelRegistry.hx
@@ -11,13 +11,15 @@ class LevelRegistry extends BaseRegistry
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
- public static final LEVEL_DATA_VERSION:String = "1.0.0";
+ public static final LEVEL_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+ public static final LEVEL_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
public static final instance:LevelRegistry = new LevelRegistry();
public function new()
{
- super('LEVEL', 'levels');
+ super('LEVEL', 'levels', LEVEL_DATA_VERSION_RULE);
}
/**
@@ -34,7 +36,7 @@ class LevelRegistry extends BaseRegistry
if (parser.errors.length > 0)
{
- trace('Failed to parse entry data: ${id}');
+ trace('[${registryId}] Failed to parse entry data: ${id}');
for (error in parser.errors)
{
trace(error);
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
index d666a037c..65f6f627a 100644
--- a/source/funkin/data/notestyle/NoteStyleRegistry.hx
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -11,7 +11,9 @@ class NoteStyleRegistry extends BaseRegistry
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateNoteStyleData()` function.
*/
- public static final NOTE_STYLE_DATA_VERSION:String = "1.0.0";
+ public static final NOTE_STYLE_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+ public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
public static final DEFAULT_NOTE_STYLE_ID:String = "funkin";
@@ -19,7 +21,7 @@ class NoteStyleRegistry extends BaseRegistry
public function new()
{
- super('NOTESTYLE', 'notestyles');
+ super('NOTESTYLE', 'notestyles', NOTE_STYLE_DATA_VERSION_RULE);
}
public function fetchDefault():NoteStyle
@@ -43,7 +45,7 @@ class NoteStyleRegistry extends BaseRegistry
if (parser.errors.length > 0)
{
- trace('Failed to parse entry data: ${id}');
+ trace('[${registryId}] Failed to parse entry data: ${id}');
for (error in parser.errors)
{
trace(error);
diff --git a/source/funkin/freeplayStuff/DJBoyfriend.hx b/source/funkin/freeplayStuff/DJBoyfriend.hx
index b109fb03f..5bee4129a 100644
--- a/source/funkin/freeplayStuff/DJBoyfriend.hx
+++ b/source/funkin/freeplayStuff/DJBoyfriend.hx
@@ -40,27 +40,6 @@ class DJBoyfriend extends FlxSprite
{
super.update(elapsed);
- if (FlxG.keys.justPressed.LEFT)
- {
- animOffsets["confirm"] = [animOffsets["confirm"][0] + 1, animOffsets["confirm"][1]];
- applyAnimOffset();
- }
- else if (FlxG.keys.justPressed.RIGHT)
- {
- animOffsets["confirm"] = [animOffsets["confirm"][0] - 1, animOffsets["confirm"][1]];
- applyAnimOffset();
- }
- else if (FlxG.keys.justPressed.UP)
- {
- animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] + 1];
- applyAnimOffset();
- }
- else if (FlxG.keys.justPressed.DOWN)
- {
- animOffsets["confirm"] = [animOffsets["confirm"][0], animOffsets["confirm"][1] - 1];
- applyAnimOffset();
- }
-
switch (currentState)
{
case Intro:
diff --git a/source/funkin/freeplayStuff/SongMenuItem.hx b/source/funkin/freeplayStuff/SongMenuItem.hx
index 3d9f9dd04..37198f6d7 100644
--- a/source/funkin/freeplayStuff/SongMenuItem.hx
+++ b/source/funkin/freeplayStuff/SongMenuItem.hx
@@ -103,9 +103,6 @@ class SongMenuItem extends FlxSpriteGroup
y = CoolUtil.coolLerp(y, targetPos.y, 0.4);
}
- if (FlxG.keys.justPressed.ALT) selected = false;
- if (FlxG.keys.justPressed.CONTROL) selected = true;
-
super.update(elapsed);
}
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 1ff2c0caa..3f5752acc 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -24,7 +24,7 @@ class PolymodHandler
* Bug fixes increment the patch version, new features increment the minor version.
* Changes that break old mods increment the major version.
*/
- static final API_VERSION = "0.1.0";
+ static final API_VERSION:String = "0.1.0";
/**
* Where relative to the executable that mods are located.
diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx
index 5a1c4dd6a..3cc7b7984 100644
--- a/source/funkin/modding/module/ModuleHandler.hx
+++ b/source/funkin/modding/module/ModuleHandler.hx
@@ -67,19 +67,26 @@ class ModuleHandler
{
modulePriorityOrder = moduleCache.keys().array();
- modulePriorityOrder.sort(function(a:String, b:String):Int {
- var aModule:Module = moduleCache.get(a);
- var bModule:Module = moduleCache.get(b);
+ modulePriorityOrder.sort(sortByPriority);
+ }
- if (aModule.priority != bModule.priority)
- {
- return aModule.priority - bModule.priority;
- }
- else
- {
- return SortUtil.alphabetically(a, b);
- }
- });
+ /**
+ * Given two module IDs, sort them by priority.
+ * @return 1 or -1 depending on which module has a higher priority.
+ */
+ static function sortByPriority(a:String, b:String)
+ {
+ var aModule:Module = moduleCache.get(a);
+ var bModule:Module = moduleCache.get(b);
+
+ if (aModule.priority != bModule.priority)
+ {
+ return aModule.priority - bModule.priority;
+ }
+ else
+ {
+ return SortUtil.alphabetically(a, b);
+ }
}
public static function getModule(moduleId:String):Module
diff --git a/source/funkin/play/HealthIcon.hx b/source/funkin/play/HealthIcon.hx
index e4802b99c..7785fb4b1 100644
--- a/source/funkin/play/HealthIcon.hx
+++ b/source/funkin/play/HealthIcon.hx
@@ -146,13 +146,13 @@ class HealthIcon extends FlxSprite
*/
public function toggleOldIcon():Void
{
- if (characterId == 'beta')
+ if (characterId == 'bf-old')
{
characterId = PlayState.instance.currentPlayerId;
}
else
{
- characterId = 'beta';
+ characterId = 'bf-old';
}
}
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index a28c35c67..d01c7b049 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -666,14 +666,6 @@ class PlayState extends MusicBeatSubState
super.update(elapsed);
- if (FlxG.keys.justPressed.U)
- {
- // hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
- disableKeys = true;
- persistentUpdate = false;
- openSubState(new StageOffsetSubState());
- }
-
updateHealthBar();
updateScoreText();
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 18a537a40..710eb884b 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -382,7 +382,7 @@ class CharacterDataParser
input.version = CHARACTER_DATA_VERSION;
}
- if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE))
+ if (!VersionUtil.validateVersionStr(input.version, CHARACTER_DATA_VERSION_RULE))
{
trace('ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})');
return null;
diff --git a/source/funkin/play/cutscene/dialogue/Conversation.hx b/source/funkin/play/cutscene/dialogue/Conversation.hx
index 0816e1d25..a6851f0f9 100644
--- a/source/funkin/play/cutscene/dialogue/Conversation.hx
+++ b/source/funkin/play/cutscene/dialogue/Conversation.hx
@@ -5,7 +5,7 @@ import flixel.group.FlxSpriteGroup;
import flixel.util.FlxColor;
import flixel.tweens.FlxTween;
import flixel.tweens.FlxEase;
-import flixel.system.FlxSound;
+import flixel.sound.FlxSound;
import funkin.util.SortUtil;
import flixel.util.FlxSort;
import funkin.modding.events.ScriptEvent;
@@ -18,7 +18,7 @@ import flixel.addons.display.FlxPieDial;
/**
* A high-level handler for dialogue.
- *
+ *
* This shit is great for modders but it's pretty elaborate for how much it'll actually be used, lolol. -Eric
*/
class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
@@ -299,7 +299,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
/**
* Dispatch an event to attempt to advance the conversation.
* This is done once at the start of the conversation, and once whenever the user presses CONFIRM to advance the conversation.
- *
+ *
* The broadcast event may be cancelled by modules or ScriptedConversations. This will prevent the conversation from actually advancing.
* This is useful if you want to manually play an animation or something.
*/
@@ -365,7 +365,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
/**
* Dispatch an event to attempt to immediately end the conversation.
- *
+ *
* The broadcast event may be cancelled by modules or ScriptedConversations. This will prevent the conversation from being cancelled.
* This is useful if you want to prevent an animation from being skipped or something.
*/
@@ -567,7 +567,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
/**
* As this event is dispatched to the Conversation, it is also dispatched to the active speaker.
- * @param event
+ * @param event
*/
function propagateEvent(event:ScriptEvent):Void
{
diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx
index 6cb9e7cdc..fd45342a0 100644
--- a/source/funkin/play/notes/notestyle/NoteStyle.hx
+++ b/source/funkin/play/notes/notestyle/NoteStyle.hx
@@ -104,6 +104,10 @@ class NoteStyle implements IRegistryEntry
noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary());
+ if (noteFrames == null) {
+ throw 'Could not load note frames for note style: $id';
+ }
+
noteFrames.parent.persist = true;
return noteFrames;
@@ -161,7 +165,8 @@ class NoteStyle implements IRegistryEntry
{
if (raw)
{
- var rawPath:Null = _data?.assets?.holdNote?.assetPath;
+ // TODO: figure out why ?. didn't work here
+ var rawPath:Null = (_data?.assets?.holdNote == null) ? null : _data?.assets?.holdNote?.assetPath;
return (rawPath == null) ? fallback.getHoldNoteAssetPath(true) : rawPath;
}
@@ -299,6 +304,6 @@ class NoteStyle implements IRegistryEntry
public function _fetchData(id:String):Null
{
- return NoteStyleRegistry.instance.parseEntryData(id);
+ return NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id));
}
}
diff --git a/source/funkin/play/song/SongMigrator.hx b/source/funkin/play/song/SongMigrator.hx
index f561c4d3e..bb8718bb7 100644
--- a/source/funkin/play/song/SongMigrator.hx
+++ b/source/funkin/play/song/SongMigrator.hx
@@ -32,7 +32,7 @@ class SongMigrator
{
if (jsonData.version != null)
{
- if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
+ if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE))
{
trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
@@ -70,7 +70,7 @@ class SongMigrator
{
if (jsonData.version)
{
- if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
+ if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE))
{
trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx
index de8804cfb..867c6e1a5 100644
--- a/source/funkin/play/stage/StageData.hx
+++ b/source/funkin/play/stage/StageData.hx
@@ -217,7 +217,7 @@ class StageDataParser
return null;
}
- if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE))
+ if (!VersionUtil.validateVersionStr(input.version, STAGE_DATA_VERSION_RULE))
{
trace('ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
return null;
diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
index 43e8ac96c..df5a25b62 100644
--- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
@@ -257,6 +257,7 @@ class ChartEditorDialogHandler
* @param closable Whether the dialog can be closed by the user.
* @return The dialog that was opened.
*/
+ @:haxe.warning("-WVarInit")
public static function openUploadInstDialog(state:ChartEditorState, ?closable:Bool = true):Dialog
{
var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable);
@@ -416,6 +417,7 @@ class ChartEditorDialogHandler
* @param state The ChartEditorState instance.
* @return The dialog to open.
*/
+ @:haxe.warning("-WVarInit")
public static function openSongMetadataDialog(state:ChartEditorState):Dialog
{
var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
diff --git a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
index 768e0be52..2cd9ab2fe 100644
--- a/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorEventSprite.hx
@@ -95,7 +95,7 @@ class ChartEditorEventSprite extends FlxSprite
public function correctAnimationName(name:String):String
{
if (this.animation.exists(name)) return name;
- trace('Warning: Invalid animation name "' + name + '" for song event. Using "${DEFAULT_EVENT}"');
+ trace('Warning: Invalid animation name "${name}" for song event. Using "${DEFAULT_EVENT}"');
return DEFAULT_EVENT;
}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index e1ed75b06..aa6e70714 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -15,7 +15,7 @@ import flixel.addons.transition.FlxTransitionableState;
import flixel.input.keyboard.FlxKey;
import flixel.math.FlxPoint;
import flixel.math.FlxRect;
-import flixel.system.FlxSound;
+import flixel.sound.FlxSound;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.tweens.misc.VarTween;
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
index 7913ac8ca..1e5d60bf3 100644
--- a/source/funkin/ui/story/Level.hx
+++ b/source/funkin/ui/story/Level.hx
@@ -181,6 +181,6 @@ class Level implements IRegistryEntry
public function _fetchData(id:String):Null
{
- return LevelRegistry.instance.parseEntryData(id);
+ return LevelRegistry.instance.parseEntryDataWithMigration(id, LevelRegistry.instance.fetchEntryVersion(id));
}
}
diff --git a/source/funkin/util/BezierUtil.hx b/source/funkin/util/BezierUtil.hx
index 56d20cf48..b3cf51d1d 100644
--- a/source/funkin/util/BezierUtil.hx
+++ b/source/funkin/util/BezierUtil.hx
@@ -15,7 +15,7 @@ class BezierUtil
/**
* Linearly interpolate between three values.
- * Depending on p, 0 = a, 0.5 = b, 1 = c, 0.25 = halfway between a and c, etc.
+ * Depending on p, 0 = a, 0.5 = b, 1 = c, 0.25 = halfway between a and b, etc.
*/
static inline function mix3(p:Float, a:Float, b:Float, c:Float):Float
{
@@ -32,11 +32,6 @@ class BezierUtil
return mix2(p, mix4(p, a, b, c, d), mix4(p, b, c, d, e));
}
- static inline function mix6(p:Float, a:Float, b:Float, c:Float, d:Float, e:Float, f:Float):Float
- {
- return mix2(p, mix5(p, a, b, c, d, e), mix5(p, b, c, d, e, f));
- }
-
/**
* A bezier curve with two points.
* This is really just linear interpolation but whatever.
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index c06911e4e..71cfe7b8e 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -88,12 +88,17 @@ class Constants
public static final COLOR_HEALTH_BAR_GREEN:FlxColor = 0xFF66FF33;
/**
- * The base colors of the notes.
+ * The base colors used by notes.
*/
- public static final COLOR_NOTES:Array = [0xFFFF22AA, 0xFF00EEFF, 0xFF00CC00, 0xFFCC1111];
+ public static var COLOR_NOTES:Array = [
+ 0xFFFF22AA, // left (0)
+ 0xFF00EEFF, // down (1)
+ 0xFF00CC00, // up (2)
+ 0xFFCC1111 // right (3)
+ ];
/**
- * STAGE DEFAULTS
+ * GAME DEFAULTS
*/
// ==============================
diff --git a/source/funkin/util/DateUtil.hx b/source/funkin/util/DateUtil.hx
index 715371f80..72838477c 100644
--- a/source/funkin/util/DateUtil.hx
+++ b/source/funkin/util/DateUtil.hx
@@ -2,9 +2,10 @@ package funkin.util;
class DateUtil
{
- public static function generateTimestamp():String
+ public static function generateTimestamp(?date:Date = null):String
{
- var date = Date.now();
+ if (date == null) date = Date.now();
+
return
'${date.getFullYear()}-${Std.string(date.getMonth() + 1).lpad('0', 2)}-${Std.string(date.getDate()).lpad('0', 2)}-${Std.string(date.getHours()).lpad('0', 2)}-${Std.string(date.getMinutes()).lpad('0', 2)}-${Std.string(date.getSeconds()).lpad('0', 2)}';
}
diff --git a/source/funkin/util/SaveDataUtil.hx b/source/funkin/util/SaveDataUtil.hx
deleted file mode 100644
index 202536aad..000000000
--- a/source/funkin/util/SaveDataUtil.hx
+++ /dev/null
@@ -1 +0,0 @@
-package funkin.util;
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index d731aee8f..0075e83c0 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -11,26 +11,21 @@ typedef ScoreInput =
var t:Int; // Start timestamp
}
+/**
+ * A class of functions dedicated to serializing and deserializing data.
+ */
class SerializerUtil
{
static final INDENT_CHAR = "\t";
/**
* Convert a Haxe object to a JSON string.
- **/
+ */
public static function toJSON(input:Dynamic, ?pretty:Bool = true):String
{
return Json.stringify(input, replacer, pretty ? INDENT_CHAR : null);
}
- /**
- * Convert a JSON string to a Haxe object of the chosen type.
- */
- public static function fromJSONTyped(input:String, type:Class):T
- {
- return cast Json.parse(input);
- }
-
/**
* Convert a JSON string to a Haxe object.
*/
@@ -76,14 +71,19 @@ class SerializerUtil
if (Std.isOfType(value, String)) return value;
// Stringify Version objects.
- var valueVersion:thx.semver.Version = cast value;
- var result = '${valueVersion.major}.${valueVersion.minor}.${valueVersion.patch}';
- if (valueVersion.hasPre) result += '-${valueVersion.pre}';
- if (valueVersion.hasBuild) result += '+${valueVersion.build}';
- return result;
+ return serializeVersion(cast value);
}
// Else, return the value as-is.
return value;
}
+
+ static inline function serializeVersion(value:thx.semver.Version):String
+ {
+ var result = '${value.major}.${value.minor}.${value.patch}';
+ if (value.hasPre) result += '-${value.pre}';
+ // TODO: Merge fix for version.hasBuild
+ if (value.build.length > 0) result += '+${value.build}';
+ return result;
+ }
}
diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx
index 08cde69a6..368bb12de 100644
--- a/source/funkin/util/VersionUtil.hx
+++ b/source/funkin/util/VersionUtil.hx
@@ -14,13 +14,11 @@ class VersionUtil
* Checks that a given verison number satisisfies a given version rule.
* Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports.
*/
- public static function validateVersion(version:String, versionRule:String):Bool
+ public static function validateVersion(version:thx.semver.Version, versionRule:thx.semver.VersionRule):Bool
{
try
{
- var v:Version = version; // Perform a cast.
- var vr:VersionRule = versionRule; // Perform a cast.
- return v.satisfies(vr);
+ return version.satisfies(versionRule);
}
catch (e)
{
@@ -28,4 +26,38 @@ class VersionUtil
return false;
}
}
+
+ /**
+ * Checks that a given verison number satisisfies a given version rule.
+ * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports.
+ */
+ public static function validateVersionStr(version:String, versionRule:String):Bool
+ {
+ try
+ {
+ var version:thx.semver.Version = version;
+ var versionRule:thx.semver.VersionRule = versionRule;
+ return version.satisfies(versionRule);
+ }
+ catch (e)
+ {
+ trace('[VERSIONUTIL] Invalid semantic version: ${version}');
+ return false;
+ }
+ }
+
+ /**
+ * Get and parse the semantic version from a JSON string.
+ * @param input The JSON string to parse.
+ * @return The semantic version, or null if it could not be parsed.
+ */
+ public static function getVersionFromJSON(input:String):Null
+ {
+ var parsed = SerializerUtil.fromJSON(input);
+ if (parsed == null) return null;
+ if (parsed.version == null) return null;
+ var versionStr:String = parsed.version; // Dynamic -> String cast
+ var version:thx.semver.Version = versionStr; // Implicit, not explicit, cast.
+ return version;
+ }
}
diff --git a/source/funkin/util/assets/FlxAnimationUtil.hx b/source/funkin/util/assets/FlxAnimationUtil.hx
index 0e32a1918..f94dc4dc3 100644
--- a/source/funkin/util/assets/FlxAnimationUtil.hx
+++ b/source/funkin/util/assets/FlxAnimationUtil.hx
@@ -19,7 +19,7 @@ class FlxAnimationUtil
if (anim.frameIndices != null && anim.frameIndices.length > 0)
{
// trace('addByIndices(${anim.name}, ${anim.prefix}, ${anim.frameIndices}, ${frameRate}, ${looped}, ${flipX}, ${flipY})');
- target.animation.addByIndices(anim.name, anim.prefix, anim.frameIndices, "", frameRate, looped, flipX, flipY);
+ target.animation.addByIndices(anim.name, anim.prefix, anim.frameIndices, '', frameRate, looped, flipX, flipY);
// trace('RESULT:${target.animation.getAnimationList()}');
}
else
diff --git a/source/funkin/util/tools/ArraySortTools.hx b/source/funkin/util/tools/ArraySortTools.hx
index 3af114b98..9a3f8b349 100644
--- a/source/funkin/util/tools/ArraySortTools.hx
+++ b/source/funkin/util/tools/ArraySortTools.hx
@@ -108,6 +108,9 @@ class ArraySortTools
input[i] = input[j];
input[j] = temp;
}
+
+ // Don't expect to get here.
+ return -1;
}
/**
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index 461b88bbf..739c5efdb 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -27,6 +27,7 @@ class MapTools
/**
* Return a list of keys from the map (as an array, rather than an iterator).
+ * TODO: Rename this?
*/
public static function keyValues(map:Map):Array
{
diff --git a/source/funkin/util/tools/StringTools.hx b/source/funkin/util/tools/StringTools.hx
index 8ff76a080..0585ffeae 100644
--- a/source/funkin/util/tools/StringTools.hx
+++ b/source/funkin/util/tools/StringTools.hx
@@ -51,6 +51,7 @@ class StringTools
/**
* Parses the string data as JSON and returns the resulting object.
+ * This is here so you can use `string.parseJSON()` when `using StringTools`.
*
* @return The parsed object.
*/
diff --git a/tests/unit/.gitignore b/tests/unit/.gitignore
new file mode 100644
index 000000000..abb6c9cf9
--- /dev/null
+++ b/tests/unit/.gitignore
@@ -0,0 +1,7 @@
+
+bin/
+# auto-generated
+source/TestSuite.hx
+.temp/
+report/
+build/
diff --git a/tests/unit/.munit b/tests/unit/.munit
new file mode 100644
index 000000000..61de57963
--- /dev/null
+++ b/tests/unit/.munit
@@ -0,0 +1,7 @@
+version=2.3.5
+src=source
+bin=bin
+report=report
+hxml=test.hxml
+classPaths=../../source
+coveragePackages=funkin
diff --git a/tests/unit/.vscode/launch.json b/tests/unit/.vscode/launch.json
new file mode 100644
index 000000000..a255f1644
--- /dev/null
+++ b/tests/unit/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Lime",
+ "type": "lime",
+ "request": "launch"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/unit/README.md b/tests/unit/README.md
new file mode 100644
index 000000000..00fed78e5
--- /dev/null
+++ b/tests/unit/README.md
@@ -0,0 +1,50 @@
+Flixel Unit Tests
+-----------------
+
+This is a unit test project using [munit](https://github.com/massiveinteractive/MassiveUnit). It's good practice to add tests for fixed bugs or new features.
+
+TODO Make sure the unit tests are automatically run on GitHub Actions.
+
+There's a 1:1 mapping between `.hx` files in `source/` and the unit test project - tests for `funkin.Conductor` go into `funkin.ConductorTest` etc.
+
+### Building
+
+Run one of the `test-*.hxml` files in this directory to run the tests on that specific target, e.g. `haxe test-cpp.hxml`. Currently supported are:
+
+- `web` (HTML5)
+- `cpp` (Native)
+
+Alternatively, this can be done from within Visual Studio Code - (`F1` -> `Tasks: Run Task` -> Choose the target to test).
+
+#### Adding Tests
+
+- Run `haxelib run munit create com.FooBarTest -for com.Foo`
+- Use `@:allow(full.package.name.ClassName)` to allow a test class to call private functions.
+- Use `mockatoo.Mockatoo.mock(ClassName)` to mock a class. See [Mockatoo docs](https://github.com/misprintt/mockatoo).
+
+#### Functions
+
+- `@Before` functions are named `before()`
+- Each `@Test` function starts with `test` and describes what exactly it tests. This can lead to long function names like `FlxEmitter#testStartShouldNotReviveMembers()` and serves as self-documentation.
+- Another thing that helps with self-documentation is adding a comment for tests that are related an issue on GitHub.
+
+ ```haxe
+ @Test // #1203
+ function testColorWithAlphaComparison()
+ ```
+
+### `FunkinTest` base class
+
+Test classes extend `FunkinTest`, which is a base class with some utility functions for testing.
+
+### `step()`
+
+`step()` advances the `FlxGame` exactly one step. This is useful for tests that depend on game time advancing / `FlxGame#step()` being executed, such as physics of `add()`ed objects, state switches, or just time passing for tweens or timers.
+
+There are two parameters:
+- `steps` - specifies the amount of steps to advance (defaults to 1)
+- `callback` - an optional callback function that is executed after each step
+
+### `testDestroy()`
+
+`testDestroy()` tests whether an `IFlxDestroyable` can safely be `destroy()`ed more than once (null reference errors are fairly common here). For this, `destroyable` has to be set during `before()` of the test class.
diff --git a/tests/unit/assets/preload/data/anotherFile.txt b/tests/unit/assets/preload/data/anotherFile.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/preload/data/levels/test.json b/tests/unit/assets/preload/data/levels/test.json
new file mode 100644
index 000000000..8b004e213
--- /dev/null
+++ b/tests/unit/assets/preload/data/levels/test.json
@@ -0,0 +1,47 @@
+{
+ "version": "1.0.0",
+ "name": "TEACHING TIME",
+ "titleAsset": "storymenu/titles/tutorial",
+ "props": [
+ {
+ "assetPath": "storymenu/props/gf",
+ "scale": 1.0,
+ "danceEvery": 2,
+ "offsets": [80, 80],
+ "animations": [
+ {
+ "name": "danceLeft",
+ "prefix": "idle0",
+ "frameIndices": [30, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
+ },
+ {
+ "name": "danceRight",
+ "prefix": "idle0",
+ "frameIndices": [
+ 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29
+ ]
+ }
+ ]
+ },
+ {
+ "assetPath": "storymenu/props/bf",
+ "scale": 1.0,
+ "danceEvery": 2,
+ "offsets": [150, 80],
+ "animations": [
+ {
+ "name": "idle",
+ "prefix": "idle0",
+ "frameRate": 24
+ },
+ {
+ "name": "confirm",
+ "prefix": "confirm0",
+ "frameRate": 24
+ }
+ ]
+ }
+ ],
+ "background": "#F9CF51",
+ "songs": ["tutorial"]
+}
diff --git a/tests/unit/assets/preload/data/mytype/blablabla.json b/tests/unit/assets/preload/data/mytype/blablabla.json
new file mode 100644
index 000000000..cf7d16746
--- /dev/null
+++ b/tests/unit/assets/preload/data/mytype/blablabla.json
@@ -0,0 +1,6 @@
+{
+ "version": "1.0.0",
+ "id": "blablabla",
+ "name": "blablabla API",
+ "data": []
+}
diff --git a/tests/unit/assets/preload/data/mytype/fizzbuzz.json b/tests/unit/assets/preload/data/mytype/fizzbuzz.json
new file mode 100644
index 000000000..9b160ff55
--- /dev/null
+++ b/tests/unit/assets/preload/data/mytype/fizzbuzz.json
@@ -0,0 +1,15 @@
+{
+ "version": "1.0.0",
+ "id": "fizzbuzz",
+ "name": "nodejs-express-mongoose-demo",
+ "data": [
+ {
+ "foo": "bar",
+ "bar": "foo"
+ },
+ {
+ "foo": "baz",
+ "bar": "baz"
+ }
+ ]
+}
diff --git a/tests/unit/assets/preload/data/mytype/foobar.json b/tests/unit/assets/preload/data/mytype/foobar.json
new file mode 100644
index 000000000..0377a2262
--- /dev/null
+++ b/tests/unit/assets/preload/data/mytype/foobar.json
@@ -0,0 +1,5 @@
+{
+ "version": "0.1.0",
+ "id": 1,
+ "name": "My First App"
+}
diff --git a/tests/unit/assets/preload/data/mytype/junk.json b/tests/unit/assets/preload/data/mytype/junk.json
new file mode 100644
index 000000000..13ae5d1a0
--- /dev/null
+++ b/tests/unit/assets/preload/data/mytype/junk.json
@@ -0,0 +1,4 @@
+{
+ "version": "3.0.0",
+ "info": "stuff"
+}
diff --git a/tests/unit/assets/preload/data/notestyles/funkin.json b/tests/unit/assets/preload/data/notestyles/funkin.json
new file mode 100644
index 000000000..abb039150
--- /dev/null
+++ b/tests/unit/assets/preload/data/notestyles/funkin.json
@@ -0,0 +1,59 @@
+{
+ "version": "1.0.0",
+ "name": "Funkin'",
+ "author": "PhantomArcade",
+ "fallback": null,
+ "assets": {
+ "note": {
+ "assetPath": "shared:arrows",
+ "scale": 1.0,
+ "isPixel": true,
+ "data": {
+ "left": { "prefix": "noteLeft" },
+ "down": { "prefix": "noteDown" },
+ "up": { "prefix": "noteUp" },
+ "right": { "prefix": "noteRight" }
+ }
+ },
+ "noteStrumline": {
+ "assetPath": "shared:arrows",
+ "scale": 1.0,
+ "offsets": [28, 32],
+ "isPixel": true,
+ "data": {
+ "leftStatic": { "prefix": "staticLeft0" },
+ "leftPress": { "prefix": "pressedLeft0" },
+ "leftConfirm": { "prefix": "confirmLeft0" },
+ "leftConfirmHold": { "prefix": "confirmLeft0" },
+ "downStatic": { "prefix": "staticDown0" },
+ "downPress": { "prefix": "pressedDown0" },
+ "downConfirm": { "prefix": "confirmDown0" },
+ "downConfirmHold": { "prefix": "confirmDown0" },
+ "upStatic": { "prefix": "staticUp0" },
+ "upPress": { "prefix": "pressedUp0" },
+ "upConfirm": { "prefix": "confirmUp0" },
+ "upConfirmHold": { "prefix": "confirmUp0" },
+ "rightStatic": { "prefix": "staticRight0" },
+ "rightPress": { "prefix": "pressedRight0" },
+ "rightConfirm": { "prefix": "confirmRight0" },
+ "rightConfirmHold": { "prefix": "confirmRight0" }
+ }
+ },
+ "holdNote": {
+ "assetPath": "NOTE_hold_assets",
+ "data": {}
+ },
+ "noteSplash": {
+ "assetPath": "",
+ "data": {
+ "enabled": true
+ }
+ },
+ "holdNoteCover": {
+ "assetPath": "",
+ "data": {
+ "enabled": true
+ }
+ }
+ }
+}
diff --git a/tests/unit/assets/preload/data/notestyles/test2.json b/tests/unit/assets/preload/data/notestyles/test2.json
new file mode 100644
index 000000000..95a669077
--- /dev/null
+++ b/tests/unit/assets/preload/data/notestyles/test2.json
@@ -0,0 +1,18 @@
+{
+ "version": "1.0.0",
+ "name": "Test2",
+ "author": "Eric",
+ "fallback": "funkin",
+ "assets": {
+ "note": {
+ "assetPath": "shared:coolstuff",
+ "scale": 1.8,
+ "data": {
+ "left": { "prefix": "noteLeft1" },
+ "down": { "prefix": "noteDown3" },
+ "up": { "prefix": "noteUp2" },
+ "right": { "prefix": "noteRight4" }
+ }
+ }
+ }
+}
diff --git a/tests/unit/assets/preload/data/test/blablabla.json b/tests/unit/assets/preload/data/test/blablabla.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/preload/data/test/test1.json b/tests/unit/assets/preload/data/test/test1.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/preload/data/test/test2.json b/tests/unit/assets/preload/data/test/test2.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/unit/assets/shared/images/arrows.png b/tests/unit/assets/shared/images/arrows.png
new file mode 100644
index 000000000..a44368432
Binary files /dev/null and b/tests/unit/assets/shared/images/arrows.png differ
diff --git a/tests/unit/assets/shared/images/arrows.xml b/tests/unit/assets/shared/images/arrows.xml
new file mode 100644
index 000000000..8f3355462
--- /dev/null
+++ b/tests/unit/assets/shared/images/arrows.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/unit/project.xml b/tests/unit/project.xml
new file mode 100644
index 000000000..ccfadce8c
--- /dev/null
+++ b/tests/unit/project.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/unit/source/FunkinAssert.hx b/tests/unit/source/FunkinAssert.hx
new file mode 100644
index 000000000..acfba6723
--- /dev/null
+++ b/tests/unit/source/FunkinAssert.hx
@@ -0,0 +1,77 @@
+package;
+
+import flixel.math.FlxPoint;
+import flixel.math.FlxRect;
+import haxe.PosInfos;
+import massive.munit.Assert;
+
+using flixel.util.FlxArrayUtil;
+
+/**
+ * @see https://github.com/HaxeFlixel/flixel/tree/dev/tests/unit
+ */
+class FunkinAssert
+{
+ /**
+ * Assert if `expected` is within `margin` of `actual`, and fail if not.
+ * Useful for comparting Float values.
+ *
+ * @param expected The expected value of the test.
+ * @param actual The actual value of the test.
+ * @param margin The allowed margin of error between the expected and actual values.
+ * @param info Info on the position this function was called from. Magic value, passed automatically.
+ */
+ public static function areNear(expected:Float, actual:Float, margin:Float = 0.001, ?info:PosInfos):Void
+ {
+ if (areNearHelper(expected, actual)) Assert.assertionCount++;
+ else
+ Assert.fail('Value [$actual] is not within [$margin] of [$expected]', info);
+ }
+
+ public static function rectsNear(expected:FlxRect, actual:FlxRect, margin:Float = 0.001, ?info:PosInfos):Void
+ {
+ var areNear = areNearHelper(expected.x, actual.x, margin)
+ && areNearHelper(expected.y, actual.y, margin)
+ && areNearHelper(expected.width, actual.width, margin)
+ && areNearHelper(expected.height, actual.height, margin);
+
+ if (areNear) Assert.assertionCount++;
+ else
+ Assert.fail('Value [$actual] is not within [$margin] of [$expected]', info);
+ }
+
+ static function areNearHelper(expected:Float, actual:Float, margin:Float = 0.001):Bool
+ {
+ return actual >= expected - margin && actual <= expected + margin;
+ }
+
+ public static function arraysEqual(expected:Array, actual:Array, ?info:PosInfos):Void
+ {
+ if (expected.equals(actual)) Assert.assertionCount++;
+ else
+ Assert.fail('\nExpected\n ${expected}\nbut was\n ${actual}\n', info);
+ }
+
+ public static function arraysNotEqual(expected:Array, actual:Array, ?info:PosInfos):Void
+ {
+ if (!expected.equals(actual)) Assert.assertionCount++;
+ else
+ Assert.fail('\nValue\n ${actual}\nwas equal to\n ${expected}\n', info);
+ }
+
+ public static function pointsEqual(expected:FlxPoint, actual:FlxPoint, ?msg:String, ?info:PosInfos)
+ {
+ if (expected.equals(actual)) Assert.assertionCount++;
+ else if (msg != null) Assert.fail(msg, info);
+ else
+ Assert.fail("Value [" + actual + "] was not equal to expected value [" + expected + "]", info);
+ }
+
+ public static function pointsNotEqual(expected:FlxPoint, actual:FlxPoint, ?msg:String, ?info:PosInfos)
+ {
+ if (!expected.equals(actual)) Assert.assertionCount++;
+ else if (msg != null) Assert.fail(msg, info);
+ else
+ Assert.fail("Value [" + actual + "] was equal to value [" + expected + "]", info);
+ }
+}
diff --git a/tests/unit/source/FunkinTest.hx b/tests/unit/source/FunkinTest.hx
new file mode 100644
index 000000000..7e0e0b26c
--- /dev/null
+++ b/tests/unit/source/FunkinTest.hx
@@ -0,0 +1,105 @@
+package;
+
+import openfl.utils.Assets;
+import openfl.errors.Error;
+import flixel.FlxG;
+import flixel.FlxState;
+import flixel.tweens.FlxTween;
+import flixel.util.FlxDestroyUtil.IFlxDestroyable;
+import massive.munit.Assert;
+
+/**
+ * @see https://github.com/HaxeFlixel/flixel/tree/dev/tests/unit
+ */
+class FunkinTest
+{
+ public static final MS_PER_STEP:Float = 1.0 / 60.0 * 1000;
+
+ // approx. amount of ticks at 60 fps
+ static inline var TICKS_PER_FRAME:UInt = 25;
+ static var totalSteps:UInt = 0;
+
+ var destroyable:IFlxDestroyable;
+
+ public function new() {}
+
+ @After
+ @:access(flixel)
+ function after()
+ {
+ // Redefine how the game gets the time during tests.
+ FlxG.game.getTimer = function() {
+ return totalSteps * TICKS_PER_FRAME;
+ }
+
+ // make sure we have the same starting conditions for each test
+ resetGame();
+ }
+
+ /**
+ * Advance the game simulation.
+ * @param steps The amount to advance the game by.
+ * @param callback A function to call after each step.
+ */
+ @:access(flixel)
+ function step(steps:UInt = 1, ?callback:Void->Void)
+ {
+ for (i in 0...steps)
+ {
+ FlxG.game.step();
+ if (callback != null) callback();
+ totalSteps++;
+ }
+ }
+
+ function resetGame()
+ {
+ FlxG.resetGame();
+ step();
+ }
+
+ function switchState(nextState:FlxState)
+ {
+ FlxG.switchState(nextState);
+ step();
+ }
+
+ function resetState()
+ {
+ FlxG.resetState();
+ step();
+ }
+
+ @Test
+ public function testAssert()
+ {
+ Assert.areEqual(4, 2 + 2);
+ }
+
+ @Test
+ function testDestroy()
+ {
+ if (destroyable == null)
+ {
+ return;
+ }
+
+ try
+ {
+ destroyable.destroy();
+ destroyable.destroy();
+ }
+ catch (e:Error)
+ {
+ Assert.fail(e.message);
+ }
+ }
+
+ function finishTween(tween:FlxTween)
+ {
+ while (!tween.finished)
+ {
+ step();
+ }
+ }
+}
diff --git a/tests/unit/source/MockTest.hx b/tests/unit/source/MockTest.hx
new file mode 100644
index 000000000..c1ae0a90d
--- /dev/null
+++ b/tests/unit/source/MockTest.hx
@@ -0,0 +1,60 @@
+package;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.DateUtil;
+
+class MockTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testMock()
+ {
+ // Test that mocking works.
+
+ var mockSprite = mockatoo.Mockatoo.mock(flixel.FlxSprite);
+ var mockAnim = mockatoo.Mockatoo.mock(flixel.animation.FlxAnimationController);
+ mockSprite.animation = mockAnim;
+
+ var animData:funkin.data.animation.AnimationData =
+ {
+ name: "testAnim",
+ prefix: "blablabla"
+ };
+
+ mockSprite.animation.addByPrefix("testAnim", "blablabla", 24, false, false, false);
+
+ // Verify that the method was called once.
+ // If not, a VerificationException will be thrown and the test will fail.
+ mockatoo.Mockatoo.verify(mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false), times(1));
+
+ try
+ {
+ // Attempt to verify the method was called.
+ // This should FAIL, since we didn't call the method.
+ mockatoo.Mockatoo.verify(mockAnim.addByIndices("testAnim", "blablabla", [], "", 24, false, false, false), times(1));
+ Assert.fail("Mocking function should have thrown but didn't.");
+ }
+ catch (_:mockatoo.exception.VerificationException)
+ {
+ // Expected.
+ }
+ }
+}
diff --git a/tests/unit/source/TestMain.hx b/tests/unit/source/TestMain.hx
new file mode 100644
index 000000000..a3fbdb428
--- /dev/null
+++ b/tests/unit/source/TestMain.hx
@@ -0,0 +1,77 @@
+package;
+
+import openfl.Lib;
+import flixel.FlxGame;
+import flixel.FlxState;
+import massive.munit.TestRunner;
+import massive.munit.client.HTTPClient;
+import massive.munit.client.SummaryReportClient;
+
+/**
+ * Auto generated Test Application.
+ * Refer to munit command line tool for more information (haxelib run munit)
+ */
+class TestMain
+{
+ /**
+ * If true, include a report with each ignored test and their descriptions.
+ */
+ static final INCLUDE_IGNORED_REPORT:Bool = false;
+
+ static function main()
+ {
+ new TestMain();
+ }
+
+ public function new()
+ {
+ // Flixel was not designed for unit testing so we can only have one instance for now.
+ Lib.current.stage.addChild(new FlxGame(640, 480, FlxState, 60, 60, true));
+
+ var suites = new Array>();
+ suites.push(TestSuite);
+
+ #if MCOVER
+ // Print individual test results alongside coverage results for each test class,
+ // as well as a final coverage report for the entire test suite.
+ var innerClient = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT);
+ var client = new mcover.coverage.munit.client.MCoverPrintClient(innerClient);
+ // Print final test results alongside detailed coverage results for the test suite.
+ var httpClient = new HTTPClient(new mcover.coverage.munit.client.MCoverSummaryReportClient());
+ // NOTE: You can also create a custom ICoverageTestResultClient implementation
+
+ mcover.coverage.MCoverage.getLogger().addClient(new mcover.coverage.client.CodecovJsonPrintClient());
+ #else
+ // Print individual test results.
+ var client = new massive.munit.client.RichPrintClient(INCLUDE_IGNORED_REPORT);
+ // Print final test suite results.
+ var httpClient = new HTTPClient(new SummaryReportClient());
+ #end
+
+ var runner = new TestRunner(client);
+ runner.addResultClient(httpClient);
+
+ runner.completionHandler = completionHandler;
+ runner.run(suites);
+ }
+
+ /**
+ * updates the background color and closes the current browser
+ * for flash and html targets (useful for continuos integration servers)
+ */
+ function completionHandler(successful:Bool):Void
+ {
+ try
+ {
+ #if flash
+ openfl.external.ExternalInterface.call("testResult", successful);
+ #elseif js
+ js.Lib.eval("testResult(" + successful + ");");
+ #elseif sys
+ Sys.exit(successful ? 0 : 1);
+ #end
+ }
+ // if run from outside browser can get error which we can ignore
+ catch (e:Dynamic) {}
+ }
+}
diff --git a/tests/unit/source/_template.hxtemplate b/tests/unit/source/_template.hxtemplate
new file mode 100644
index 000000000..47a546bd0
--- /dev/null
+++ b/tests/unit/source/_template.hxtemplate
@@ -0,0 +1,30 @@
+package funkin.play;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.play.PlayState;
+
+@:access(funkin.play.PlayState)
+class Template extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function test() {}
+}
diff --git a/tests/unit/source/flixel/FlxGTest.hx b/tests/unit/source/flixel/FlxGTest.hx
new file mode 100644
index 000000000..5f201a9f0
--- /dev/null
+++ b/tests/unit/source/flixel/FlxGTest.hx
@@ -0,0 +1,107 @@
+package flixel;
+
+import massive.munit.Assert;
+
+@:access(flixel.FlxG)
+class FlxGTest extends FunkinTest
+{
+ @Test function testVERSIONNull():Void
+ Assert.isNotNull(FlxG.VERSION);
+
+ @Test function testGameNull():Void
+ Assert.isNotNull(FlxG.game);
+
+ @Test function testStageNull():Void
+ Assert.isNotNull(FlxG.stage);
+
+ @Test function testStateNull():Void
+ Assert.isNotNull(FlxG.state);
+
+ @Test function testWorldBoundsNull():Void
+ Assert.isNotNull(FlxG.worldBounds);
+
+ @Test function testSaveNull():Void
+ Assert.isNotNull(FlxG.save);
+
+ #if FLX_MOUSE
+ @Test function testMouseNull():Void
+ Assert.isNotNull(FlxG.mouse);
+ #end
+
+ #if FLX_TOUCH
+ @Test function testTouchNull():Void
+ Assert.isNotNull(FlxG.touches);
+ #end
+
+ #if FLX_POINTER_INPUT
+ @Test function testSwipesNull():Void
+ Assert.isNotNull(FlxG.swipes);
+ #end
+
+ #if FLX_KEYBOARD
+ @Test function testKeysNull():Void
+ Assert.isNotNull(FlxG.keys);
+ #end
+
+ #if FLX_GAMEPAD
+ @Test function testGamepadsNull():Void
+ Assert.isNotNull(FlxG.gamepads);
+ #end
+
+ #if android
+ @Test function testAndroidNull():Void
+ Assert.isNotNull(FlxG.android);
+ #end
+
+ #if js
+ @Test function testHtml5Null():Void
+ Assert.isNotNull(FlxG.html5);
+ #end
+
+ @Test function testInputsNull():Void
+ Assert.isNotNull(FlxG.inputs);
+
+ @Test function testConsoleNull():Void
+ Assert.isNotNull(FlxG.console);
+
+ @Test function testLogNull():Void
+ Assert.isNotNull(FlxG.log);
+
+ @Test function testWatchNull():Void
+ Assert.isNotNull(FlxG.watch);
+
+ @Test function testDebuggerNull():Void
+ Assert.isNotNull(FlxG.debugger);
+
+ @Test function testVcrNull():Void
+ Assert.isNotNull(FlxG.vcr);
+
+ @Test function testBitmapNull():Void
+ Assert.isNotNull(FlxG.bitmap);
+
+ @Test function testCamerasNull():Void
+ Assert.isNotNull(FlxG.cameras);
+
+ @Test function testPluginsNull():Void
+ Assert.isNotNull(FlxG.plugins);
+
+ #if FLX_SOUND_SYSTEM
+ @Test function testSoundNull():Void
+ Assert.isNotNull(FlxG.sound);
+ #end
+
+ @Test function testScaleModeNull():Void
+ Assert.isNotNull(FlxG.scaleMode);
+
+ @Test
+ function testDefaultWidth():Void
+ {
+ Assert.areEqual(640, FlxG.width);
+ }
+
+ @Test
+ function testDefaultHeight():Void
+ {
+ Assert.areEqual(480, FlxG.height);
+ }
+}
diff --git a/tests/unit/source/funkin/ConductorTest.hx b/tests/unit/source/funkin/ConductorTest.hx
new file mode 100644
index 000000000..856c52186
--- /dev/null
+++ b/tests/unit/source/funkin/ConductorTest.hx
@@ -0,0 +1,550 @@
+package funkin;
+
+import flixel.FlxG;
+import flixel.FlxState;
+import funkin.Conductor;
+import funkin.play.song.SongData.SongTimeChange;
+import funkin.util.Constants;
+import massive.munit.Assert;
+
+@:access(funkin.Conductor)
+class ConductorTest extends FunkinTest
+{
+ var conductorState:ConductorState;
+
+ @Before
+ function before()
+ {
+ resetGame();
+
+ // The ConductorState will advance the conductor when step() is called.
+ FlxG.switchState(conductorState = new ConductorState());
+
+ Conductor.reset();
+ }
+
+ @Test
+ function testDefaultValues():Void
+ {
+ // NOTE: Expected value comes first.
+
+ Assert.areEqual([], Conductor.timeChanges);
+ Assert.areEqual(null, Conductor.currentTimeChange);
+
+ Assert.areEqual(0, Conductor.songPosition);
+ Assert.areEqual(Constants.DEFAULT_BPM, Conductor.bpm);
+ Assert.areEqual(null, Conductor.bpmOverride);
+
+ Assert.areEqual(600, Conductor.beatLengthMs);
+
+ Assert.areEqual(4, Conductor.timeSignatureNumerator);
+ Assert.areEqual(4, Conductor.timeSignatureDenominator);
+
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ Assert.areEqual(0.0, Conductor.currentStepTime);
+
+ Assert.areEqual(150, Conductor.stepLengthMs);
+ }
+
+ /**
+ * Tests implementation of `update()`, and how it affects
+ * `currentBeat`, `currentStep`, `currentStepTime`, and the `beatHit` and `stepHit` signals.
+ */
+ @Test
+ function testUpdate():Void
+ {
+ Assert.areEqual(0, Conductor.songPosition);
+
+ step(); // 1
+
+ var BPM_100_STEP_TIME = 1 / 9;
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(1 / 9, Conductor.currentStepTime);
+
+ step(7); // 8
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 8, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(8 / 9, Conductor.currentStepTime);
+
+ Assert.areEqual(0, conductorState.beatsHit);
+ Assert.areEqual(0, conductorState.stepsHit);
+
+ step(); // 9
+
+ Assert.areEqual(0, conductorState.beatsHit);
+ Assert.areEqual(1, conductorState.stepsHit);
+ conductorState.beatsHit = 0;
+ conductorState.stepsHit = 0;
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 9, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(1, Conductor.currentStep);
+ FunkinAssert.areNear(1.0, Conductor.currentStepTime);
+
+ step(35 - 9); // 35
+
+ Assert.areEqual(0, conductorState.beatsHit);
+ Assert.areEqual(2, conductorState.stepsHit);
+ conductorState.beatsHit = 0;
+ conductorState.stepsHit = 0;
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 35, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(3, Conductor.currentStep);
+ FunkinAssert.areNear(3.0 + 8 / 9, Conductor.currentStepTime);
+
+ step(); // 36
+
+ Assert.areEqual(1, conductorState.beatsHit);
+ Assert.areEqual(1, conductorState.stepsHit);
+ conductorState.beatsHit = 0;
+ conductorState.stepsHit = 0;
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 36, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(4, Conductor.currentStep);
+ FunkinAssert.areNear(4.0, Conductor.currentStepTime);
+
+ step(50 - 36); // 50
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 50, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(5, Conductor.currentStep);
+ FunkinAssert.areNear(5.555555, Conductor.currentStepTime);
+
+ step(49); // 99
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 99, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(11, Conductor.currentStep);
+ FunkinAssert.areNear(11.0, Conductor.currentStepTime);
+
+ step(1); // 100
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 100, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(11, Conductor.currentStep);
+ FunkinAssert.areNear(11.111111, Conductor.currentStepTime);
+ }
+
+ @Test
+ function testUpdateForcedBPM():Void
+ {
+ Conductor.forceBPM(60);
+
+ Assert.areEqual(0, Conductor.songPosition);
+
+ // 60 beats per minute = 1 beat per second
+ // 1 beat per second = 1/60 beats per frame = 4/60 steps per frame
+ step(); // Advances time 1/60 of 1 second.
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(4 / 60, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
+
+ step(14 - 1); // 14
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 14, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(1.0 - 4 / 60, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
+
+ step(); // 15
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(1, Conductor.currentStep);
+ FunkinAssert.areNear(1.0, Conductor.currentStepTime); // 1/60 of 1 beat = 4/60 of 1 step
+
+ step(45 - 1); // 59
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(3, Conductor.currentStep);
+ FunkinAssert.areNear(4.0 - 4 / 60, Conductor.currentStepTime);
+
+ step(); // 60
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(4, Conductor.currentStep);
+ FunkinAssert.areNear(4.0, Conductor.currentStepTime);
+
+ step(); // 61
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(4, Conductor.currentStep);
+ FunkinAssert.areNear(4.0 + 4 / 60, Conductor.currentStepTime);
+ }
+
+ @Test
+ function testSingleTimeChange():Void
+ {
+ // Start the song with a BPM of 120.
+ var songTimeChanges:Array = [
+ {
+ t: 0,
+ b: 0,
+ bpm: 120,
+ n: 4,
+ d: 4,
+ bt: [4, 4, 4, 4]
+ }, // 120 bpm starting 0 sec/0 beats
+ ];
+ Conductor.mapTimeChanges(songTimeChanges);
+
+ // All should be at 0.
+ FunkinAssert.areNear(0, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+
+ // 120 beats per minute = 2 beat per second
+ // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame
+ step(); // Advances time 1/60 of 1 second.
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+
+ step(15 - 1); // 15
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 15, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(2, Conductor.currentStep);
+ FunkinAssert.areNear(2.0, Conductor.currentStepTime); // 2/60 of 1 beat = 8/60 of 1 step
+
+ step(45 - 1); // 59
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(7, Conductor.currentStep);
+ FunkinAssert.areNear(7.0 + 104 / 120, Conductor.currentStepTime);
+
+ step(); // 60
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(8, Conductor.currentStep);
+ FunkinAssert.areNear(8.0, Conductor.currentStepTime);
+
+ step(); // 61
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(8, Conductor.currentStep);
+ FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime);
+ }
+
+ @Test
+ function testDoubleTimeChange():Void
+ {
+ // Start the song with a BPM of 120.
+ var songTimeChanges:Array = [
+ {
+ t: 0,
+ b: 0,
+ bpm: 120,
+ n: 4,
+ d: 4,
+ bt: [4, 4, 4, 4]
+ }, // 120 bpm starting 0 sec/0 beats
+ {
+ t: 3000,
+ b: 6,
+ bpm: 90,
+ n: 4,
+ d: 4,
+ bt: [4, 4, 4, 4]
+ } // 90 bpm starting 3 sec/6 beats
+ ];
+ Conductor.mapTimeChanges(songTimeChanges);
+
+ // All should be at 0.
+ FunkinAssert.areNear(0, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+
+ // 120 beats per minute = 2 beat per second
+ // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame
+ step(); // Advances time 1/60 of 1 second.
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step
+
+ step(60 - 1 - 1); // 59
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(7, Conductor.currentStep);
+ FunkinAssert.areNear(7.0 + 104 / 120, Conductor.currentStepTime);
+
+ step(); // 60
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(8, Conductor.currentStep);
+ FunkinAssert.areNear(8.0, Conductor.currentStepTime);
+
+ step(); // 61
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(8, Conductor.currentStep);
+ FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime);
+
+ step(179 - 61); // 179
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.songPosition);
+ Assert.areEqual(5, Conductor.currentBeat);
+ Assert.areEqual(23, Conductor.currentStep);
+ FunkinAssert.areNear(23.0 + 52 / 60, Conductor.currentStepTime);
+
+ step(); // 180 (3 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.songPosition);
+ Assert.areEqual(6, Conductor.currentBeat);
+ Assert.areEqual(24, Conductor.currentStep);
+ FunkinAssert.areNear(24.0, Conductor.currentStepTime);
+
+ step(); // 181 (3 + 1/60 seconds)
+ // BPM has switched to 90!
+ // 90 beats per minute = 1.5 beat per second
+ // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame
+ // = 12/120 steps per frame
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.songPosition);
+ Assert.areEqual(6, Conductor.currentBeat);
+ Assert.areEqual(24, Conductor.currentStep);
+ FunkinAssert.areNear(24.0 + 12 / 120, Conductor.currentStepTime);
+
+ step(59); // 240 (4 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.songPosition);
+ Assert.areEqual(7, Conductor.currentBeat);
+ Assert.areEqual(30, Conductor.currentStep);
+ FunkinAssert.areNear(30.0, Conductor.currentStepTime);
+
+ step(); // 241 (4 + 1/60 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.songPosition);
+ Assert.areEqual(7, Conductor.currentBeat);
+ Assert.areEqual(30, Conductor.currentStep);
+ FunkinAssert.areNear(30.0 + 12 / 120, Conductor.currentStepTime);
+ }
+
+ @Test
+ function testTripleTimeChange():Void
+ {
+ // Start the song with a BPM of 120, then move to 90, then move to 180.
+ var songTimeChanges:Array = [
+ {
+ t: 0,
+ b: null,
+ bpm: 120,
+ n: 4,
+ d: 4,
+ bt: [4, 4, 4, 4]
+ }, // 120 bpm starting 0 sec/0 beats
+ {
+ t: 3000,
+ b: null,
+ bpm: 90,
+ n: 4,
+ d: 4,
+ bt: [4, 4, 4, 4]
+ }, // 90 bpm starting 3 sec/6 beats
+ {
+ t: 6000,
+ b: null,
+ bpm: 180,
+ n: 4,
+ d: 4,
+ bt: [4, 4, 4, 4]
+ } // 90 bpm starting 3 sec/6 beats
+ ];
+ Conductor.mapTimeChanges(songTimeChanges);
+
+ // Verify time changes.
+ Assert.areEqual(3, Conductor.timeChanges.length);
+ FunkinAssert.areNear(0, Conductor.timeChanges[0].beatTime);
+ FunkinAssert.areNear(6, Conductor.timeChanges[1].beatTime);
+ FunkinAssert.areNear(10.5, Conductor.timeChanges[2].beatTime);
+
+ // All should be at 0.
+ FunkinAssert.areNear(0, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(0.0, Conductor.currentStepTime); // 2/120 of 1 beat = 8/120 of 1 step
+
+ // 120 beats per minute = 2 beat per second
+ // 2 beat per second = 2/60 beats per frame = 16/120 steps per frame
+ step(); // Advances time 1/60 of 1 second.
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 1, Conductor.songPosition);
+ Assert.areEqual(0, Conductor.currentBeat);
+ Assert.areEqual(0, Conductor.currentStep);
+ FunkinAssert.areNear(16 / 120, Conductor.currentStepTime); // 4/120 of 1 beat = 16/120 of 1 step
+
+ step(60 - 1 - 1); // 59
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 59, Conductor.songPosition);
+ Assert.areEqual(1, Conductor.currentBeat);
+ Assert.areEqual(7, Conductor.currentStep);
+ FunkinAssert.areNear(7 + 104 / 120, Conductor.currentStepTime);
+
+ step(); // 60
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 60, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(8, Conductor.currentStep);
+ FunkinAssert.areNear(8.0, Conductor.currentStepTime);
+
+ step(); // 61
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 61, Conductor.songPosition);
+ Assert.areEqual(2, Conductor.currentBeat);
+ Assert.areEqual(8, Conductor.currentStep);
+ FunkinAssert.areNear(8.0 + 8 / 60, Conductor.currentStepTime);
+
+ step(179 - 61); // 179
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 179, Conductor.songPosition);
+ Assert.areEqual(5, Conductor.currentBeat);
+ Assert.areEqual(23, Conductor.currentStep);
+ FunkinAssert.areNear(23.0 + 52 / 60, Conductor.currentStepTime);
+
+ step(); // 180 (3 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 180, Conductor.songPosition);
+ Assert.areEqual(6, Conductor.currentBeat);
+ Assert.areEqual(24, Conductor.currentStep); // 23.999 => 24
+ FunkinAssert.areNear(24.0, Conductor.currentStepTime);
+
+ step(); // 181 (3 + 1/60 seconds)
+ // BPM has switched to 90!
+ // 90 beats per minute = 1.5 beat per second
+ // 1.5 beat per second = 1.5/60 beats per frame = 3/120 beats per frame
+ // = 12/120 steps per frame
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 181, Conductor.songPosition);
+ Assert.areEqual(6, Conductor.currentBeat);
+ Assert.areEqual(24, Conductor.currentStep);
+ FunkinAssert.areNear(24.0 + 12 / 120, Conductor.currentStepTime);
+
+ step(60 - 1 - 1); // 240 (4 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 239, Conductor.songPosition);
+ Assert.areEqual(7, Conductor.currentBeat);
+ Assert.areEqual(29, Conductor.currentStep);
+ FunkinAssert.areNear(29.0 + 108 / 120, Conductor.currentStepTime);
+
+ step(); // 240 (4 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 240, Conductor.songPosition);
+ Assert.areEqual(7, Conductor.currentBeat);
+ Assert.areEqual(30, Conductor.currentStep);
+ FunkinAssert.areNear(30.0, Conductor.currentStepTime);
+
+ step(); // 241 (4 + 1/60 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 241, Conductor.songPosition);
+ Assert.areEqual(7, Conductor.currentBeat);
+ Assert.areEqual(30, Conductor.currentStep);
+ FunkinAssert.areNear(30.0 + 12 / 120, Conductor.currentStepTime);
+
+ step(359 - 241); // 359 (5 + 59/60 seconds)
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 359, Conductor.songPosition);
+ Assert.areEqual(10, Conductor.currentBeat);
+ Assert.areEqual(41, Conductor.currentStep);
+ FunkinAssert.areNear(41 + 108 / 120, Conductor.currentStepTime);
+
+ step(); // 360
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 360, Conductor.songPosition);
+ Assert.areEqual(10, Conductor.currentBeat);
+ Assert.areEqual(42, Conductor.currentStep); // 41.999
+ FunkinAssert.areNear(42.0, Conductor.currentStepTime);
+
+ step(); // 361
+ // BPM has switched to 180!
+ // 180 beats per minute = 3 beat per second
+ // 3 beat per second = 3/60 beats per frame
+ // = 12/60 steps per frame
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 361, Conductor.songPosition);
+ Assert.areEqual(10, Conductor.currentBeat);
+ Assert.areEqual(42, Conductor.currentStep);
+ FunkinAssert.areNear(42.0 + 12 / 60, Conductor.currentStepTime);
+
+ step(); // 362
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 362, Conductor.songPosition);
+ Assert.areEqual(10, Conductor.currentBeat);
+ Assert.areEqual(42, Conductor.currentStep);
+ FunkinAssert.areNear(42.0 + 24 / 60, Conductor.currentStepTime);
+
+ step(3); // 365
+
+ FunkinAssert.areNear(FunkinTest.MS_PER_STEP * 365, Conductor.songPosition);
+ Assert.areEqual(10, Conductor.currentBeat);
+ Assert.areEqual(43, Conductor.currentStep); // 42.999 => 42
+ FunkinAssert.areNear(43.0, Conductor.currentStepTime);
+ }
+}
+
+class ConductorState extends FlxState
+{
+ public var beatsHit:Int = 0;
+ public var stepsHit:Int = 0;
+
+ public function new()
+ {
+ super();
+ }
+
+ function beatHit():Void
+ {
+ beatsHit += 1;
+ }
+
+ function stepHit():Void
+ {
+ stepsHit += 1;
+ }
+
+ public override function create():Void
+ {
+ super.create();
+ Conductor.beatHit.add(this.beatHit);
+ Conductor.stepHit.add(this.stepHit);
+ }
+
+ public override function destroy():Void
+ {
+ super.destroy();
+ Conductor.beatHit.remove(this.beatHit);
+ Conductor.stepHit.remove(this.stepHit);
+ }
+
+ public override function update(elapsed:Float)
+ {
+ super.update(elapsed);
+
+ // On each step, increment the Conductor as though the song was playing.
+ Conductor.update(Conductor.songPosition + elapsed * Constants.MS_PER_SEC);
+ }
+}
diff --git a/tests/unit/source/funkin/data/BaseRegistryTest.hx b/tests/unit/source/funkin/data/BaseRegistryTest.hx
new file mode 100644
index 000000000..3532c4403
--- /dev/null
+++ b/tests/unit/source/funkin/data/BaseRegistryTest.hx
@@ -0,0 +1,228 @@
+package funkin.data;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.data.BaseRegistry;
+import funkin.util.SortUtil;
+import funkin.util.VersionUtil;
+
+@:access(funkin.data.BaseRegistry)
+class BaseRegistryTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testMyTypeRegistry()
+ {
+ // This shouldn't crash.
+ MyTypeRegistry.instance.loadEntries();
+
+ // Ensure all entries were loaded.
+ var entryList = MyTypeRegistry.instance.listEntryIds();
+ entryList.sort(SortUtil.alphabetically);
+
+ Assert.areEqual(entryList, [
+ "blablabla",
+ "fizzbuzz",
+ "foobar",
+ // "junk"
+ ]);
+
+ // Ensure this one is not in the list.
+ Assert.areEqual(entryList.indexOf("junk"), -1);
+
+ // Ensure blablabla got parsed correctly.
+ var blablabla = MyTypeRegistry.instance.fetchEntry("blablabla");
+ Assert.areEqual(blablabla.id, "blablabla");
+ Assert.areEqual(blablabla._data.version, "1.0.0");
+ Assert.areEqual(blablabla._data.name, "blablabla API");
+ }
+}
+
+typedef MyTypeData =
+{
+ /**
+ * The version number of the data schema.
+ * When making changes to the note style data format, this should be incremented,
+ * and a migration function should be added to handle old versions.
+ */
+ @:default(funkin.data.BaseRegistryTest.MyTypeRegistry.DATA_VERSION)
+ var version:String;
+
+ var id:String;
+ var name:String;
+ var data:Array;
+}
+
+typedef MySubTypeData =
+{
+ var foo:String;
+ var bar:String;
+};
+
+typedef MyTypeData_v0_1_x =
+{
+ var version:String;
+ var id:Int;
+ var name:String;
+};
+
+class MyType implements IRegistryEntry
+{
+ /**
+ * The ID of the mytype.
+ */
+ public final id:String;
+
+ /**
+ * Mytype data as parsed from the JSON file.
+ */
+ public final _data:MyTypeData;
+
+ /**
+ * @param id The ID of the JSON file to parse.
+ */
+ public function new(id:String)
+ {
+ this.id = id;
+ _data = _fetchData(id);
+
+ if (_data == null)
+ {
+ throw 'Could not parse mytype data for id: $id';
+ }
+ }
+
+ public function destroy():Void {}
+
+ public function toString():String
+ {
+ return 'MyType($id)';
+ }
+
+ public function _fetchData(id:String):Null
+ {
+ return MyTypeRegistry.instance.parseEntryDataWithMigration(id, MyTypeRegistry.instance.fetchEntryVersion(id));
+ }
+
+ public function getSubData():Array
+ {
+ return _data.data;
+ }
+}
+
+class MyTypeRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the note style data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migrateMyTypeData()` function.
+ */
+ public static final DATA_VERSION:String = "1.0.0";
+
+ public static final instance:MyTypeRegistry = new MyTypeRegistry();
+
+ public function new()
+ {
+ super('MYTYPE', 'mytype');
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ var jsonStr:String = loadEntryFile(id);
+
+ parser.fromJson(jsonStr);
+
+ if (parser.errors.length > 0)
+ {
+ trace('[${registryId}] Failed to parse entry data: ${id}');
+ for (error in parser.errors)
+ {
+ trace(error);
+ }
+ return null;
+ }
+ return parser.value;
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData_v0_1_x(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ var jsonStr:String = loadEntryFile(id);
+
+ parser.fromJson(jsonStr);
+
+ if (parser.errors.length > 0)
+ {
+ trace('[${registryId}] Failed to parse entry data: ${id}');
+ for (error in parser.errors)
+ {
+ trace(error);
+ }
+ return null;
+ }
+
+ var oldData:MyTypeData_v0_1_x = parser.value;
+
+ var result:MyTypeData =
+ {
+ version: DATA_VERSION,
+ id: '${oldData.id}',
+ name: oldData.name,
+ data: []
+ };
+
+ return result;
+ }
+
+ public override function parseEntryDataWithMigration(id:String, version:thx.semver.Version):Null
+ {
+ if (VersionUtil.validateVersion(version, "0.1.x"))
+ {
+ trace('Migrating mytype data from ${version} to ${DATA_VERSION}');
+ return parseEntryData_v0_1_x(id);
+ }
+ else
+ {
+ trace('Parsing mytype data with version ${version}');
+ return super.parseEntryDataWithMigration(id, version);
+ }
+ }
+
+ function createScriptedEntry(clsName:String):MyType
+ {
+ return null;
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return [];
+ }
+}
diff --git a/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
new file mode 100644
index 000000000..ec33b37d7
--- /dev/null
+++ b/tests/unit/source/funkin/data/notestyle/NoteStyleRegistryTest.hx
@@ -0,0 +1,90 @@
+package funkin.data.notestyle;
+
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notestyle.NoteStyle;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import massive.munit.util.Timer;
+
+@:access(funkin.play.notes.notestyle.NoteStyle)
+@:access(funkin.data.notestyle.NoteStyleRegistry)
+class NoteStyleRegistryTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass()
+ {
+ NoteStyleRegistry.instance.loadEntries();
+ }
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testValid()
+ {
+ Assert.isNotNull(NoteStyleRegistry.instance);
+ }
+
+ @Test
+ public function testParseEntryData()
+ {
+ var result:NoteStyleData = NoteStyleRegistry.instance.parseEntryData("test2");
+
+ Assert.areEqual(result.version, "1.0.0");
+ Assert.areEqual(result.name, "Test2");
+ Assert.areEqual(result.author, "Eric");
+ Assert.areEqual(result.fallback, "funkin");
+
+ Assert.areEqual(result.assets.note.assetPath, "shared:coolstuff");
+ Assert.areEqual(result.assets.note.scale, 1.8);
+ Assert.areEqual(result.assets.note.data.left.prefix, "noteLeft1");
+ Assert.areEqual(result.assets.note.data.down.prefix, "noteDown3");
+ Assert.areEqual(result.assets.note.data.up.prefix, "noteUp2");
+ Assert.areEqual(result.assets.note.data.right.prefix, "noteRight4");
+ }
+
+ @Test
+ public function testFetchEntry()
+ {
+ var result:NoteStyle = NoteStyleRegistry.instance.fetchEntry("test2");
+
+ Assert.areEqual(result.toString(), "NoteStyle(test2)");
+ Assert.areEqual(result.getName(), "Test2");
+ Assert.areEqual(result.getAuthor(), "Eric");
+ Assert.areEqual(result.getFallbackID(), "funkin");
+ }
+
+ @Test
+ public function testFetchBadEntry()
+ {
+ var result:NoteStyle = NoteStyleRegistry.instance.fetchEntry("blablabla");
+
+ Assert.areEqual(result, null);
+ }
+
+ @Test
+ public function testFetchDefault()
+ {
+ var nsrMock:NoteStyleRegistry = mock(NoteStyleRegistry);
+
+ nsrMock.fetchDefault().callsRealMethod();
+
+ // Perform the call.
+ nsrMock.fetchDefault();
+
+ // Verify the underlying call.
+
+ nsrMock.fetchEntry(NoteStyleRegistry.DEFAULT_NOTE_STYLE_ID).verify(times(1));
+ }
+}
diff --git a/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
new file mode 100644
index 000000000..31e8e38ae
--- /dev/null
+++ b/tests/unit/source/funkin/play/notes/notestyle/NoteStyleTest.hx
@@ -0,0 +1,77 @@
+package funkin.play.notes.notestyle;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notestyle.NoteStyle;
+import flixel.animation.FlxAnimationController;
+
+@:access(funkin.play.notes.notestyle.NoteStyle)
+class NoteStyleTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass()
+ {
+ NoteStyleRegistry.instance.loadEntries();
+ }
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ @Ignore("This test doesn't work, crashes when the project has 2 mocks of the same class???")
+ public function testBuildNoteSprite()
+ {
+ var target:NoteStyle = NoteStyleRegistry.instance.fetchEntry("funkin");
+
+ var mockNoteSprite:NoteSprite = mock(NoteSprite);
+ // var mockAnim = mock(FlxAnimationController);
+ // mockNoteSprite.animation = mockAnim;
+
+ target.buildNoteSprite(mockNoteSprite);
+
+ Assert.areEqual(mockNoteSprite.frames, []);
+ }
+
+ @Test
+ public function testFallbackBehavior()
+ {
+ var target1:NoteStyle = NoteStyleRegistry.instance.fetchEntry("funkin");
+ var target2:NoteStyle = NoteStyleRegistry.instance.fetchEntry("test2");
+
+ Assert.areEqual("funkin", target1.id);
+ Assert.areEqual("test2", target2.id);
+
+ Assert.areEqual("Funkin'", target1.getName());
+ Assert.areEqual("Test2", target2.getName());
+
+ Assert.isNull(target1.getFallbackID());
+ Assert.areEqual(target1.id, target2.getFallbackID());
+
+ // Overridden fields are different.
+ Assert.areEqual("arrows", target1.getNoteAssetPath(false));
+ Assert.areEqual("coolstuff", target2.getNoteAssetPath(false));
+
+ Assert.areEqual("shared:arrows", target1.getNoteAssetPath(true));
+ Assert.areEqual("shared:coolstuff", target2.getNoteAssetPath(true));
+
+ // Unspecified fields use the fallback.
+ // Should NOT return null!
+ Assert.areEqual("assets/images/NOTE_hold_assets.png", target1.getHoldNoteAssetPath(false));
+ Assert.areEqual("assets/images/NOTE_hold_assets.png", target2.getHoldNoteAssetPath(false));
+
+ Assert.areEqual("NOTE_hold_assets", target1.getHoldNoteAssetPath(true));
+ }
+}
diff --git a/tests/unit/source/funkin/util/BezierUtilTest.hx b/tests/unit/source/funkin/util/BezierUtilTest.hx
new file mode 100644
index 000000000..90186c111
--- /dev/null
+++ b/tests/unit/source/funkin/util/BezierUtilTest.hx
@@ -0,0 +1,137 @@
+package funkin.util;
+
+import flixel.math.FlxPoint;
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.BezierUtil;
+
+@:access(funkin.util.BezierUtil)
+class BezierUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testBezier2()
+ {
+ var point1:FlxPoint = FlxPoint.get(1, 1);
+ var point2:FlxPoint = FlxPoint.get(2, 2);
+
+ var result:FlxPoint = BezierUtil.bezier2(0.5, point1, point2);
+
+ Assert.areEqual(result.x, 1.5);
+ Assert.areEqual(result.y, 1.5);
+
+ result = BezierUtil.bezier2(0.25, point1, point2);
+
+ Assert.areEqual(result.x, 1.25);
+ Assert.areEqual(result.y, 1.25);
+
+ result = BezierUtil.bezier2(0.75, point1, point2);
+
+ Assert.areEqual(result.x, 1.75);
+ Assert.areEqual(result.y, 1.75);
+ }
+
+ @Test
+ public function testBezier3()
+ {
+ var point1:FlxPoint = FlxPoint.get(1, 1);
+ var point2:FlxPoint = FlxPoint.get(2, 2);
+ var point3:FlxPoint = FlxPoint.get(3, 3);
+
+ var result:FlxPoint = BezierUtil.bezier3(0.5, point1, point2, point3);
+
+ Assert.areEqual(result.x, 2);
+ Assert.areEqual(result.y, 2);
+
+ result = BezierUtil.bezier3(0.25, point1, point2, point3);
+
+ Assert.areEqual(result.x, 1.5);
+ Assert.areEqual(result.y, 1.5);
+
+ result = BezierUtil.bezier3(0.75, point1, point2, point3);
+
+ Assert.areEqual(result.x, 2.5);
+ Assert.areEqual(result.y, 2.5);
+
+ result = BezierUtil.bezier3(0.5, point1, point2, point3);
+
+ Assert.areEqual(result.x, 2);
+ Assert.areEqual(result.y, 2);
+
+ result = BezierUtil.bezier3(0.6, point1, point2, point3);
+
+ Assert.areEqual(result.x, 2.2);
+ Assert.areEqual(result.y, 2.2);
+ }
+
+ @Test
+ public function testBezier4()
+ {
+ var point1:FlxPoint = FlxPoint.get(1, 1);
+ var point2:FlxPoint = FlxPoint.get(2, 2);
+ var point3:FlxPoint = FlxPoint.get(3, 3);
+ var point4:FlxPoint = FlxPoint.get(4, 4);
+
+ var result:FlxPoint = BezierUtil.bezier4(0.5, point1, point2, point3, point4);
+
+ Assert.areEqual(result.x, 2.5);
+ Assert.areEqual(result.y, 2.5);
+
+ result = BezierUtil.bezier4(0.25, point1, point2, point3, point4);
+
+ Assert.areEqual(result.x, 1.75);
+ Assert.areEqual(result.y, 1.75);
+
+ result = BezierUtil.bezier4(0.75, point1, point2, point3, point4);
+
+ Assert.areEqual(result.x, 3.25);
+ Assert.areEqual(result.y, 3.25);
+ }
+
+ @Test
+ public function testBezier5()
+ {
+ var point1:FlxPoint = FlxPoint.get(1, 1);
+ var point2:FlxPoint = FlxPoint.get(2, 2);
+ var point3:FlxPoint = FlxPoint.get(3, 3);
+ var point4:FlxPoint = FlxPoint.get(4, 4);
+ var point5:FlxPoint = FlxPoint.get(5, 5);
+
+ var result:FlxPoint = BezierUtil.bezier5(0.5, point1, point2, point3, point4, point5);
+
+ Assert.areEqual(result.x, 3);
+ Assert.areEqual(result.y, 3);
+
+ result = BezierUtil.bezier5(0.25, point1, point2, point3, point4, point5);
+
+ Assert.areEqual(result.x, 2);
+ Assert.areEqual(result.y, 2);
+
+ result = BezierUtil.bezier5(0.75, point1, point2, point3, point4, point5);
+
+ Assert.areEqual(result.x, 4);
+ Assert.areEqual(result.y, 4);
+
+ result = BezierUtil.bezier5(0.5, point1, point2, point3, point4, point5);
+
+ Assert.areEqual(result.x, 3);
+ Assert.areEqual(result.y, 3);
+ }
+}
diff --git a/tests/unit/source/funkin/util/ClipboardUtilTest.hx b/tests/unit/source/funkin/util/ClipboardUtilTest.hx
new file mode 100644
index 000000000..9efd96d34
--- /dev/null
+++ b/tests/unit/source/funkin/util/ClipboardUtilTest.hx
@@ -0,0 +1,71 @@
+package funkin.util;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.ClipboardUtil;
+
+@:access(funkin.util.ClipboardUtil)
+class ClipboardUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testGetSetClipboard()
+ {
+ var testString = "test string";
+ ClipboardUtil.setClipboard(testString);
+ var clipboardString = ClipboardUtil.getClipboard();
+ Assert.areEqual(testString, clipboardString);
+ }
+
+ @Ignore("This test doesn't work, Lime issue?")
+ @Test
+ public function testAddRemoveListener()
+ {
+ ClipboardUtil.addListener(onClipboardChange);
+
+ var testString = "test string";
+ ClipboardUtil.setClipboard(testString);
+
+ var clipboardString = ClipboardUtil.getClipboard();
+ Assert.areEqual(testString, clipboardString);
+
+ step();
+
+ // TODO: Fix issue where this test fails
+ Assert.areEqual(1, count);
+
+ ClipboardUtil.removeListener(onClipboardChange);
+
+ var testString2 = "test string 2";
+ ClipboardUtil.setClipboard(testString2);
+
+ var clipboardString2 = ClipboardUtil.getClipboard();
+ Assert.areEqual(testString2, clipboardString2);
+
+ Assert.areEqual(1, count);
+ }
+
+ var count:Int = 0;
+
+ function onClipboardChange()
+ {
+ count += 1;
+ }
+}
diff --git a/tests/unit/source/funkin/util/DateUtilTest.hx b/tests/unit/source/funkin/util/DateUtilTest.hx
new file mode 100644
index 000000000..c8adb3824
--- /dev/null
+++ b/tests/unit/source/funkin/util/DateUtilTest.hx
@@ -0,0 +1,35 @@
+package funkin.util;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.DateUtil;
+
+@:access(funkin.util.DateUtil)
+class DateUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testGenerateTimestamp()
+ {
+ var date:Date = new Date(2020, 10 - 1, 31, 3, 0, 0);
+ var timestamp:String = DateUtil.generateTimestamp(date);
+ Assert.areEqual("2020-10-31-03-00-00", timestamp);
+ }
+}
diff --git a/tests/unit/source/funkin/util/SerializerUtilTest.hx b/tests/unit/source/funkin/util/SerializerUtilTest.hx
new file mode 100644
index 000000000..d2adc3350
--- /dev/null
+++ b/tests/unit/source/funkin/util/SerializerUtilTest.hx
@@ -0,0 +1,87 @@
+package funkin.util;
+
+import haxe.io.Bytes;
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.SerializerUtil;
+
+typedef FooBar =
+{
+ a:Int,
+ b:Int,
+ c:Int
+};
+
+@:access(funkin.util.SerializerUtil)
+class SerializerUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testToJSON()
+ {
+ var object = {a: 1, b: 2, c: 3};
+ var json = SerializerUtil.toJSON(object);
+ Assert.areEqual('{' + '\n\t"a": 1,' + '\n\t"b": 2,' + '\n\t"c": 3' + '\n}', json);
+ }
+
+ @Test
+ public function testFromJSON()
+ {
+ var json = '{' + '\n\t"a": 1,' + '\n\t"b": 2,' + '\n\t"c": 3' + '\n}';
+ var object = SerializerUtil.fromJSON(json);
+ Assert.areEqual(1, object.a);
+ Assert.areEqual(2, object.b);
+ Assert.areEqual(3, object.c);
+ }
+
+ @Test
+ public function testFromJSONBytes()
+ {
+ var json = '{' + '\n\t"a": 1,' + '\n\t"b": 2,' + '\n\t"c": 3' + '\n}';
+ var bytes = Bytes.ofString(json);
+
+ var object = SerializerUtil.fromJSONBytes(bytes);
+ Assert.areEqual(1, object.a);
+ Assert.areEqual(2, object.b);
+ Assert.areEqual(3, object.c);
+ }
+
+ @Test
+ public function testReplacer()
+ {
+ var version:thx.semver.Version = '1.0.0-beta';
+
+ Assert.areEqual(1, version.major);
+ Assert.areEqual(0, version.minor);
+ Assert.areEqual(0, version.patch);
+ Assert.areEqual(true, version.hasPre);
+ Assert.areEqual('beta', version.pre);
+ // Assert.areEqual(false, version.hasBuild);
+ Assert.areEqual('', version.build);
+
+ var formatted = SerializerUtil.replacer('version', version);
+
+ Assert.areEqual('1.0.0-beta', formatted);
+
+ var test2 = SerializerUtil.toJSON({version: version});
+
+ Assert.areEqual('{' + '\n\t"version": "1.0.0-beta"' + '\n}', test2);
+ }
+}
diff --git a/tests/unit/source/funkin/util/SortUtilTest.hx b/tests/unit/source/funkin/util/SortUtilTest.hx
new file mode 100644
index 000000000..4720c3da6
--- /dev/null
+++ b/tests/unit/source/funkin/util/SortUtilTest.hx
@@ -0,0 +1,63 @@
+package funkin.util;
+
+import flixel.FlxObject;
+import flixel.FlxSprite;
+import flixel.util.FlxSort;
+import funkin.play.song.SongData.SongNoteData;
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.SortUtil;
+
+@:access(funkin.util.SortUtil)
+class SortUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testAlphabetically()
+ {
+ var arr:Array = ["b", "a", "c"];
+
+ arr.sort(SortUtil.alphabetically);
+
+ Assert.areEqual(["a", "b", "c"], arr);
+ }
+
+ @Test
+ public function testByZIndex()
+ {
+ var arr:Array = [new FlxSprite(), new FlxObject(), new FlxSprite()];
+
+ arr[0].zIndex = 2000;
+ arr[1].zIndex = 1000;
+ arr[2].zIndex = 3000;
+
+ arr.sort(SortUtil.byZIndex.bind(FlxSort.ASCENDING));
+
+ Assert.areEqual(1000, arr[0].zIndex);
+ Assert.areEqual(2000, arr[1].zIndex);
+ Assert.areEqual(3000, arr[2].zIndex);
+
+ arr.sort(SortUtil.byZIndex.bind(FlxSort.DESCENDING));
+
+ Assert.areEqual(3000, arr[0].zIndex);
+ Assert.areEqual(2000, arr[1].zIndex);
+ Assert.areEqual(1000, arr[2].zIndex);
+ }
+}
diff --git a/tests/unit/source/funkin/util/VersionUtilTest.hx b/tests/unit/source/funkin/util/VersionUtilTest.hx
new file mode 100644
index 000000000..55848955a
--- /dev/null
+++ b/tests/unit/source/funkin/util/VersionUtilTest.hx
@@ -0,0 +1,64 @@
+package funkin.util;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.VersionUtil;
+
+@:access(funkin.util.VersionUtil)
+class VersionUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testValidateVersionStr()
+ {
+ Assert.areEqual(true, VersionUtil.validateVersionStr("1.0.0", "1.0.0"));
+
+ Assert.areEqual(false, VersionUtil.validateVersionStr("ehe", "test"));
+ }
+
+ @Test
+ public function testValidateVersion()
+ {
+ var version:thx.semver.Version = "1.0.0"; // implicit cast
+ var versionRule:thx.semver.VersionRule = "1.0.0"; // implicit cast
+
+ Assert.areEqual(true, VersionUtil.validateVersion(version, versionRule));
+
+ var versionRule2:thx.semver.VersionRule = ">=3.1.0"; // implicit cast
+
+ var version1:thx.semver.Version = "3.0.0";
+ var version2:thx.semver.Version = "3.1.1";
+ var version3:thx.semver.Version = "4.2.0";
+
+ Assert.areEqual(false, VersionUtil.validateVersion(version1, versionRule2));
+ Assert.areEqual(true, VersionUtil.validateVersion(version2, versionRule2));
+ Assert.areEqual(true, VersionUtil.validateVersion(version3, versionRule2));
+ }
+
+ @Test
+ public function testGetVersionFromJSON()
+ {
+ var jsonStr:String = "{ \"version\": \"3.1.0\" }";
+
+ var version:thx.semver.Version = VersionUtil.getVersionFromJSON(jsonStr);
+
+ Assert.areEqual("3.1.0", version.toString());
+ }
+}
diff --git a/tests/unit/source/funkin/util/assets/DataAssetsTest.hx b/tests/unit/source/funkin/util/assets/DataAssetsTest.hx
new file mode 100644
index 000000000..7612d4706
--- /dev/null
+++ b/tests/unit/source/funkin/util/assets/DataAssetsTest.hx
@@ -0,0 +1,42 @@
+package funkin.util.assets;
+
+import openfl.utils.Assets;
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.assets.DataAssets;
+
+@:access(funkin.util.assets.DataAssets)
+class DataAssetsTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testBuildDataPath()
+ {
+ Assert.areEqual('assets/data/test.json', DataAssets.buildDataPath('test.json'));
+ }
+
+ @Test
+ public function listDataFilesInPath()
+ {
+ var expected = ['blablabla', 'test1', 'test2'];
+
+ Assert.areEqual(expected, DataAssets.listDataFilesInPath('test/'));
+ }
+}
diff --git a/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
new file mode 100644
index 000000000..990d998f2
--- /dev/null
+++ b/tests/unit/source/funkin/util/assets/FlxAnimationUtilTest.hx
@@ -0,0 +1,118 @@
+package funkin.util.assets;
+
+import funkin.util.assets.FlxAnimationUtil;
+import flixel.animation.FlxAnimationController;
+import funkin.data.animation.AnimationData;
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.DateUtil;
+import flixel.FlxSprite;
+
+@:access(funkin.util.assets.FlxAnimationUtil)
+class FlxAnimationUtilTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testAddAtlasAnimation()
+ {
+ // Build a mock child class of FlxSprite
+ var mockSprite = mock(FlxSprite);
+ var mockAnim = mock(FlxAnimationController);
+ mockSprite.animation = mockAnim;
+
+ var animData:AnimationData =
+ {
+ name: "testAnim",
+ prefix: "blablabla"
+ };
+
+ FlxAnimationUtil.addAtlasAnimation(mockSprite, animData);
+
+ // Verify that the method was called once.
+ // If not, a VerificationException will be thrown and the test will fail.
+ mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1));
+
+ // Verify there were no other functions called.
+ mockAnim.verifyZeroInteractions();
+ mockSprite.verifyZeroInteractions();
+
+ var animData2:AnimationData =
+ {
+ name: "testAnim2",
+ prefix: "blablabla2",
+ frameIndices: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
+ frameRate: 12,
+ flipX: true,
+ flipY: true,
+ looped: true
+ };
+
+ FlxAnimationUtil.addAtlasAnimation(mockSprite, animData2);
+
+ try
+ {
+ mockAnim.addByIndices("testAnim2", "blablabla2", cast anyIterator, "", 12, true, true, true).verify(times(1));
+ }
+ catch (e)
+ {
+ trace('CAUGHT EXCEPTION');
+ trace(e);
+ }
+
+ mockAnim.verifyZeroInteractions();
+ mockSprite.verifyZeroInteractions();
+ }
+
+ @Test
+ public function testAddAtlasAnimations()
+ {
+ // Build a mock child class of FlxSprite
+ var mockSprite = mock(FlxSprite);
+ var mockAnim = mock(FlxAnimationController);
+ mockSprite.animation = mockAnim;
+
+ var animData:Array = [
+ {
+ name: "testAnim",
+ prefix: "blablabla"
+ },
+ {
+ name: "testAnim2",
+ prefix: "blablabla2"
+ },
+ {
+ name: "testAnim3",
+ prefix: "blablabla3"
+ }
+ ];
+
+ FlxAnimationUtil.addAtlasAnimations(mockSprite, animData);
+
+ // Verify that the method was called once.
+ // If not, a VerificationException will be thrown and the test will fail.
+ mockAnim.addByPrefix("testAnim", "blablabla", 24, false, false, false).verify(times(1));
+ mockAnim.addByPrefix("testAnim2", "blablabla2", 24, false, false, false).verify(times(1));
+ mockAnim.addByPrefix("testAnim3", "blablabla3", 24, false, false, false).verify(times(1));
+
+ // Verify there were no other functions called.
+ mockAnim.verifyZeroInteractions();
+ mockSprite.verifyZeroInteractions();
+ }
+}
diff --git a/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx b/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx
new file mode 100644
index 000000000..0bcef00b5
--- /dev/null
+++ b/tests/unit/source/funkin/util/tools/ArraySortToolsTest.hx
@@ -0,0 +1,165 @@
+package funkin.util.tools;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.tools.ArrayTools;
+
+@:access(funkin.util.tools.ArrayTools)
+class ArraySortToolsTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testMergeSort()
+ {
+ var testArray:Array = [5, 4, 3, 2, 1];
+
+ function compare(a:Int, b:Int)
+ {
+ return a - b;
+ }
+
+ ArraySortTools.mergeSort(testArray, compare);
+
+ Assert.areEqual(testArray[0], 1);
+ Assert.areEqual(testArray[1], 2);
+ Assert.areEqual(testArray[2], 3);
+ Assert.areEqual(testArray[3], 4);
+ Assert.areEqual(testArray[4], 5);
+
+ var testArray2:Array = [9, 6, 3, 12];
+
+ ArraySortTools.mergeSort(testArray2, compare);
+
+ Assert.areEqual(testArray2[0], 3);
+ Assert.areEqual(testArray2[1], 6);
+ Assert.areEqual(testArray2[2], 9);
+ Assert.areEqual(testArray2[3], 12);
+
+ // Just make sure these don't crash.
+ ArraySortTools.mergeSort([], compare);
+ ArraySortTools.mergeSort(null, compare);
+ ArraySortTools.mergeSort([], null);
+ ArraySortTools.mergeSort(null, null);
+
+ // Make sure these throw an exception.
+ try
+ {
+ ArraySortTools.mergeSort(testArray, null);
+
+ Assert.fail("Function should have thrown an exception.");
+ }
+ catch (e)
+ {
+ Assert.areEqual("No comparison function provided.", e);
+ }
+ }
+
+ @Test
+ public function testQuickSort()
+ {
+ var testArray:Array = [5, 4, 3, 2, 1];
+
+ function compare(a:Int, b:Int)
+ {
+ return a - b;
+ }
+
+ ArraySortTools.quickSort(testArray, compare);
+
+ Assert.areEqual(testArray[0], 1);
+ Assert.areEqual(testArray[1], 2);
+ Assert.areEqual(testArray[2], 3);
+ Assert.areEqual(testArray[3], 4);
+ Assert.areEqual(testArray[4], 5);
+
+ var testArray2:Array = [9, 6, 3, 12];
+
+ ArraySortTools.quickSort(testArray2, compare);
+
+ Assert.areEqual(testArray2[0], 3);
+ Assert.areEqual(testArray2[1], 6);
+ Assert.areEqual(testArray2[2], 9);
+ Assert.areEqual(testArray2[3], 12);
+
+ // Just make sure these don't crash.
+ ArraySortTools.quickSort([], compare);
+ ArraySortTools.quickSort(null, compare);
+ ArraySortTools.quickSort([], null);
+ ArraySortTools.quickSort(null, null);
+
+ // Make sure these throw an exception.
+ try
+ {
+ ArraySortTools.quickSort(testArray, null);
+
+ Assert.fail("Function should have thrown an exception.");
+ }
+ catch (e)
+ {
+ Assert.areEqual("No comparison function provided.", e);
+ }
+ }
+
+ @Test
+ public function testInsertionSort()
+ {
+ var testArray:Array = [5, 4, 3, 2, 1];
+
+ function compare(a:Int, b:Int)
+ {
+ return a - b;
+ }
+
+ ArraySortTools.insertionSort(testArray, compare);
+
+ Assert.areEqual(testArray[0], 1);
+ Assert.areEqual(testArray[1], 2);
+ Assert.areEqual(testArray[2], 3);
+ Assert.areEqual(testArray[3], 4);
+ Assert.areEqual(testArray[4], 5);
+
+ var testArray2:Array = [9, 6, 3, 12];
+
+ ArraySortTools.insertionSort(testArray2, compare);
+
+ Assert.areEqual(testArray2[0], 3);
+ Assert.areEqual(testArray2[1], 6);
+ Assert.areEqual(testArray2[2], 9);
+ Assert.areEqual(testArray2[3], 12);
+
+ // Just make sure these don't crash.
+ ArraySortTools.insertionSort([], compare);
+ ArraySortTools.insertionSort(null, compare);
+ ArraySortTools.insertionSort([], null);
+ ArraySortTools.insertionSort(null, null);
+
+ // Make sure these throw an exception.
+ try
+ {
+ ArraySortTools.insertionSort(testArray, null);
+
+ Assert.fail("Function should have thrown an exception.");
+ }
+ catch (e)
+ {
+ Assert.areEqual("No comparison function provided.", e);
+ }
+ }
+}
diff --git a/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx b/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx
new file mode 100644
index 000000000..68adb833b
--- /dev/null
+++ b/tests/unit/source/funkin/util/tools/ArrayToolsTest.hx
@@ -0,0 +1,65 @@
+package funkin.util.tools;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.tools.ArrayTools;
+
+@:access(funkin.util.tools.ArrayTools)
+class ArrayToolsTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testUnique()
+ {
+ var testArray:Array = [1, 2, 3, 4, 5, 6, 10, 7, 8, 9, 3, 4, 5, 6, 11, 7, 8];
+
+ var uniqueArray:Array = ArrayTools.unique(testArray);
+
+ Assert.areEqual(uniqueArray.length, 11);
+
+ // Array order doesn't change
+ Assert.areEqual(uniqueArray[0], 1);
+ Assert.areEqual(uniqueArray[1], 2);
+ Assert.areEqual(uniqueArray[2], 3);
+ Assert.areEqual(uniqueArray[3], 4);
+ Assert.areEqual(uniqueArray[4], 5);
+ Assert.areEqual(uniqueArray[5], 6);
+ Assert.areEqual(uniqueArray[6], 10);
+ Assert.areEqual(uniqueArray[7], 7);
+ Assert.areEqual(uniqueArray[8], 8);
+ Assert.areEqual(uniqueArray[9], 9);
+ Assert.areEqual(uniqueArray[10], 11);
+ }
+
+ @Test
+ public function testFind()
+ {
+ function predicate(a:String):Bool
+ {
+ return a.startsWith("Hello");
+ }
+
+ var testArray:Array = ["Foo", "Bar", "HelloWorld", "Baz", "HelloTest"];
+
+ var result = ArrayTools.find(testArray, predicate);
+
+ Assert.areEqual(result, "HelloWorld");
+ }
+}
diff --git a/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx b/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx
new file mode 100644
index 000000000..deaea352b
--- /dev/null
+++ b/tests/unit/source/funkin/util/tools/IteratorToolsTest.hx
@@ -0,0 +1,59 @@
+package funkin.util.tools;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.tools.IteratorTools;
+
+@:access(funkin.util.tools.IteratorTools)
+class IteratorToolsTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testArray()
+ {
+ var iter = new MyStringIterator("HelloWorld");
+
+ var arr = IteratorTools.array(iter);
+
+ Assert.areEqual(["H", "e", "l", "l", "o", "W", "o", "r", "l", "d"], arr);
+ }
+}
+
+class MyStringIterator
+{
+ var s:String;
+ var i:Int;
+
+ public function new(s:String)
+ {
+ this.s = s;
+ i = 0;
+ }
+
+ public function hasNext()
+ {
+ return i < s.length;
+ }
+
+ public function next()
+ {
+ return s.charAt(i++);
+ }
+}
diff --git a/tests/unit/source/funkin/util/tools/MapToolsTest.hx b/tests/unit/source/funkin/util/tools/MapToolsTest.hx
new file mode 100644
index 000000000..c3fe34461
--- /dev/null
+++ b/tests/unit/source/funkin/util/tools/MapToolsTest.hx
@@ -0,0 +1,55 @@
+package funkin.util.tools;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.tools.MapTools;
+
+@:access(funkin.util.tools.MapTools)
+class MapToolsTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testSize()
+ {
+ var testMap:Map = ["key1" => "value1", "key2" => "value2", "key3" => "value3"];
+
+ Assert.areEqual(3, MapTools.size(testMap));
+ }
+
+ @Test
+ public function testValues()
+ {
+ var testMap:Map = ["key1" => "value1", "key2" => "value2", "key3" => "value3"];
+
+ var result:Array = MapTools.values(testMap);
+ result.sort(SortUtil.alphabetically);
+ Assert.areEqual(["value1", "value2", "value3"], result);
+ }
+
+ @Test
+ public function testKeyValues()
+ {
+ var testMap:Map = ["key1" => "value1", "key2" => "value2", "key3" => "value3"];
+
+ var result:Array = MapTools.keyValues(testMap);
+ result.sort(SortUtil.alphabetically);
+ Assert.areEqual(["key1", "key2", "key3"], result);
+ }
+}
diff --git a/tests/unit/source/funkin/util/tools/StringToolsTest.hx b/tests/unit/source/funkin/util/tools/StringToolsTest.hx
new file mode 100644
index 000000000..215b5e402
--- /dev/null
+++ b/tests/unit/source/funkin/util/tools/StringToolsTest.hx
@@ -0,0 +1,59 @@
+package funkin.util.tools;
+
+import massive.munit.util.Timer;
+import massive.munit.Assert;
+import massive.munit.async.AsyncFactory;
+import funkin.util.tools.StringTools;
+
+@:access(funkin.util.tools.StringTools)
+class StringToolsTest extends FunkinTest
+{
+ public function new()
+ {
+ super();
+ }
+
+ @BeforeClass
+ public function beforeClass() {}
+
+ @AfterClass
+ public function afterClass() {}
+
+ @Before
+ public function setup() {}
+
+ @After
+ public function tearDown() {}
+
+ @Test
+ public function testToTitleCase()
+ {
+ var input = "hello world";
+
+ Assert.areEqual("Hello World", StringTools.toTitleCase(input));
+ }
+
+ @Test
+ public function testToLowerKebabCase()
+ {
+ var input = "hello world";
+
+ Assert.areEqual("hello-world", StringTools.toLowerKebabCase(input));
+ }
+
+ @Test
+ public function testToUpperKebabCase()
+ {
+ var input = "hello world";
+
+ Assert.areEqual("HELLO-WORLD", StringTools.toUpperKebabCase(input));
+ }
+
+ @Test
+ public function testParseJSON()
+ {
+ var input = "{ \"hello\": \"world\" }";
+
+ Assert.areEqual({hello: "world"}, StringTools.parseJSON(input));
+ }
+}
diff --git a/tests/unit/source/import.hx b/tests/unit/source/import.hx
new file mode 100644
index 000000000..89264c0c9
--- /dev/null
+++ b/tests/unit/source/import.hx
@@ -0,0 +1,21 @@
+#if !macro
+// Only import these when we aren't in a macro.
+import funkin.util.Constants;
+import funkin.Paths;
+import flixel.FlxG; // This one in particular causes a compile error if you're using macros.
+
+// These are great.
+using Lambda;
+using StringTools;
+using funkin.util.tools.ArrayTools;
+using funkin.util.tools.ArraySortTools;
+using funkin.util.tools.IteratorTools;
+using funkin.util.tools.MapTools;
+using funkin.util.tools.StringTools;
+#end
+
+// Testing-specific
+// Mocking
+import mockatoo.Mockatoo.*;
+
+using mockatoo.Mockatoo;
diff --git a/tests/unit/start-mac-native.sh b/tests/unit/start-mac-native.sh
new file mode 100755
index 000000000..fb6c812bd
--- /dev/null
+++ b/tests/unit/start-mac-native.sh
@@ -0,0 +1,3 @@
+#!/bin/zsh
+
+haxe test-cpp.hxml
diff --git a/tests/unit/start-mac-web.sh b/tests/unit/start-mac-web.sh
new file mode 100755
index 000000000..66af469f7
--- /dev/null
+++ b/tests/unit/start-mac-web.sh
@@ -0,0 +1,3 @@
+#!/bin/zsh
+
+haxe test-web.hxml
diff --git a/tests/unit/start-win-native.bat b/tests/unit/start-win-native.bat
new file mode 100644
index 000000000..7678a86ea
--- /dev/null
+++ b/tests/unit/start-win-native.bat
@@ -0,0 +1,3 @@
+REM Launches the unit tests for the native target on Windows.
+
+haxe test-cpp.hxml
diff --git a/tests/unit/test-cpp.hxml b/tests/unit/test-cpp.hxml
new file mode 100644
index 000000000..9379f841d
--- /dev/null
+++ b/tests/unit/test-cpp.hxml
@@ -0,0 +1,11 @@
+# Updates TestSuite.hx to include all tests
+#-cmd haxelib run munit gen
+# Actually performs the tests
+#-cmd haxelib run munit test -debug -coverage
+# -debug may or may not be needed
+# -coverage adds code coverage reporting
+
+# Legacy style. Doesn't give detailed coverage reports,
+# but it works without crashing.
+-cmd haxelib run munit gen
+-cmd haxelib run lime test cpp
diff --git a/tests/unit/test-web.hxml b/tests/unit/test-web.hxml
new file mode 100644
index 000000000..c7c2ab444
--- /dev/null
+++ b/tests/unit/test-web.hxml
@@ -0,0 +1,5 @@
+# Update the test runner classes
+# TODO: This just opens the browser and doesn't do anything?
+-cmd haxelib run munit gen
+-cmd haxelib run lime build html5
+-cmd haxelib run lime test html5
diff --git a/tests/unit/test.hxml b/tests/unit/test.hxml
new file mode 100644
index 000000000..cece0c43a
--- /dev/null
+++ b/tests/unit/test.hxml
@@ -0,0 +1,32 @@
+## CPP
+--next
+-main TestMain
+-cpp build/cpp_test
+# Funkin' deps
+-lib lime
+-lib openfl
+-lib flixel
+-lib flixel-addons
+-lib flixel-ui
+-lib hscript
+-lib polymod
+-lib haxeui-core
+-lib haxeui-flixel
+-lib flxanimate
+-lib hxCodec
+-lib thx.semver
+-lib json2object
+-lib tink_json
+# Test deps
+-lib munit
+-lib hamcrest
+-lib mcover
+-lib mockatoo
+# Class paths
+-cp source
+-cp ../../source
+# Flixel macros
+--remap flash:openfl
+--macro flixel.system.macros.FlxDefines.run()
+# Funkin' macros
+--macro addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic')
diff --git a/tests/unit/test.hxml-old b/tests/unit/test.hxml-old
new file mode 100644
index 000000000..d86661fc8
--- /dev/null
+++ b/tests/unit/test.hxml-old
@@ -0,0 +1,24 @@
+## JavaScript HTML5
+--next
+-js build/js_test.js
+# Funkin' deps
+-lib lime
+-lib openfl
+-lib flixel
+-lib flixel-addons
+-lib hscript
+-lib flixel-ui
+-lib haxeui-core
+-lib haxeui-flixel
+-lib polymod
+-lib flxanimate
+-lib hxCodec
+-lib thx.semver
+-lib json2object
+-lib tink_json
+# Test deps
+-lib munit
+-lib hamcrest
+# Class paths
+-cp source
+-cp ../../source