diff --git a/LICENSE b/LICENSE
index bbcb0aa..0097e57 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,9 @@
MIT License
-Copyright (c) 2020 Leroy Hopson
+Copyright (c) 2020, The GodotXterm authors (https://github.com/lihop/godot-xterm)
+Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
+Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
+Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +12,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
index 828c7c0..4a24d04 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,7 @@
-# godot-xterm
-Xterm.js for Godot
+
+
+# GodotXterm
+
+[![Build Status](https://travis-ci.com/lihop/godot-xterm.svg?branch=master)](https://travis-ci.com/lihop/godot-xterm)
+
+[Xterm.js](https://github.com/xtermjs/xterm.js) for Godot.
diff --git a/addons/godot_xterm/Constants.gd b/addons/godot_xterm/Constants.gd
new file mode 100644
index 0000000..210c4b8
--- /dev/null
+++ b/addons/godot_xterm/Constants.gd
@@ -0,0 +1,23 @@
+extends Reference
+
+# font flags
+enum {
+ FONT_NORMAL = 0,
+ FONT_BOLD = 1 << 0
+ FONT_ITALIC = 1 << 1,
+ FONT_UNDERLINED = 1 << 2
+ FONT_BLINK = 1 << 3
+ FONT_INVERSE = 1 << 4
+ FONT_IVSIBILE = 1 << 5
+ FONT_STRIKETHROUGH = 1 << 6
+}
+
+# colors
+const COLOR_BLACK = Color(0.0, 0.0, 0.0) # 0
+const COLOR_RED = Color(1.0, 0.0, 0.0) # 1
+const COLOR_GREEN = Color(0.0, 1.0, 0.0) # 2
+const COLOR_YELLOW = Color(1.0, 1.0, 0.0) # 3
+const COLOR_BLUE = Color(0.0, 0.0, 1.0) # 4
+const COLOR_MAGENTA = Color(1.0, 0.0, 1.0) # 5
+const COLOR_CYAN = Color(0.0, 1.0, 1.0) # 6
+const COLOR_WHITE = Color(1.0, 1.0, 1.0) # 7
diff --git a/addons/godot_xterm/buffer.gd b/addons/godot_xterm/buffer.gd
new file mode 100644
index 0000000..ea65fc7
--- /dev/null
+++ b/addons/godot_xterm/buffer.gd
@@ -0,0 +1,271 @@
+# Copyright (c) 2020 The GodotXterm authors. All rights reserved.
+# License MIT
+extends Reference
+
+
+const CharData = preload("res://addons/godot_xterm/char_data.gd")
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+
+const MAX_BUFFER_SIZE = 32768 # 32 KiB
+
+
+
+# Erase in Line (EL)
+enum {EL_RIGHT, EL_LEFT, EL_ALL}
+enum {FONT_NORMAL, FONT_BOLD, FONT_BLINK}
+
+# Places a tab stop after every 8 columns.
+var tabWidth = 8
+
+var rows = [[]] # array of CharData
+
+var fg = Color(1.0, 1.0, 1.0) # foreground color
+var bg = Color(0.0, 0.0, 0.0) # background color
+var font # font
+var font_flags = FONT_NORMAL
+
+var crow = 0 setget _set_crow # cursor's row
+var ccol = 0 setget _set_ccol # cursor's column
+
+var ccol_saved: int
+var crow_saved: int
+
+var num_rows = 20
+var num_cols = 70
+
+var savedBuffer
+var savedCursorRow
+var savedCursorCol
+
+func _init(num_rows: int, num_columns: int, alternate: bool = false):
+ rows = []
+ rows.resize(num_rows)
+ for i in range(rows.size()):
+ var cols = []
+ cols.resize(num_columns)
+ for j in range(cols.size()):
+ cols[j] = CharData.new(" ", bg, fg, font_flags)
+ rows[i] = cols
+
+func _get_buffer_size():
+ # Get the size of the (virtual) buffer.
+ # Count each CharData as one byte even though it might be multiple bytes
+ # in the case of unicode characters.
+ var size = 0
+ for row in rows:
+ size += row.size()
+ return size
+
+func _set_rows(rows):
+ print("rows: ", rows)
+
+func _set_crow(row: int):
+ print("setting crow")
+ # Ensure there are enoungh rows in the
+ # buffer for the new cursor position.
+ if row >= rows.size():
+ rows.resize(row + 1)
+
+ # resize() uses null for new elements.
+ # but a row should be an array so we
+ # need to replace null values.
+ for i in range(rows.size()):
+ if rows[i] == null:
+ rows[i] = []
+
+ crow = row
+
+func _set_ccol(col: int):
+ # Ensure there are enough columns in the
+ # row for the new cursor position.
+ print("da size: ", rows[crow].size())
+ if col >= rows[crow].size():
+ rows[crow].resize(col + 1)
+
+ print("da new size: ", rows[crow].size())
+
+ for i in range(rows[crow].size()):
+ if rows[crow][i] == null:
+ rows[crow][i] = CharData.new(' ', bg, fg)
+
+ ccol = col
+
+func save_cursor():
+ ccol_saved = ccol
+ crow_saved = crow
+
+func restore_cursor():
+ ccol = ccol_saved
+ crow = crow_saved
+
+func insert_at_cursor(d, start: int = 0, end: int = 1):
+ var string
+ if typeof(d) == TYPE_ARRAY:
+ string = Decoder.utf32_to_string(d.slice(start, end - 1))
+ else:
+ string = d
+
+ var row = rows[crow]
+
+ for i in range(string.length()):
+ var data = CharData.new(string[i], bg, fg, font_flags)
+
+ if ccol < row.size():
+ row[ccol] = data
+ else:
+ row.resize(ccol)
+
+ for i in range(row.size()):
+ if row[i] == null:
+ row[i] = CharData.new(' ', bg, fg, font_flags)
+
+ row.append(data)
+
+ # Update the cursor position.
+ ccol += 1
+
+func insert_tab():
+ print("Insert a tab!")
+ # Insert a space.
+ insert_at_cursor(' ')
+
+ # Keep inserting spaces until cursor is at next Tab stop.
+ while ccol % tabWidth != 0:
+ insert_at_cursor(' ')
+
+# cr
+func carriage_return():
+ ccol = 0
+
+# lf
+func line_feed():
+ rows.resize(rows.size() + 1)
+ rows[-1] = []
+ crow = crow + 1
+
+# bs
+# Deletes the element before the current cursor position.
+func backspace():
+ rows[crow].remove(ccol - 1)
+ ccol = ccol - 1
+
+# cup
+# Move the cursor to the given row and column.
+# For example cursor_position(0, 0) would move
+# the cursor to the top left corner of the terminal.
+func cursor_position(params: Array) -> void:
+ var row = params[0] if params.size() > 0 else 1
+ var col = params[1] if params.size() > 1 else 1
+
+ # Origin is (0,0) so row 1, col 1 would be 0,0.
+ if col != 0:
+ self.ccol = col - 1
+ else:
+ self.ccol = 0
+ if row != 0:
+ self.crow = row - 1
+ else:
+ self.crow = 0
+
+# ed 3
+func erase_saved_lines():
+ rows = [[]]
+ print("saved lines erased")
+
+# el
+func erase_in_line(section):
+ return
+ match section:
+ EL_LEFT, EL_ALL:
+ for i in range(0, ccol):
+ rows[crow][i] = CharData.new(" ")
+ print("Erased the thing")
+ if section == EL_ALL:
+ continue
+ EL_RIGHT, _:
+ for i in range(ccol, rows[crow].size()):
+ rows[crow][i] = CharData.new(" ")
+ print("Erased the thing")
+
+# ed 0 (default)
+func erase_below():
+ # Erase from the cursor through to the end of the display.
+ save_cursor()
+ while crow < num_rows:
+ erase_in_line(EL_RIGHT)
+ _set_ccol(0)
+ _set_crow(crow + 1)
+ restore_cursor()
+
+func set_scrolling_region(top: int, bottom: int):
+ print("set_scrolling_position")
+ # Not sure what this does yet.
+ # Make default be full window size.
+ pass
+
+func set_font(fontState: int, set: bool = true):
+ match fontState:
+ FONT_NORMAL:
+ pass
+
+func set_font_flag(flag: int, set: bool = true):
+ print("setting font flag!")
+ if set: # Set the flag
+ font_flags |= (1 << flag)
+ else: # Clear the flag
+ font_flags &= ~(1 << flag)
+ print("font flag is set!")
+ print(font_flags)
+
+# Clear all font flags. Returns font to default state.
+func reset_font_flags():
+ font_flags = FONT_NORMAL
+
+# setf
+func set_foreground(color: Color = Color(1.0, 1.0, 1.0)):
+ fg = color
+
+# setb
+func set_background(color: Color = Color(0.0, 0.0, 0.0)):
+ bg = color
+
+# setaf
+func set_a_foreground(params):
+ pass
+
+# setab
+func set_a_background(params):
+ pass
+
+func reset_sgr():
+ set_foreground()
+ set_background()
+ reset_font_flags()
+
+func repeat_preceding_character(times: int = 0):
+
+ var preceding_char
+
+ if ccol == 0:
+ preceding_char = rows[crow-1][-1]
+ else:
+ preceding_char = rows[crow][ccol-1]
+
+ print("Repeating preceding char ", preceding_char.ch, " ", times, " times")
+
+ for i in range(times):
+ insert_at_cursor(preceding_char.ch)
+
+# Save the buffer (useful when switching to the alternate buffer)
+func save():
+ savedBuffer = rows
+ savedCursorCol = ccol
+ savedCursorRow = crow
+
+# Full Reset
+func reset():
+ rows = [[]]
+ crow = 0
+ ccol = 0
+ fg = Color(1.0, 1.0, 1.0)
+ bg = Color(0.0, 0.0, 0.0)
diff --git a/addons/godot_xterm/char_data.gd b/addons/godot_xterm/char_data.gd
new file mode 100644
index 0000000..d65d8a7
--- /dev/null
+++ b/addons/godot_xterm/char_data.gd
@@ -0,0 +1,19 @@
+# Copyright (c) 2020 The GodotXterm authors. All rights reserved.
+# License MIT
+extends Reference
+
+var ch # character
+var fg = Color(1.0, 1.0, 1.0) # foreground color
+var bg = Color(0.0, 0.0, 0.0) # background color
+var ff = 0 # font flags
+
+func _init(
+ character: String,
+ background_color: Color = bg,
+ foreground_color: Color = fg,
+ font_flags = ff # Does this work or will it cause problems (this assignement technique)
+ ):
+ ch = character
+ bg = background_color
+ fg = foreground_color
+ ff = font_flags
diff --git a/addons/godot_xterm/fonts/source_code_pro/OFL.txt b/addons/godot_xterm/fonts/source_code_pro/OFL.txt
new file mode 100644
index 0000000..6f4c937
--- /dev/null
+++ b/addons/godot_xterm/fonts/source_code_pro/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.tres b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.tres
new file mode 100644
index 0000000..6a95e02
--- /dev/null
+++ b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.tres
@@ -0,0 +1,6 @@
+[gd_resource type="DynamicFont" load_steps=2 format=2]
+
+[ext_resource path="res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.ttf" type="DynamicFontData" id=1]
+
+[resource]
+font_data = ExtResource( 1 )
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.ttf b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.ttf
new file mode 100644
index 0000000..c790e04
Binary files /dev/null and b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.ttf differ
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.tres b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.tres
new file mode 100644
index 0000000..41d8f3d
--- /dev/null
+++ b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.tres
@@ -0,0 +1,6 @@
+[gd_resource type="DynamicFont" load_steps=2 format=2]
+
+[ext_resource path="res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.ttf" type="DynamicFontData" id=1]
+
+[resource]
+font_data = ExtResource( 1 )
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.ttf b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.ttf
new file mode 100644
index 0000000..0878199
Binary files /dev/null and b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.ttf differ
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.tres b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.tres
new file mode 100644
index 0000000..033a34e
--- /dev/null
+++ b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.tres
@@ -0,0 +1,6 @@
+[gd_resource type="DynamicFont" load_steps=2 format=2]
+
+[ext_resource path="res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.ttf" type="DynamicFontData" id=1]
+
+[resource]
+font_data = ExtResource( 1 )
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.ttf b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.ttf
new file mode 100644
index 0000000..91a2a44
Binary files /dev/null and b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.ttf differ
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres
new file mode 100644
index 0000000..22109c0
--- /dev/null
+++ b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres
@@ -0,0 +1,6 @@
+[gd_resource type="DynamicFont" load_steps=2 format=2]
+
+[ext_resource path="res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.ttf" type="DynamicFontData" id=1]
+
+[resource]
+font_data = ExtResource( 1 )
diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.ttf b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.ttf
new file mode 100644
index 0000000..3563e73
Binary files /dev/null and b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.ttf differ
diff --git a/addons/godot_xterm/fonts/vt323/OFL.txt b/addons/godot_xterm/fonts/vt323/OFL.txt
new file mode 100644
index 0000000..24e8149
--- /dev/null
+++ b/addons/godot_xterm/fonts/vt323/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/addons/godot_xterm/fonts/vt323/vt323_regular.tres b/addons/godot_xterm/fonts/vt323/vt323_regular.tres
new file mode 100644
index 0000000..a389a10
--- /dev/null
+++ b/addons/godot_xterm/fonts/vt323/vt323_regular.tres
@@ -0,0 +1,3 @@
+[gd_resource type="DynamicFont" format=2]
+
+[resource]
diff --git a/addons/godot_xterm/fonts/vt323/vt323_regular.ttf b/addons/godot_xterm/fonts/vt323/vt323_regular.ttf
new file mode 100644
index 0000000..e838581
Binary files /dev/null and b/addons/godot_xterm/fonts/vt323/vt323_regular.ttf differ
diff --git a/addons/godot_xterm/icon.svg b/addons/godot_xterm/icon.svg
new file mode 100644
index 0000000..0f58b26
--- /dev/null
+++ b/addons/godot_xterm/icon.svg
@@ -0,0 +1,14 @@
+
+
diff --git a/addons/godot_xterm/icon.svg.import b/addons/godot_xterm/icon.svg.import
new file mode 100644
index 0000000..a4fb527
--- /dev/null
+++ b/addons/godot_xterm/icon.svg.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/icon.svg-b0b594198f5f4040e8f0e39a8a353265.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/godot_xterm/icon.svg"
+dest_files=[ "res://.import/icon.svg-b0b594198f5f4040e8f0e39a8a353265.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/addons/godot_xterm/input/text_decoder.gd b/addons/godot_xterm/input/text_decoder.gd
new file mode 100644
index 0000000..d92ce11
--- /dev/null
+++ b/addons/godot_xterm/input/text_decoder.gd
@@ -0,0 +1,269 @@
+# Copyright (c) 2020 The GodotTerm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends Reference
+
+# Convert a given to a utf8 PoolByteArray.
+# The code for this function is based on the stackoverflow
+# answer by user Schwern https://stackoverflow.com/a/42013984.
+static func utf32_to_utf8(codepoint: int):
+ var utf8 = PoolByteArray([])
+
+ if codepoint <= 0x007F:
+ utf8.append(codepoint)
+ elif codepoint <= 0x07FF:
+ utf8.append(0b11000000 | codepoint >> 6 & 0b00011111)
+ utf8.append(0b10000000 | codepoint & 0b00111111)
+ elif codepoint <= 0xFFFF:
+ utf8.append(0b11100000 | codepoint >> 12 & 0b00001111)
+ utf8.append(0b10000000 | codepoint >> 6 & 0b00111111)
+ utf8.append(0b10000000 | codepoint & 0b00111111)
+ elif codepoint <= 0x10FFFF:
+ utf8.append(0b11110000 | codepoint >> 18 & 0b00000111)
+ utf8.append(0b10000000 | codepoint >> 12 & 0b00111111)
+ utf8.append(0b10000000 | codepoint >> 6 & 0b00111111)
+ utf8.append(0b10000000 | codepoint & 0b00111111)
+ else:
+ push_warning("Codepoint " + String(codepoint) + " is out of UTF-8 range")
+
+ return utf8
+
+# Convert UTF32 codepoint into a String.
+static func string_from_codepoint(codepoint: int):
+ var utf8 = utf32_to_utf8(codepoint)
+ return utf8.get_string_from_utf8()
+
+# Covert UTF32 char codes into a String.
+# Basically the same as `string_from_codepoint` but for multiple codepoints
+# in a loop (which is a lot faster).
+static func utf32_to_string(data: Array, start: int = 0, end: int = -1):
+ if end == -1:
+ end = data.size()
+ var result = ''
+ for i in range(start, end):
+ result += string_from_codepoint(data[i])
+ return result
+
+# Utf8Decoder - decodes UTF8 byte sequences into UTF32 codepoints.
+class Utf8ToUtf32:
+ var interim = PoolByteArray()
+
+ func _init():
+ interim.resize(3)
+
+ # Clears interim bytes and resets decoder to clean state.
+ func clear():
+ for i in interim.size():
+ interim[i] = 0
+
+ # Decodes UTF8 byte sequences in `input` to UTF32 codepoints in `target`.
+ # The methods assumes stream input and will store partly transmitted bytes
+ # and decode them with the next data chunk.
+ # Note: The method does no bound checks for target, therefore make sure
+ # the provided data chunk does not exceed the size of `target`.
+ # Returns the number of written codepoints in `target`.
+ func decode(input: PoolByteArray, target: Array):
+ var length = input.size()
+
+ if !length:
+ return 0
+
+ if length > target.size():
+ target.resize(length)
+
+ var size = 0
+ var byte1: int
+ var byte2: int
+ var byte3: int
+ var byte4: int
+ var codepoint = 0
+ var start_pos = 0
+
+ # handle leftover bytes
+ if interim[0]:
+ var discard_interim = false
+ var cp = interim[0]
+ cp &= 0x1F if (cp & 0xE0) == 0xC0 else 0x0F if (cp & 0xF0) == 0xE0 else 0x07
+ var pos = 1
+ var tmp = interim[pos] & 0x3F
+ while tmp && pos < 4:
+ cp <<= 6
+ cp |= tmp
+ pos += 1
+ tmp = interim[pos] & 0x3F if interim.size() < pos else 0
+ # missing bytes - read from input
+ var type = 2 if (interim[0] & 0xE0) == 0xC0 else 3 if (interim[0] & 0xF0) == 0xE0 else 4
+ var missing = type - pos
+ while start_pos < missing:
+ if start_pos >= length:
+ return 0
+ tmp = input[start_pos]
+ start_pos += 1
+ if (tmp & 0xC0) != 0x80:
+ # wrong continuation, discard interim bytes completely
+ start_pos -= 1
+ discard_interim = true
+ break
+ else:
+ # need to save so we can continue short inputs in next call
+ interim[pos + 1] = tmp
+ pos += 1
+ cp <<= 6
+ cp |= tmp & 0x3F
+ if not discard_interim:
+ # final test is type dependent
+ match type:
+ 2:
+ if cp < 0x80:
+ # wrong starter byte
+ start_pos -= 1
+ else:
+ target[size] = cp
+ size += 1
+ 3:
+ if cp < 0x0800 or (cp >= 0xD800 and cp <= 0xDFFF):
+ # illegal codepoint
+ pass
+ else:
+ target[size] = cp
+ size += 1
+ _:
+ if cp < 0x10000 or cp > 0x10FFFF:
+ # illegal codepoint
+ pass
+ else:
+ target[size] = cp
+ size += 1
+ clear()
+
+ # loop through input
+ var four_stop = length - 4
+ var i = start_pos
+ while i < length:
+ # ASCII shortcut with loop unrolled to 4 consecutive ASCII chars.
+ # This is a compromise between speed gain for ASCII
+ # and penalty for non ASCII:
+ # For best ASCII performance the char should be stored directly into target,
+ # but even a single attempt to write to target and compare afterwards
+ # penalizes non ASCII really bad (-50%), thus we load the char into byteX first,
+ # which reduces ASCII performance by ~15%.
+ # This trial for ASCII reduces non ASCII performance by ~10% which seems acceptible
+ # compared to the gains.
+ # Note that this optimization only takes place for 4 consecutive ASCII chars,
+ # for any shorter it bails out. Worst case - all 4 bytes being read but
+ # thrown away due to the last being a non ASCII char (-10% performance).
+ while i < four_stop:
+ byte1 = input[i]
+ byte2 = input[i + 1]
+ byte3 = input[i + 2]
+ byte4 = input[i + 3]
+ if not (byte1 & 0x80) | (byte2 & 0x80) | (byte3 & 0x80) | (byte4 & 0x80):
+ target[size] = byte1
+ target[size+1] = byte2
+ target[size+2] = byte3
+ target[size+3] = byte4
+ size += 4
+ i += 4
+ else:
+ break
+
+ # reread byte1
+ byte1 = input[i]
+ i += 1
+
+ # 1 byte
+ if byte1 < 0x80:
+ target[size] = byte1
+ size += 1
+
+ # 2 bytes
+ elif (byte1 & 0xE0) == 0xC0:
+ if i >= length:
+ interim[0] = byte1
+ return size
+ byte2 = input[i]
+ i+=1
+ if (byte2 & 0xC0) != 0x80:
+ # wrong continuation
+ i-=1
+ continue
+ codepoint = (byte1 & 0x1F) << 6 | (byte2 & 0x3F)
+ if (codepoint < 0x80):
+ # wrong starter byte
+ i-=1
+ continue
+ target[size] = codepoint
+ size+=1
+
+ # 3 bytes
+ elif (byte1 & 0xF0) == 0xE0:
+ if i >= length:
+ interim[0] = byte1
+ return size
+ byte2 = input[i]
+ i+=1
+ if (byte2 & 0xC0) != 0x80:
+ # wrong continuation
+ i-=1
+ continue
+ if i >= length:
+ interim[0] = byte1
+ interim[1] = byte2
+ return size
+ byte3 = input[i]
+ i+=1
+ if (byte3 & 0xC0) != 0x80:
+ # wrong continuation
+ i-=1
+ continue
+ codepoint = (byte1 & 0x0F) << 12 | (byte2 & 0x3F) << 6 | (byte3 & 0x3F)
+ if codepoint < 0x0800 or (codepoint >=0xD800 and codepoint <= 0xDFFF):
+ # illegal codepoint, no i-- here
+ continue
+ target[size] = codepoint
+ size+=1
+
+ # 4 bytes
+ elif (byte1 & 0xF8) == 0xF0:
+ if i >= length:
+ interim[0] = byte1
+ return size
+ byte2 = input[i]
+ i += 1
+ if (byte2 & 0xC0) != 0x80:
+ # wrong continuation
+ i -= 1
+ continue
+ if i >= length:
+ interim[0] = byte1
+ interim[1] = byte2
+ return size
+ byte3 = input[i]
+ i += 1
+ if (byte3 & 0xC0) != 0x80:
+ # wrong continuation
+ i -= 1
+ continue
+ if i >= length:
+ interim[0] = byte1
+ interim[1] = byte2
+ interim[2] = byte3
+ return size
+ byte4 = input[i]
+ i += 1
+ if (byte4 & 0xC0) != 0x80:
+ # wrong continuation
+ i -= 1
+ continue
+ codepoint = (byte1 & 0x07) << 18 | (byte2 & 0x3F) << 12 | (byte3 & 0x3F) << 6 | (byte4 & 0x3F)
+ if codepoint < 0x010000 or codepoint > 0x10FFFF:
+ # illegal codepoint, no i-- here
+ continue
+ target[size] = codepoint
+ size += 1
+ else:
+ # illegal byte, just skip
+ pass
+
+ target.resize(size)
+ return size
diff --git a/addons/godot_xterm/parser/constants.gd b/addons/godot_xterm/parser/constants.gd
new file mode 100644
index 0000000..ec154ec
--- /dev/null
+++ b/addons/godot_xterm/parser/constants.gd
@@ -0,0 +1,131 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends Reference
+
+# Psuedo-character placeholder for non-ascii characters (unicode).
+const NON_ASCII_PRINTABLE = 0xa0
+
+# Payload limit for OSC and DCS.
+const PAYLOAD_LIMIT = 10000000
+
+# Internal states of EscapeSequenceParser.
+enum ParserState {
+ GROUND
+ ESCAPE
+ ESCAPE_INTERMEDIATE
+ CSI_ENTRY
+ CSI_PARAM
+ CSI_INTERMEDIATE
+ CSI_IGNORE
+ SOS_PM_APC_STRING
+ OSC_STRING
+ DCS_ENTRY
+ DCS_PARAM
+ DCS_IGNORE
+ DCS_INTERMEDIATE
+ DCS_PASSTHROUGH
+}
+
+# Internal actions of EscapeSequenceParser.
+enum ParserAction {
+ IGNORE
+ ERROR
+ PRINT
+ EXECUTE
+ OSC_START
+ OSC_PUT
+ OSC_END
+ CSI_DISPATCH
+ PARAM
+ COLLECT
+ ESC_DISPATCH
+ CLEAR
+ DCS_HOOK
+ DCS_PUT
+ DCS_UNHOOK
+}
+
+ # Internal states of OscParser.
+enum OscState {
+ START
+ ID
+ PAYLOAD
+ ABORT
+}
+
+# C0 control codes
+# See: https://en.wikipedia.org/wiki/C0_and_C1_control_codes#C0_controls
+enum C0 {
+ NUL
+ SOH
+ STX
+ ETX
+ EOT
+ ENQ
+ ACK
+ BEL
+ BS
+ HT
+ LF
+ VT
+ FF
+ CR
+ SO
+ SI
+ DLE
+ DC1
+ DC2
+ DC3
+ DC4
+ NAK
+ SYN
+ ETB
+ CAN
+ EM
+ SUB
+ ESC
+ FS
+ GS
+ RS
+ US
+ SP
+ DEL = 0x7f
+}
+
+# C1 control codes
+# See: https://en.wikipedia.org/wiki/C0_and_C1_control_codes#C1_controls
+enum C1 {
+ PAD = 0x80
+ HOP = 0x81
+ BPH = 0x82
+ NBH = 0x83
+ IND = 0x84
+ NEL = 0x85
+ SSA = 0x86
+ ESA = 0x87
+ HTS = 0x88
+ HTJ = 0x89
+ VTS = 0x8a
+ PLD = 0x8b
+ PLU = 0x8c
+ RI = 0x8d
+ SS2 = 0x8e
+ SS3 = 0x8f
+ DCS = 0x90
+ PU1 = 0x91
+ PU2 = 0x92
+ STS = 0x93
+ CCH = 0x94
+ MW = 0x95
+ SPA = 0x96
+ EPA = 0x97
+ SOS = 0x98
+ SGCI = 0x99
+ SCI = 0x9a
+ CSI = 0x9b
+ ST = 0x9c
+ OSC = 0x9d
+ PM = 0x9e
+ APC = 0x9f
+}
diff --git a/addons/godot_xterm/parser/dcs_parser.gd b/addons/godot_xterm/parser/dcs_parser.gd
new file mode 100644
index 0000000..78d5067
--- /dev/null
+++ b/addons/godot_xterm/parser/dcs_parser.gd
@@ -0,0 +1,77 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends Reference
+
+
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+
+
+const EMPTY_HANDLERS = []
+
+
+var _handlers: Dictionary = {}
+var _active: Array = EMPTY_HANDLERS
+var _ident: int = 0
+var _handler_fb: Dictionary
+
+
+func _init():
+ pass
+
+
+func set_handler(ident: int, handler):
+ _handlers[ident] = [handler]
+
+
+func clear_handler(ident: int):
+ _handlers.erase(ident)
+
+
+func set_handler_fallback(target, method):
+ _handler_fb = {'target': target, 'method': method}
+
+
+func reset():
+ if _active.size():
+ unhook(false)
+ _active = EMPTY_HANDLERS
+ _ident = 0
+
+
+func hook(ident: int, params):
+ # always reset leftover handlers
+ reset()
+ _ident = ident
+ _active = _handlers[ident] if _handlers.has(ident) else EMPTY_HANDLERS
+ if _active.empty():
+ _handler_fb['target'].call(_handler_fb['method'], _ident, 'HOOK', params)
+ else:
+ _active.invert()
+ for handler in _active:
+ handler.hook(params)
+ _active.invert()
+
+
+func put(data: Array, start: int, end: int):
+ if _active.empty():
+ _handler_fb['target'].call(_handler_fb['method'], _ident, 'PUT',
+ Decoder.utf32_to_string(data, start, end))
+ else:
+ _active.invert()
+ for handler in _active:
+ handler.put(data, start, end)
+ _active.invert()
+
+
+func unhook(success: bool):
+ if _active.empty():
+ _handler_fb['target'].call(_handler_fb['method'], _ident, 'UNHOOK', success)
+ else:
+ _active.invert()
+ for handler in _active:
+ if handler.unhook(success) != false:
+ success = false # will cleanup left over handlers
+ _active.invert()
+ _active = EMPTY_HANDLERS
+ _ident = 0
diff --git a/addons/godot_xterm/parser/escape_sequence_parser.gd b/addons/godot_xterm/parser/escape_sequence_parser.gd
new file mode 100644
index 0000000..ac9fe20
--- /dev/null
+++ b/addons/godot_xterm/parser/escape_sequence_parser.gd
@@ -0,0 +1,329 @@
+# Copyright (c) 2020 The GodotXterm authors. All rights reserved.
+# Copyright (c) 2018 The xterm.js authers. All rights reserved.
+# License MIT
+extends Reference
+
+const Constants = preload("res://addons/godot_xterm/parser/constants.gd")
+const TransitionTable = preload("res://addons/godot_xterm/parser/transition_table.gd")
+const VT500TransitionTable = preload("res://addons/godot_xterm/parser/vt500_transition_table.gd")
+const DcsParser = preload("res://addons/godot_xterm/parser/dcs_parser.gd")
+const Params = preload("res://addons/godot_xterm/parser/params.gd")
+
+const TableAccess = TransitionTable.TableAccess
+const NON_ASCII_PRINTABLE = Constants.NON_ASCII_PRINTABLE
+const ParserState = Constants.ParserState
+const ParserAction = Constants.ParserAction
+
+var initial_state
+var current_state
+var preceding_codepoint
+
+var _transitions
+
+# buffers over several parse calls
+var _params
+var _collect
+
+# handler lookup containers
+var _print_handler
+var _execute_handlers
+var _csi_handlers
+var _esc_handlers
+var _osc_parser
+var _dcs_parser
+var _error_handler
+
+# fallback handlers
+var _print_handler_fb
+var _execute_handler_fb
+var _csi_handler_fb
+var _esc_handler_fb
+var _error_handler_fb
+
+
+# Default do noting fallback handler.
+# Allows a variable number of arguments from 0 - 7.
+func noop(a = null, b = null, c = null, d = null, e = null, f = null, g = null):
+ pass
+
+
+func _init(transitions = VT500TransitionTable.new().table):
+ initial_state = ParserState.GROUND
+ current_state = initial_state
+ _transitions = transitions
+ _params = Params.new() # Defaults to 32 storable params/subparams
+ _params.add_param(0) # ZDM (Zero Default Mode
+ _collect = 0
+ preceding_codepoint = 0
+
+ # set default fallback handlers and handler lookup containers
+ var noop = {'target': self, 'method': 'noop'}
+ _print_handler_fb = noop
+ _execute_handler_fb = noop
+ _csi_handler_fb = noop
+ _esc_handler_fb = noop
+ _error_handler_fb = noop
+ _print_handler = _print_handler_fb
+ _execute_handlers = {}
+ _csi_handlers = {}
+ _esc_handlers = {}
+ _osc_parser = null # TODO OscParser.new()
+ _dcs_parser = DcsParser.new()
+ _error_handler = _error_handler_fb
+
+ # swallow 7bit ST (ESC+\)
+ set_esc_handler({'final': '\\'}, self, 'noop')
+
+
+static func identifier(id: Dictionary, final_range: Array = [0x40, 0x7e]):
+ var res = 0
+
+ var prefix = id.get('prefix')
+ var intermediates = id.get('intermediates')
+ var final = id.get('final')
+
+ if prefix:
+ if prefix.length() > 1:
+ push_error("only one byte prefix supported")
+ res = prefix.to_ascii()[0]
+ if res and 0x3c > res or res > 0x3f:
+ push_error("prefix must be in the range 0x3c-0x3f")
+
+ if intermediates:
+ if intermediates.length() > 2:
+ push_error("only two bytes as intermediates are supported")
+ for intermediate in intermediates:
+ var im = intermediate.to_ascii()[0]
+ if 0x20 > im or im > 0x2f:
+ push_error("intermediate must be in the range 0x20-0x2f")
+ res = res << 8
+ res = res | im
+
+ if final.length() != 1:
+ push_error("final must be a single byte")
+ var final_code = final.to_ascii()[0]
+ if final_range[0] > final_code or final_code > final_range[1]:
+ push_error("final must be in the range " + String(final_range[0]) + "-" + String(final_range[1]))
+ res = res << 8
+ res = res | final_code
+
+ return res
+
+static func ident_to_string(ident: int):
+ var res = PoolStringArray([])
+ while ident:
+ res.append(PoolByteArray([ident & 0xFF]).get_string_from_ascii())
+ ident >>= 8
+ res.invert()
+ return res.join('')
+
+func set_print_handler(target: Object, method: String):
+ _print_handler = { 'target': target, 'method': method }
+
+
+func add_esc_handler(id, target, method):
+ var ident = identifier(id, [0x30, 0x7e])
+ if not _esc_handlers.has(ident):
+ _esc_handlers[ident] = []
+ var handler_list = _esc_handlers[ident]
+ handler_list.append({'target': target, 'method': method})
+
+
+func set_csi_handler(id: Dictionary, target: Object, method: String):
+ _csi_handlers[identifier(id)] = [{ 'target': target, 'method': method }]
+
+
+func set_csi_handler_fallback(target, method):
+ _csi_handler_fb = { 'target': target, 'method': method }
+
+
+func set_execute_handler(flag: int, target: Object, method: String):
+ _execute_handlers[flag] = { 'target': target, 'method': method }
+
+
+func set_execute_handler_fallback(target: Object, method: String):
+ _execute_handler_fb = { 'target': target, 'method': method }
+
+
+func set_esc_handler(id, target, method):
+ _esc_handlers[identifier(id, [0x30, 0x7e])] = [{'target': target, 'method': method}]
+
+
+func set_esc_handler_fallback(target: Object, method: String):
+ _esc_handler_fb = {'target': target, 'method': method}
+
+
+func add_dcs_handler(id, target, method):
+ pass
+ # TODO!!!
+
+func set_dcs_handler(id, target: Object, method: String):
+ _dcs_parser.set_handler(id, {'target': target, 'method': method})
+
+func set_dcs_handler_fallback(target: Object, method: String):
+ _dcs_parser.set_handler_fallback(target, method)
+
+func reset():
+ current_state = initial_state
+ _params.reset()
+ _params.add_param(0) # ZDM
+ _collect = 0
+ preceding_codepoint = 0
+
+func parse(data: Array, length: int):
+ var code = 0
+ var transition = 0
+ var _current_state = current_state
+ var dcs = _dcs_parser
+ var collect = _collect
+ var params = _params
+
+ #print("table", table)
+
+ #print("parse -> data: ", data, " length: ", length)
+
+ # Process input string.
+ var i = 0
+ while i < length:
+ #print("i: ", i)
+ code = data[i]
+
+ #print("code: ", code)
+
+ # Normal transition and action lookup.
+ transition = _transitions[_current_state << TableAccess.INDEX_STATE_SHIFT | code if code < 0xa0 else NON_ASCII_PRINTABLE]
+
+ #print ("transition: ", transition)
+ #print("current state: ", current_state)
+
+ match transition >> TableAccess.TRANSITION_ACTION_SHIFT:
+ ParserAction.PRINT:
+ # read ahead with loop unrolling
+# # Note: 0x20 (SP) is included, 0x7F (DEL) is excluded
+ var j = i + 1
+ while true:
+ code = data[j] if j < data.size() else 0
+ if j >= length or code < 0x20 or (code > 0x7e and code < NON_ASCII_PRINTABLE):
+ _print_handler['target'].call(_print_handler['method'], data, i, j)
+ i = j - 1
+ break
+ j += 1
+ code = data[j] if j < data.size() else 0
+ if j >= length or code < 0x20 or (code > 0x7e and code < NON_ASCII_PRINTABLE):
+ _print_handler['target'].call(_print_handler['method'], data, i, j)
+ i = j - 1
+ break
+ j += 1
+ code = data[j] if j < data.size() else 0
+ if j >= length or code < 0x20 or (code > 0x7e and code < NON_ASCII_PRINTABLE):
+ _print_handler['target'].call(_print_handler['method'], data, i, j)
+ i = j - 1
+ break
+ j += 1
+ code = data[j] if j < data.size() else 0
+ if j >= length or code < 0x20 or (code > 0x7e and code < NON_ASCII_PRINTABLE):
+ _print_handler['target'].call(_print_handler['method'], data, i, j)
+ i = j - 1
+ break
+ j += 1
+ ParserAction.EXECUTE:
+ var handler = _execute_handlers.get(code)
+ if handler:
+ handler['target'].call(handler['method'])
+ elif _execute_handler_fb:
+ _execute_handler_fb['target'].call(_execute_handler_fb['method'], code)
+ preceding_codepoint = 0
+ ParserAction.IGNORE:
+ pass
+ ParserAction.ERROR:
+ print("Parser error!")
+
+ ParserAction.CSI_DISPATCH:
+ # Trigger CSI Handler
+ var handlers = _csi_handlers.get((collect << 8 | code), [])
+ handlers.invert()
+ for handler in handlers:
+ # undefined or true means success and to stop bubbling
+ if handler['target'].call(handler['method'], params.to_array()):
+ continue
+ handlers.invert()
+ if handlers.empty():
+ _csi_handler_fb['target'].call(_csi_handler_fb['method'], collect << 8 | code, params.to_array())
+ preceding_codepoint = 0
+
+
+ ParserAction.PARAM:
+ # Inner loop digits (0x30 - 0x39) and ; (0x3b) and : (0x3a)
+ var do = true
+ while do:
+ match code:
+ 0x3b:
+ params.add_param(0)
+ 0x3a:
+ params.add_sub_param(-1)
+ _:
+ params.add_digit(code - 48)
+ i += 1
+ code = data[i] if i < data.size() else 0
+ do = i < length and code > 0x2f and code < 0x3c
+ i-=1
+
+ ParserAction.COLLECT:
+ collect <<= 8
+ collect |= code
+
+ ParserAction.ESC_DISPATCH:
+ var handlers = _esc_handlers.get((collect << 8 | code), [])
+ handlers.invert()
+ for handler in handlers:
+ # undefined or true means success and to stop bubbling
+ if handler['target'].call(handler['method']) != false:
+ continue
+ handlers.invert()
+ if handlers.empty():
+ _esc_handler_fb['target'].call(_esc_handler_fb['method'], collect << 8 | code)
+ preceding_codepoint = 0
+
+ ParserAction.CLEAR:
+ params.reset()
+ params.add_param(0) # ZDM
+ collect = 0
+
+ ParserAction.DCS_HOOK:
+ dcs.hook(collect << 8 | code, params.to_array())
+
+ ParserAction.DCS_PUT:
+ # inner loop - exit DCS_PUT: 0x18, 0x1a, 0x1b, 0x7f, 0x80 - 0x9f
+ # unhook triggered by: 0x1b, 0x9c (success) and 0x18, 0x1a (abort)
+ for j in range(i + 1, length + 1):
+ code = data[j]
+ if code == 0x18 or code == 0x1a or code == 0x1b or (code > 0x7f and code < NON_ASCII_PRINTABLE):
+ dcs.put(data, i, j)
+ i = j - 1
+ break
+ break
+ ParserAction.DCS_UNHOOK:
+ _dcs_parser.unhook(code != 0x18 and code != 0x1a)
+ if code == 0x1b:
+ transition |= ParserState.ESCAPE
+ params.reset()
+ params.add_param(0) # ZDM
+ collect = 0;
+ preceding_codepoint = 0
+ ParserAction.OSC_START:
+ pass
+
+ ParserAction.OSC_PUT:
+ pass
+
+ ParserAction.OSC_END:
+ pass
+
+ _current_state = transition & TableAccess.TRANSITION_STATE_MASK
+ i += 1
+
+ # save collected intermediates
+ _collect = collect
+
+ # save state
+ current_state = _current_state
diff --git a/addons/godot_xterm/parser/params.gd b/addons/godot_xterm/parser/params.gd
new file mode 100644
index 0000000..ecad77e
--- /dev/null
+++ b/addons/godot_xterm/parser/params.gd
@@ -0,0 +1,104 @@
+# Copyright (c) 2020 The GodotTerm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends Reference
+
+
+# Max value supported for a single param/subparam (clamped to positive int32 range).
+const MAX_VALUE = 0x7FFFFFFF;
+# Max allowed subparams for a single sequence (hardcoded limitation).
+const MAX_SUBPARAMS = 256;
+
+var params = []
+var length = 0
+
+var sub_params = []
+var sub_params_length = 0
+var _max_length
+var _max_sub_params_length
+var sub_params_idx = []
+var _reject_digits = false
+var _reject_sub_digits = false
+var digit_is_sub = false
+
+
+static func from_array(values: Array):
+ # Workaround as per: https://github.com/godotengine/godot/issues/19345#issuecomment-471218401
+ var params = load("res://addons/godot_xterm/parser/params.gd").new()
+ if values.empty():
+ return params
+ # skip leading sub params
+ for i in range(values.size()):
+ var value = values[i]
+ if typeof(value) == TYPE_ARRAY:
+ if i == 0:
+ # skip leading sub params
+ continue
+ else:
+ for sub_param in value:
+ params.add_sub_param(sub_param)
+ else:
+ params.add_param(value)
+ return params
+
+
+func _init(max_length: int = 32, max_sub_params_length: int = 32):
+ _max_length = max_length
+ _max_sub_params_length = max_sub_params_length
+
+ if (max_sub_params_length > MAX_SUBPARAMS):
+ push_error("max_sub_params_length must not be greater than 256")
+
+ params.resize(max_length)
+ sub_params.resize(max_sub_params_length)
+ sub_params_idx.resize(max_length)
+
+func add_param(value: int):
+ digit_is_sub = false
+ if length >= _max_length:
+ _reject_digits = true
+ return
+ if value < -1:
+ push_error('values lesser than -1 are not allowed')
+ sub_params_idx[length] = sub_params_length << 8 | sub_params_length
+ params[length] = MAX_VALUE if value > MAX_VALUE else value
+ length += 1
+
+func add_sub_param(value: int):
+ digit_is_sub = true
+ if !length:
+ return
+ if _reject_digits or sub_params_length >= _max_sub_params_length:
+ _reject_sub_digits = true
+ return
+ if value < -1:
+ push_error('values lesser than -1 are not allowed')
+ sub_params[sub_params_length] = MAX_VALUE if value > MAX_VALUE else value
+ sub_params_length += 1
+ sub_params_idx[length - 1] += 1
+
+func add_digit(value: int):
+ print("adding digit: ", value, " is sub: ", digit_is_sub)
+ var _length = sub_params_length if digit_is_sub else length
+ if _reject_digits or (not _length) or (digit_is_sub and _reject_sub_digits):
+ return
+ var store = sub_params if digit_is_sub else params
+ var cur = store[_length - 1]
+ store[_length - 1] = min(cur * 10 + value, MAX_VALUE) if ~cur else value
+
+func to_array():
+ var res = []
+ for i in range(length):
+ res.append(params[i])
+ var start = sub_params_idx[i] >> 8
+ var end = sub_params_idx[i] & 0xff
+ if end - start > 0:
+ res.append(sub_params.slice(start, end - 1))
+ return res
+
+func reset():
+ length = 0
+ sub_params_length = 0
+ _reject_digits = false
+ _reject_sub_digits = false
+ digit_is_sub = false
diff --git a/addons/godot_xterm/parser/transition_table.gd b/addons/godot_xterm/parser/transition_table.gd
new file mode 100644
index 0000000..a70628c
--- /dev/null
+++ b/addons/godot_xterm/parser/transition_table.gd
@@ -0,0 +1,26 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends Reference
+
+enum TableAccess {
+ TRANSITION_ACTION_SHIFT = 4,
+ TRANSITION_STATE_MASK = 15,
+ INDEX_STATE_SHIFT = 8
+}
+
+var table: PoolByteArray = PoolByteArray()
+
+func _init(length: int):
+ table.resize(length)
+
+func setDefault(action: int, next: int):
+ for i in range(table.size()):
+ table[i] = action << TableAccess.TRANSITION_ACTION_SHIFT | next
+
+func add(code: int, state: int, action: int, next: int):
+ table[state << TableAccess.INDEX_STATE_SHIFT | code] = action << TableAccess.TRANSITION_ACTION_SHIFT | next
+
+func addMany(codes: Array, state: int, action: int, next: int):
+ for code in codes:
+ add(code, state, action, next)
diff --git a/addons/godot_xterm/parser/vt500_transition_table.gd b/addons/godot_xterm/parser/vt500_transition_table.gd
new file mode 100644
index 0000000..101d371
--- /dev/null
+++ b/addons/godot_xterm/parser/vt500_transition_table.gd
@@ -0,0 +1,123 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends "res://addons/godot_xterm/parser/transition_table.gd"
+
+const Constants = preload("res://addons/godot_xterm/parser/constants.gd")
+const ParserState = Constants.ParserState
+const ParserAction = Constants.ParserAction
+const NON_ASCII_PRINTABLE = Constants.NON_ASCII_PRINTABLE
+
+var PRINTABLES = Array(range(0x20, 0x7f)) # 0x20 (SP) included, 0x7f (DEL) excluded.
+var EXECUTABLES = Array(range(0x00, 0x18)) + [0x19] + Array(range(0x1c, 0x20))
+
+func _init().(4096):
+ # Set default transition.
+ setDefault(ParserAction.ERROR, ParserState.GROUND)
+
+ # Printables.
+ addMany(PRINTABLES, ParserState.GROUND, ParserAction.PRINT, ParserState.GROUND)
+
+ # Global anywhere rules.
+ for state in ParserState.values():
+ addMany([0x18, 0x1a, 0x99, 0x9a], state, ParserAction.EXECUTE, ParserState.GROUND)
+ addMany(range(0x80, 0x90), state, ParserAction.EXECUTE, ParserState.GROUND)
+ addMany(range(0x90, 0x98), state, ParserAction.EXECUTE, ParserState.GROUND)
+ add(0x9c, state, ParserAction.IGNORE, ParserState.GROUND) # ST as terminator
+ add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE) # ESC
+ add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING) # OSC
+ addMany([0x98, 0x9e, 0x9f], state, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING)
+ add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY) # CSI
+ add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY) # DCS
+
+ # Rules for executables and 7f.
+ addMany(EXECUTABLES, ParserState.GROUND, ParserAction.EXECUTE, ParserState.GROUND)
+ addMany(EXECUTABLES, ParserState.ESCAPE, ParserAction.EXECUTE, ParserState.ESCAPE)
+ add(0x7f, ParserState.ESCAPE, ParserAction.IGNORE, ParserState.ESCAPE)
+ addMany(EXECUTABLES, ParserState.OSC_STRING, ParserAction.IGNORE, ParserState.OSC_STRING)
+ addMany(EXECUTABLES, ParserState.CSI_ENTRY, ParserAction.EXECUTE, ParserState.CSI_ENTRY)
+ add(0x7f, ParserState.CSI_ENTRY, ParserAction.IGNORE, ParserState.CSI_ENTRY)
+ addMany(EXECUTABLES, ParserState.CSI_PARAM, ParserAction.EXECUTE, ParserState.CSI_PARAM)
+ add(0x7f, ParserState.CSI_PARAM, ParserAction.IGNORE, ParserState.CSI_PARAM);
+ addMany(EXECUTABLES, ParserState.CSI_IGNORE, ParserAction.EXECUTE, ParserState.CSI_IGNORE)
+ addMany(EXECUTABLES, ParserState.CSI_INTERMEDIATE, ParserAction.EXECUTE, ParserState.CSI_INTERMEDIATE)
+ add(0x7f, ParserState.CSI_INTERMEDIATE, ParserAction.IGNORE, ParserState.CSI_INTERMEDIATE)
+ addMany(EXECUTABLES, ParserState.ESCAPE_INTERMEDIATE, ParserAction.EXECUTE, ParserState.ESCAPE_INTERMEDIATE)
+ add(0x7f, ParserState.ESCAPE_INTERMEDIATE, ParserAction.IGNORE, ParserState.ESCAPE_INTERMEDIATE);
+
+ # OSC.
+ add(0x5d, ParserState.ESCAPE, ParserAction.OSC_START, ParserState.OSC_STRING)
+ addMany(PRINTABLES, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING)
+ add(0x7f, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING)
+ addMany([0x9c, 0x1b, 0x18, 0x1a, 0x07], ParserState.OSC_STRING, ParserAction.OSC_END, ParserState.GROUND)
+ addMany(range(0x1c, 0x20), ParserState.OSC_STRING, ParserAction.IGNORE, ParserState.OSC_STRING)
+
+ # SOS/PM/APC does nothing.
+ addMany([0x58, 0x5e, 0x5f], ParserState.ESCAPE, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING)
+ addMany(PRINTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING)
+ addMany(EXECUTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING)
+ add(0x9c, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.GROUND)
+ add(0x7f, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING)
+ # csi entries
+ add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY)
+ addMany(range(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND)
+ addMany(range(0x30, 0x3c), ParserState.CSI_ENTRY, ParserAction.PARAM, ParserState.CSI_PARAM)
+ addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.CSI_ENTRY, ParserAction.COLLECT, ParserState.CSI_PARAM)
+ addMany(range(0x30, 0x3c), ParserState.CSI_PARAM, ParserAction.PARAM, ParserState.CSI_PARAM)
+ addMany(range(0x40, 0x7f), ParserState.CSI_PARAM, ParserAction.CSI_DISPATCH, ParserState.GROUND)
+ addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.CSI_PARAM, ParserAction.IGNORE, ParserState.CSI_IGNORE)
+ addMany(range(0x20, 0x40), ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE)
+ add(0x7f, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE)
+ addMany(range(0x40, 0x7f), ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.GROUND)
+ addMany(range(0x20, 0x30), ParserState.CSI_ENTRY, ParserAction.COLLECT, ParserState.CSI_INTERMEDIATE)
+ addMany(range(0x20, 0x30), ParserState.CSI_INTERMEDIATE, ParserAction.COLLECT, ParserState.CSI_INTERMEDIATE)
+ addMany(range(0x30, 0x40), ParserState.CSI_INTERMEDIATE, ParserAction.IGNORE, ParserState.CSI_IGNORE)
+ addMany(range(0x40, 0x7f), ParserState.CSI_INTERMEDIATE, ParserAction.CSI_DISPATCH, ParserState.GROUND)
+ addMany(range(0x20, 0x30), ParserState.CSI_PARAM, ParserAction.COLLECT, ParserState.CSI_INTERMEDIATE)
+ # esc_intermediate
+ addMany(range(0x20, 0x30), ParserState.ESCAPE, ParserAction.COLLECT, ParserState.ESCAPE_INTERMEDIATE)
+ addMany(range(0x20, 0x30), ParserState.ESCAPE_INTERMEDIATE, ParserAction.COLLECT, ParserState.ESCAPE_INTERMEDIATE)
+ addMany(range(0x30, 0x7f), ParserState.ESCAPE_INTERMEDIATE, ParserAction.ESC_DISPATCH, ParserState.GROUND)
+ addMany(range(0x30, 0x50), ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND)
+ addMany(range(0x51, 0x58), ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND)
+ addMany([0x59, 0x5a, 0x5c], ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND)
+ addMany(range(0x60, 0x7f), ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND);
+
+ # dcs entry
+ add(0x50, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.DCS_ENTRY)
+ addMany(EXECUTABLES, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY)
+ add(0x7f, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY)
+ addMany(range(0x1c, 0x20), ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY)
+ addMany(range(0x20, 0x30), ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE)
+ addMany(range(0x30, 0x3c), ParserState.DCS_ENTRY, ParserAction.PARAM, ParserState.DCS_PARAM)
+ addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_PARAM)
+ addMany(EXECUTABLES, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE)
+ addMany(range(0x20, 0x80), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE)
+ addMany(range(0x1c, 0x20), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE)
+ addMany(EXECUTABLES, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM)
+ add(0x7f, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM)
+ addMany(range(0x1c, 0x20), ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM)
+ addMany(range(0x30, 0x3c), ParserState.DCS_PARAM, ParserAction.PARAM, ParserState.DCS_PARAM)
+ addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_IGNORE)
+ addMany(range(0x20, 0x30), ParserState.DCS_PARAM, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE)
+ addMany(EXECUTABLES, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE)
+ add(0x7f, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE)
+ addMany(range(0x1c, 0x20), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE)
+ addMany(range(0x20, 0x30), ParserState.DCS_INTERMEDIATE, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE)
+ addMany(range(0x30, 0x40), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_IGNORE)
+ addMany(range(0x40, 0x7f), ParserState.DCS_INTERMEDIATE, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH)
+ addMany(range(0x40, 0x7f), ParserState.DCS_PARAM, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH)
+ addMany(range(0x40, 0x7f), ParserState.DCS_ENTRY, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH)
+ addMany(EXECUTABLES, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH)
+ addMany(PRINTABLES, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH)
+ add(0x7f, ParserState.DCS_PASSTHROUGH, ParserAction.IGNORE, ParserState.DCS_PASSTHROUGH)
+ addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.DCS_PASSTHROUGH, ParserAction.DCS_UNHOOK, ParserState.GROUND);
+
+ # special handling of unicode chars
+ add(NON_ASCII_PRINTABLE, ParserState.GROUND, ParserAction.PRINT, ParserState.GROUND)
+ add(NON_ASCII_PRINTABLE, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING)
+ add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE)
+ add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE)
+ add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH)
+
+ return table
diff --git a/addons/godot_xterm/plugin.cfg b/addons/godot_xterm/plugin.cfg
new file mode 100644
index 0000000..ff9fc59
--- /dev/null
+++ b/addons/godot_xterm/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="GodotXterm"
+description="Xterm.js for Godot"
+author="Leroy Hopson"
+version="0.1.0"
+script="plugin.gd"
diff --git a/addons/godot_xterm/plugin.gd b/addons/godot_xterm/plugin.gd
new file mode 100644
index 0000000..4b7d758
--- /dev/null
+++ b/addons/godot_xterm/plugin.gd
@@ -0,0 +1,14 @@
+tool
+extends EditorPlugin
+
+
+func _enter_tree():
+ var script = preload("res://addons/godot_xterm/terminal.gd")
+ var texture = preload("res://addons/godot_xterm/icon.svg")
+ add_custom_type("Terminal", "Control", script, texture)
+ pass
+
+
+func _exit_tree():
+ remove_custom_type("Terminal")
+ pass
diff --git a/addons/godot_xterm/terminal.gd b/addons/godot_xterm/terminal.gd
new file mode 100644
index 0000000..cbd62c6
--- /dev/null
+++ b/addons/godot_xterm/terminal.gd
@@ -0,0 +1,367 @@
+# Copyright (c) 2020 The GodotXterm authors. All rights reserved.
+# License MIT
+tool
+extends Control
+
+
+signal data_sent(data)
+
+const Const = preload("res://addons/godot_xterm/Constants.gd")
+const Constants = preload("res://addons/godot_xterm/parser/constants.gd")
+const Parser = preload("res://addons/godot_xterm/parser/escape_sequence_parser.gd")
+const Buffer = preload("res://addons/godot_xterm/buffer.gd")
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+const SourceCodeProRegular = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres")
+const SourceCodeProBold = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.tres")
+const SourceCodeProItalic = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_italic.tres")
+const SourceCodeProBoldItalic = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold_italic.tres")
+
+const C0 = Constants.C0
+const C1 = Constants.C1
+const ESCAPE = 27
+const BACKSPACE = 8
+const BEEP = 7
+const SPACE = 32
+const LEFT_BRACKET = 91
+const ENTER = 10
+const BACKSPACE_ALT = 127
+
+export (Font) var normal_font = SourceCodeProRegular setget _set_normal_font
+export (Font) var bold_font = SourceCodeProBold setget _set_bold_font
+export (Font) var italic_font = SourceCodeProItalic setget _set_italics_font
+export (Font) var bold_italic_font = SourceCodeProBoldItalic setget _set_bold_italics_font
+var buffer
+var alternate_buffer
+var parser
+var decoder
+var cols = 80
+var rows = 24
+var cell: Vector2
+
+# font flags
+export(int, FLAGS,
+ "Bold",
+ "Italic", # Not xterm-256color
+ "Underlined",
+ "Blink",
+ "Inverse",
+ "Invisible",
+ "Strikethrough" # Not xterm-256color
+ ) var font_flags = Const.FONT_NORMAL
+
+
+func _init():
+ pass
+
+
+func _set_normal_font(font: Font) -> void:
+ normal_font = font
+ _calculate_cell_size()
+
+
+func _set_bold_font(font: Font) -> void:
+ bold_font = font
+ _calculate_cell_size()
+
+
+func _set_italics_font(font: Font) -> void:
+ italic_font = font
+ _calculate_cell_size()
+
+
+func _set_bold_italics_font(font: Font) -> void:
+ bold_italic_font = font
+ _calculate_cell_size()
+
+
+func _calculate_cell_size() -> void:
+ var x = 0.0
+ var y = 0.0
+ var fonts = [normal_font, bold_font, italic_font, bold_italic_font]
+ for font in fonts:
+ if not font:
+ continue
+ var size = font.get_string_size("W")
+ x = max(x, size.x)
+ y = max(y, size.y)
+ cell.x = x
+ cell.y = y
+
+
+func _ready():
+ _calculate_cell_size()
+ var rect = get_rect()
+ var rs = rect_size
+ cols = (rect_size.x / cell.x) as int
+ rows = (rect_size.y / cell.y) as int
+
+ decoder = Decoder.Utf8ToUtf32.new()
+
+ buffer = Buffer.new(rows, cols)
+ alternate_buffer = Buffer.new(rows, cols, true)
+
+ parser = Parser.new()
+
+ # Print handler
+ parser.set_print_handler(buffer, "insert_at_cursor")
+
+ # Execute handlers
+ parser.set_execute_handler(C0.BEL, self, 'bell')
+ parser.set_execute_handler(C0.LF, buffer, 'line_feed')
+ parser.set_execute_handler(C0.VT, buffer, 'line_feed')
+ parser.set_execute_handler(C0.FF, buffer, 'line_feed')
+ parser.set_execute_handler(C0.CR, buffer, 'carriage_return')
+ parser.set_execute_handler(C0.BS, buffer, 'backspace')
+ parser.set_execute_handler(C0.HT, buffer, 'insert_tab');
+ parser.set_execute_handler(C0.SO, self, 'shift_out')
+ parser.set_execute_handler(C0.SI, self, 'shift_in')
+ parser.set_execute_handler(C1.IND, self, 'index')
+ parser.set_execute_handler(C1.NEL, self, 'next_line')
+ parser.set_execute_handler(C1.HTS, self, 'tab_set')
+
+ # CSI handlers
+ parser.set_csi_handler({'final': '@'}, self, 'insert_chars')
+ parser.set_csi_handler({'intermediates': ' ', 'final': '@'}, self, 'scroll_left')
+ parser.set_csi_handler({'final': 'A'}, self, 'cursor_up')
+ parser.set_csi_handler({'intermediates': ' ', 'final': 'A'}, self, 'scroll_right')
+ parser.set_csi_handler({'final': 'B'}, self, 'cursor_down')
+ parser.set_csi_handler({'final': 'C'}, self, 'cursor_forward')
+ parser.set_csi_handler({'final': 'D'}, self, 'cursor_backward')
+ parser.set_csi_handler({'final': 'E'}, self, 'cursor_nextLine')
+ parser.set_csi_handler({'final': 'F'}, self, 'cursor_precedingLine')
+ parser.set_csi_handler({'final': 'G'}, self, 'cursor_charAbsolute')
+ parser.set_csi_handler({'final': 'H'}, buffer, 'cursor_position')
+ parser.set_csi_handler({'final': 'I'}, self, 'cursor_forward_tab')
+ parser.set_csi_handler({'final': 'J'}, self, 'erase_in_display')
+ parser.set_csi_handler({'prefix': '?', 'final': 'J'}, self, 'erase_in_display')
+ parser.set_csi_handler({'final': 'K'}, self, 'erase_in_line')
+ parser.set_csi_handler({'prefix': '?', 'final': 'K'}, self, 'erase_in_line')
+ parser.set_csi_handler({'final': 'L'}, self, 'insert_lines')
+ parser.set_csi_handler({'final': 'M'}, self, 'delete_lines')
+ parser.set_csi_handler({'final': 'P'}, self, 'delete_chars')
+ parser.set_csi_handler({'final': 'S'}, self, 'scroll_up')
+ parser.set_csi_handler({'final': 'T'}, self, 'scroll_down')
+ parser.set_csi_handler({'final': 'X'}, self, 'erase_chars')
+ parser.set_csi_handler({'final': 'Z'}, self, 'cursor_backward_tab')
+ parser.set_csi_handler({'final': '`'}, self, 'char_pos_absolute')
+ parser.set_csi_handler({'final': 'a'}, self, 'h_position_relative')
+ parser.set_csi_handler({'final': 'b'}, self, 'repeat_preceding_character')
+ parser.set_csi_handler({'final': 'c'}, self, 'send_device_attributes_primary')
+ parser.set_csi_handler({'prefix': '>', 'final': 'c'}, self, 'send_device_attributes_secondary')
+ parser.set_csi_handler({'final': 'd'}, self, 'line_pos_absolute')
+ parser.set_csi_handler({'final': 'e'}, self, 'v_position_relative')
+ parser.set_csi_handler({'final': 'f'}, self, 'h_v_position')
+ parser.set_csi_handler({'final': 'g'}, self, 'tab_clear')
+ parser.set_csi_handler({'final': 'h'}, self, 'set_mode')
+ parser.set_csi_handler({'prefix': '?', 'final': 'h'}, self, 'set_mode_private')
+ parser.set_csi_handler({'final': 'l'}, self, 'reset_mode')
+ parser.set_csi_handler({'prefix': '?', 'final': 'l'}, self, 'reset_mode_private')
+ parser.set_csi_handler({'final': 'm'}, self, 'char_attributes')
+ parser.set_csi_handler({'final': 'n'}, self, 'device_status')
+ parser.set_csi_handler({'prefix': '?', 'final': 'n'}, self, 'device_status_private')
+ parser.set_csi_handler({'intermediates': '!', 'final': 'p'}, self, 'soft_reset')
+ parser.set_csi_handler({'intermediates': ' ', 'final': 'q'}, self, 'set_cursor_style')
+ parser.set_csi_handler({'final': 'r'}, self, 'set_scroll_region')
+ parser.set_csi_handler({'final': 's'}, self, 'save_cursor')
+ parser.set_csi_handler({'final': 't'}, self, 'window_options')
+ parser.set_csi_handler({'final': 'u'}, self, 'restore_cursor')
+ parser.set_csi_handler({'intermediates': '\'', 'final': '}'}, self, 'insert_columns')
+ parser.set_csi_handler({'intermediates': '\'', 'final': '~'}, self, 'delete_columns')
+
+func print(data, start, end):
+ print(data.substr(start, end))
+
+func bell():
+ print("The bell signal was emited!")
+
+func line_feed():
+ pass
+
+func carriage_return():
+ print("carriage return!")
+
+func backspace():
+ print("backspace!")
+ pass
+
+func tab():
+ pass
+
+func shift_out():
+ pass
+
+func shift_in():
+ pass
+
+func index():
+ pass
+
+func next_line():
+ pass
+
+func tab_set():
+ pass
+
+func insert_chars(params):
+ pass
+
+func scroll_left(params):
+ pass
+func cursor_up(params):
+ pass
+func scroll_right(params):
+ pass
+func cursor_down(params):
+ pass
+func cursor_forward(params):
+ pass
+func cursor_backward(params):
+ pass
+func cursor_next_line(params):
+ pass
+func cursor_preceding_line(params):
+ pass
+func cursor_char_absolute(params):
+ pass
+func cursor_position(params):
+ pass
+func cursor_forward_tab(params):
+ pass
+func erase_in_display(params):
+ pass
+func erase_in_line(params):
+ pass
+func insert_lines(params):
+ pass
+func delete_lines(params):
+ pass
+func delete_chars(params):
+ pass
+func scroll_up(params):
+ pass
+func scroll_down(params):
+ pass
+func erase_chars(params):
+ pass
+func cursor_backward_tab(params):
+ pass
+func char_pos_absolute(params):
+ pass
+func h_position_relative(params):
+ pass
+func repeat_preceding_character(params):
+ pass
+func send_device_attributes_primary(params):
+ pass
+func send_device_attributes_secondary(params):
+ pass
+func line_pos_absolute(params):
+ pass
+func v_position_relative(params):
+ pass
+func h_v_position(params):
+ pass
+func tab_clear(params):
+ pass
+func set_mode(params):
+ pass
+func set_mode_private(params):
+ pass
+func reset_mode(params):
+ pass
+func char_attributes(params):
+ pass
+func device_status(params):
+ pass
+func device_status_private(params):
+ pass
+func soft_reset(params):
+ pass
+func set_cursor_style(params):
+ pass
+func set_scroll_region(params):
+ pass
+func save_cursor(params):
+ pass
+func window_options(params):
+ pass
+func restore_cursor(params):
+ pass
+func insert_columns(params):
+ pass
+func delete_columns(params):
+ pass
+
+func _input(event):
+ if event is InputEventKey and event.pressed:
+ accept_event()
+
+ # TODO: Handle more of these.
+ if (event.control and event.scancode == KEY_C):
+ send_data(PoolByteArray([3]))
+ elif event.unicode:
+ send_data(PoolByteArray([event.unicode]))
+ elif event.scancode == KEY_ENTER:
+ send_data(PoolByteArray([ENTER]))
+ elif event.scancode == KEY_BACKSPACE:
+ send_data(PoolByteArray([BACKSPACE_ALT]))
+ elif event.scancode == KEY_ESCAPE:
+ send_data(PoolByteArray([27]))
+ elif event.scancode == KEY_TAB:
+ send_data(PoolByteArray([9]))
+ elif OS.get_scancode_string(event.scancode) == "Shift":
+ pass
+ elif OS.get_scancode_string(event.scancode) == "Control":
+ pass
+ else:
+ push_warning('Unhandled input. scancode: ' + str(OS.get_scancode_string(event.scancode)))
+
+
+func send_data(data: PoolByteArray):
+ emit_signal("data_sent", data)
+
+
+func _draw():
+ # Draw the terminal background
+ draw_rect(get_rect(), Color(0.0, 0.5, 0.0))
+
+ # Naive method. Draw the entire buffer starting with row 0.
+ for row in range(buffer.rows.size()):
+ #print("Doing the thing for row: ", row)
+ # Draw each CharacterData.
+ for col in range(buffer.rows[row].size()):
+ var data = buffer.rows[row][col]
+ #print("row: ", ((row + 1) * charHeight), " col: ", (col * charWidth))
+ _draw_character(col, row, data)
+
+ # Draw the cursor.
+ _draw_cursor()
+
+
+func _draw_character(col, row, data):
+ # Draw the background.
+ draw_rect(Rect2(Vector2(col * cell.x, row * cell.y), Vector2(cell.x, cell.y)), data.bg)
+
+ var font
+ if data.ff & (1 << Const.FONT_BOLD) and data.ff & (1 << Const.FONT_ITALIC):
+ font = bold_italic_font
+ elif data.ff & (1 << Const.FONT_BOLD):
+ font = bold_font
+ elif data.ff & (1 << Const.FONT_ITALIC):
+ font = italic_font
+ else:
+ font = normal_font
+
+ # Draw the character using foreground color.
+ draw_char(font, Vector2(col * cell.x, (row + 1) * cell.y), data.ch, '', data.fg)
+
+
+func _draw_cursor():
+ draw_rect(Rect2(Vector2(buffer.ccol * cell.x, buffer.crow * cell.y), Vector2(cell.x, cell.y)), Color(1.0, 0.0, 1.0))
+
+
+func receive_data(data: PoolByteArray):
+ var utf32 = []
+ var length = decoder.decode(data, utf32)
+ parser.parse(utf32, length)
+ update()
diff --git a/addons/gut/GutScene.gd b/addons/gut/GutScene.gd
new file mode 100644
index 0000000..4139dd4
--- /dev/null
+++ b/addons/gut/GutScene.gd
@@ -0,0 +1,347 @@
+extends Panel
+
+onready var _script_list = $ScriptsList
+onready var _nav = {
+ prev = $Navigation/Previous,
+ next = $Navigation/Next,
+ run = $Navigation/Run,
+ current_script = $Navigation/CurrentScript,
+ show_scripts = $Navigation/ShowScripts
+}
+onready var _progress = {
+ script = $ScriptProgress,
+ test = $TestProgress
+}
+onready var _summary = {
+ failing = $Summary/Failing,
+ passing = $Summary/Passing
+}
+
+onready var _extras = $ExtraOptions
+onready var _ignore_pauses = $ExtraOptions/IgnorePause
+onready var _continue_button = $Continue/Continue
+onready var _text_box = $TextDisplay/RichTextLabel
+
+onready var _titlebar = {
+ bar = $TitleBar,
+ time = $TitleBar/Time,
+ label = $TitleBar/Title
+}
+
+var _mouse = {
+ down = false,
+ in_title = false,
+ down_pos = null,
+ in_handle = false
+}
+var _is_running = false
+var _start_time = 0.0
+var _time = 0.0
+
+const DEFAULT_TITLE = 'Gut: The Godot Unit Testing tool.'
+var _utils = load('res://addons/gut/utils.gd').new()
+var _text_box_blocker_enabled = true
+var _pre_maximize_size = null
+
+signal end_pause
+signal ignore_pause
+signal log_level_changed
+signal run_script
+signal run_single_script
+
+func _ready():
+ _pre_maximize_size = rect_size
+ _hide_scripts()
+ _update_controls()
+ _nav.current_script.set_text("No scripts available")
+ set_title()
+ clear_summary()
+ $TitleBar/Time.set_text("")
+ $ExtraOptions/DisableBlocker.pressed = !_text_box_blocker_enabled
+ _extras.visible = false
+ update()
+
+func _process(_delta):
+ if(_is_running):
+ _time = OS.get_unix_time() - _start_time
+ var disp_time = round(_time * 100)/100
+ $TitleBar/Time.set_text(str(disp_time))
+
+func _draw(): # needs get_size()
+ # Draw the lines in the corner to show where you can
+ # drag to resize the dialog
+ var grab_margin = 3
+ var line_space = 3
+ var grab_line_color = Color(.4, .4, .4)
+ for i in range(1, 10):
+ var x = rect_size - Vector2(i * line_space, grab_margin)
+ var y = rect_size - Vector2(grab_margin, i * line_space)
+ draw_line(x, y, grab_line_color, 1, true)
+
+func _on_Maximize_draw():
+ # draw the maximize square thing.
+ var btn = $TitleBar/Maximize
+ btn.set_text('')
+ var w = btn.get_size().x
+ var h = btn.get_size().y
+ btn.draw_rect(Rect2(0, 0, w, h), Color(0, 0, 0, 1))
+ btn.draw_rect(Rect2(2, 4, w - 4, h - 6), Color(1,1,1,1))
+
+func _on_ShowExtras_draw():
+ var btn = $Continue/ShowExtras
+ btn.set_text('')
+ var start_x = 20
+ var start_y = 15
+ var pad = 5
+ var color = Color(.1, .1, .1, 1)
+ var width = 2
+ for i in range(3):
+ var y = start_y + pad * i
+ btn.draw_line(Vector2(start_x, y), Vector2(btn.get_size().x - start_x, y), color, width, true)
+
+# ####################
+# GUI Events
+# ####################
+func _on_Run_pressed():
+ _run_mode()
+ emit_signal('run_script', get_selected_index())
+
+func _on_CurrentScript_pressed():
+ _run_mode()
+ emit_signal('run_single_script', get_selected_index())
+
+func _on_Previous_pressed():
+ _select_script(get_selected_index() - 1)
+
+func _on_Next_pressed():
+ _select_script(get_selected_index() + 1)
+
+func _on_LogLevelSlider_value_changed(_value):
+ emit_signal('log_level_changed', $LogLevelSlider.value)
+
+func _on_Continue_pressed():
+ _continue_button.disabled = true
+ emit_signal('end_pause')
+
+func _on_IgnorePause_pressed():
+ var checked = _ignore_pauses.is_pressed()
+ emit_signal('ignore_pause', checked)
+ if(checked):
+ emit_signal('end_pause')
+ _continue_button.disabled = true
+
+func _on_ShowScripts_pressed():
+ _toggle_scripts()
+
+func _on_ScriptsList_item_selected(index):
+ _select_script(index)
+
+func _on_TitleBar_mouse_entered():
+ _mouse.in_title = true
+
+func _on_TitleBar_mouse_exited():
+ _mouse.in_title = false
+
+func _input(event):
+ if(event is InputEventMouseButton):
+ if(event.button_index == 1):
+ _mouse.down = event.pressed
+ if(_mouse.down):
+ _mouse.down_pos = event.position
+
+ if(_mouse.in_title):
+ if(event is InputEventMouseMotion and _mouse.down):
+ set_position(get_position() + (event.position - _mouse.down_pos))
+ _mouse.down_pos = event.position
+
+ if(_mouse.in_handle):
+ if(event is InputEventMouseMotion and _mouse.down):
+ var new_size = rect_size + event.position - _mouse.down_pos
+ var new_mouse_down_pos = event.position
+ rect_size = new_size
+ _mouse.down_pos = new_mouse_down_pos
+ _pre_maximize_size = rect_size
+
+func _on_ResizeHandle_mouse_entered():
+ _mouse.in_handle = true
+
+func _on_ResizeHandle_mouse_exited():
+ _mouse.in_handle = false
+
+# Send scroll type events through to the text box
+func _on_FocusBlocker_gui_input(ev):
+ if(_text_box_blocker_enabled):
+ if(ev is InputEventPanGesture):
+ get_text_box()._gui_input(ev)
+ # convert a drag into a pan gesture so it scrolls.
+ elif(ev is InputEventScreenDrag):
+ var converted = InputEventPanGesture.new()
+ converted.delta = Vector2(0, ev.relative.y)
+ converted.position = Vector2(0, 0)
+ get_text_box()._gui_input(converted)
+ elif(ev is InputEventMouseButton and (ev.button_index == BUTTON_WHEEL_DOWN or ev.button_index == BUTTON_WHEEL_UP)):
+ get_text_box()._gui_input(ev)
+ else:
+ get_text_box()._gui_input(ev)
+ print(ev)
+
+func _on_RichTextLabel_gui_input(ev):
+ pass
+ # leaving this b/c it is wired up and might have to send
+ # more signals through
+ print(ev)
+
+func _on_Copy_pressed():
+ _text_box.select_all()
+ _text_box.copy()
+ _text_box.deselect()
+
+func _on_DisableBlocker_toggled(button_pressed):
+ _text_box_blocker_enabled = !button_pressed
+
+func _on_ShowExtras_toggled(button_pressed):
+ _extras.visible = button_pressed
+
+func _on_Maximize_pressed():
+ if(rect_size == _pre_maximize_size):
+ maximize()
+ else:
+ rect_size = _pre_maximize_size
+# ####################
+# Private
+# ####################
+func _run_mode(is_running=true):
+ if(is_running):
+ _start_time = OS.get_unix_time()
+ _time = _start_time
+ _summary.failing.set_text("0")
+ _summary.passing.set_text("0")
+ _is_running = is_running
+
+ _hide_scripts()
+ var ctrls = $Navigation.get_children()
+ for i in range(ctrls.size()):
+ ctrls[i].disabled = is_running
+
+func _select_script(index):
+ $Navigation/CurrentScript.set_text(_script_list.get_item_text(index))
+ _script_list.select(index)
+ _update_controls()
+
+func _toggle_scripts():
+ if(_script_list.visible):
+ _hide_scripts()
+ else:
+ _show_scripts()
+
+func _show_scripts():
+ _script_list.show()
+
+func _hide_scripts():
+ _script_list.hide()
+
+func _update_controls():
+ var is_empty = _script_list.get_selected_items().size() == 0
+ if(is_empty):
+ _nav.next.disabled = true
+ _nav.prev.disabled = true
+ else:
+ var index = get_selected_index()
+ _nav.prev.disabled = index <= 0
+ _nav.next.disabled = index >= _script_list.get_item_count() - 1
+
+ _nav.run.disabled = is_empty
+ _nav.current_script.disabled = is_empty
+ _nav.show_scripts.disabled = is_empty
+
+
+# ####################
+# Public
+# ####################
+func run_mode(is_running=true):
+ _run_mode(is_running)
+
+func set_scripts(scripts):
+ _script_list.clear()
+ for i in range(scripts.size()):
+ _script_list.add_item(scripts[i])
+ _select_script(0)
+ _update_controls()
+
+func select_script(index):
+ _select_script(index)
+
+func get_selected_index():
+ return _script_list.get_selected_items()[0]
+
+func get_log_level():
+ return $LogLevelSlider.value
+
+func set_log_level(value):
+ $LogLevelSlider.value = _utils.nvl(value, 0)
+
+func set_ignore_pause(should):
+ _ignore_pauses.pressed = should
+
+func get_ignore_pause():
+ return _ignore_pauses.pressed
+
+func get_text_box():
+ return $TextDisplay/RichTextLabel
+
+func end_run():
+ _run_mode(false)
+ _update_controls()
+
+func set_progress_script_max(value):
+ _progress.script.set_max(max(value, 1))
+
+func set_progress_script_value(value):
+ _progress.script.set_value(value)
+
+func set_progress_test_max(value):
+ _progress.test.set_max(max(value, 1))
+
+func set_progress_test_value(value):
+ _progress.test.set_value(value)
+
+func clear_progress():
+ _progress.test.set_value(0)
+ _progress.script.set_value(0)
+
+func pause():
+ print('we got here')
+ _continue_button.disabled = false
+
+func set_title(title=null):
+ if(title == null):
+ $TitleBar/Title.set_text(DEFAULT_TITLE)
+ else:
+ $TitleBar/Title.set_text(title)
+
+func get_run_duration():
+ return $TitleBar/Time.text.to_float()
+
+func add_passing(amount=1):
+ if(!_summary):
+ return
+ _summary.passing.set_text(str(_summary.passing.get_text().to_int() + amount))
+ $Summary.show()
+
+func add_failing(amount=1):
+ if(!_summary):
+ return
+ _summary.failing.set_text(str(_summary.failing.get_text().to_int() + amount))
+ $Summary.show()
+
+func clear_summary():
+ _summary.passing.set_text("0")
+ _summary.failing.set_text("0")
+ $Summary.hide()
+
+func maximize():
+ if(is_inside_tree()):
+ var vp_size_offset = get_viewport().size
+ rect_size = vp_size_offset / get_scale()
+ set_position(Vector2(0, 0))
+
diff --git a/addons/gut/GutScene.tscn b/addons/gut/GutScene.tscn
new file mode 100644
index 0000000..8d56a4b
--- /dev/null
+++ b/addons/gut/GutScene.tscn
@@ -0,0 +1,299 @@
+[gd_scene load_steps=5 format=2]
+
+[ext_resource path="res://addons/gut/GutScene.gd" type="Script" id=1]
+
+[sub_resource type="StyleBoxFlat" id=1]
+bg_color = Color( 0.193863, 0.205501, 0.214844, 1 )
+corner_radius_top_left = 20
+corner_radius_top_right = 20
+
+[sub_resource type="StyleBoxFlat" id=2]
+bg_color = Color( 1, 1, 1, 1 )
+border_color = Color( 0, 0, 0, 1 )
+corner_radius_top_left = 5
+corner_radius_top_right = 5
+
+[sub_resource type="Theme" id=3]
+resource_local_to_scene = true
+Panel/styles/panel = SubResource( 2 )
+Panel/styles/panelf = null
+Panel/styles/panelnc = null
+
+[node name="Gut" type="Panel"]
+margin_right = 740.0
+margin_bottom = 320.0
+rect_min_size = Vector2( 740, 250 )
+custom_styles/panel = SubResource( 1 )
+script = ExtResource( 1 )
+
+[node name="TitleBar" type="Panel" parent="."]
+anchor_right = 1.0
+margin_bottom = 40.0
+theme = SubResource( 3 )
+
+[node name="Title" type="Label" parent="TitleBar"]
+anchor_right = 1.0
+margin_bottom = 40.0
+custom_colors/font_color = Color( 0, 0, 0, 1 )
+text = "Gut"
+align = 1
+valign = 1
+
+[node name="Time" type="Label" parent="TitleBar"]
+anchor_left = 1.0
+anchor_right = 1.0
+margin_left = -114.0
+margin_right = -53.0
+margin_bottom = 40.0
+custom_colors/font_color = Color( 0, 0, 0, 1 )
+text = "9999.99"
+valign = 1
+
+[node name="Maximize" type="Button" parent="TitleBar"]
+anchor_left = 1.0
+anchor_right = 1.0
+margin_left = -30.0
+margin_top = 10.0
+margin_right = -6.0
+margin_bottom = 30.0
+custom_colors/font_color = Color( 0, 0, 0, 1 )
+text = "M"
+flat = true
+
+[node name="ScriptProgress" type="ProgressBar" parent="."]
+anchor_top = 1.0
+anchor_bottom = 1.0
+margin_left = 70.0
+margin_top = -100.0
+margin_right = 180.0
+margin_bottom = -70.0
+step = 1.0
+
+[node name="Label" type="Label" parent="ScriptProgress"]
+margin_left = -70.0
+margin_right = -10.0
+margin_bottom = 24.0
+text = "Scripts"
+align = 1
+valign = 1
+
+[node name="TestProgress" type="ProgressBar" parent="."]
+anchor_top = 1.0
+anchor_bottom = 1.0
+margin_left = 70.0
+margin_top = -70.0
+margin_right = 180.0
+margin_bottom = -40.0
+step = 1.0
+
+[node name="Label" type="Label" parent="TestProgress"]
+margin_left = -70.0
+margin_right = -10.0
+margin_bottom = 24.0
+text = "Tests"
+align = 1
+valign = 1
+
+[node name="TextDisplay" type="Panel" parent="."]
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_top = 40.0
+margin_bottom = -107.0
+__meta__ = {
+"_edit_group_": true
+}
+
+[node name="RichTextLabel" type="TextEdit" parent="TextDisplay"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_default_cursor_shape = 0
+readonly = true
+syntax_highlighting = true
+smooth_scrolling = true
+
+[node name="FocusBlocker" type="Panel" parent="TextDisplay"]
+self_modulate = Color( 1, 1, 1, 0 )
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_right = -10.0
+
+[node name="Navigation" type="Panel" parent="."]
+self_modulate = Color( 1, 1, 1, 0 )
+anchor_top = 1.0
+anchor_bottom = 1.0
+margin_left = 220.0
+margin_top = -100.0
+margin_right = 580.0
+
+[node name="Previous" type="Button" parent="Navigation"]
+margin_left = -30.0
+margin_right = 50.0
+margin_bottom = 40.0
+text = "<"
+
+[node name="Next" type="Button" parent="Navigation"]
+margin_left = 230.0
+margin_right = 310.0
+margin_bottom = 40.0
+text = ">"
+
+[node name="Run" type="Button" parent="Navigation"]
+margin_left = 60.0
+margin_right = 220.0
+margin_bottom = 40.0
+text = "Run"
+
+[node name="CurrentScript" type="Button" parent="Navigation"]
+margin_left = -30.0
+margin_top = 50.0
+margin_right = 310.0
+margin_bottom = 90.0
+text = "res://test/unit/test_gut.gd"
+clip_text = true
+
+[node name="ShowScripts" type="Button" parent="Navigation"]
+margin_left = 320.0
+margin_top = 50.0
+margin_right = 360.0
+margin_bottom = 90.0
+text = "..."
+
+[node name="LogLevelSlider" type="HSlider" parent="."]
+anchor_top = 1.0
+anchor_bottom = 1.0
+margin_left = 80.0
+margin_top = -40.0
+margin_right = 130.0
+margin_bottom = -20.0
+rect_scale = Vector2( 2, 2 )
+max_value = 2.0
+tick_count = 3
+ticks_on_borders = true
+
+[node name="Label" type="Label" parent="LogLevelSlider"]
+margin_left = -35.0
+margin_top = 5.0
+margin_right = 25.0
+margin_bottom = 25.0
+rect_scale = Vector2( 0.5, 0.5 )
+text = "Log Level"
+align = 1
+valign = 1
+
+[node name="ScriptsList" type="ItemList" parent="."]
+anchor_bottom = 1.0
+margin_left = 180.0
+margin_top = 40.0
+margin_right = 620.0
+margin_bottom = -108.0
+allow_reselect = true
+
+[node name="ExtraOptions" type="Panel" parent="."]
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_left = -210.0
+margin_top = -246.0
+margin_bottom = -106.0
+custom_styles/panel = SubResource( 1 )
+
+[node name="IgnorePause" type="CheckBox" parent="ExtraOptions"]
+margin_left = 10.0
+margin_top = 10.0
+margin_right = 128.0
+margin_bottom = 34.0
+rect_scale = Vector2( 1.5, 1.5 )
+text = "Ignore Pauses"
+
+[node name="DisableBlocker" type="CheckBox" parent="ExtraOptions"]
+margin_left = 10.0
+margin_top = 50.0
+margin_right = 130.0
+margin_bottom = 74.0
+rect_scale = Vector2( 1.5, 1.5 )
+text = "Selectable"
+
+[node name="Copy" type="Button" parent="ExtraOptions"]
+margin_left = 20.0
+margin_top = 90.0
+margin_right = 200.0
+margin_bottom = 130.0
+text = "Copy"
+
+[node name="ResizeHandle" type="Control" parent="."]
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_left = -40.0
+margin_top = -40.0
+
+[node name="Continue" type="Panel" parent="."]
+self_modulate = Color( 1, 1, 1, 0 )
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_left = -150.0
+margin_top = -100.0
+margin_right = -30.0
+margin_bottom = -10.0
+
+[node name="Continue" type="Button" parent="Continue"]
+margin_top = 50.0
+margin_right = 119.0
+margin_bottom = 90.0
+disabled = true
+text = "Continue"
+
+[node name="ShowExtras" type="Button" parent="Continue"]
+margin_left = 50.0
+margin_right = 120.0
+margin_bottom = 40.0
+rect_pivot_offset = Vector2( 35, 20 )
+toggle_mode = true
+text = "_"
+
+[node name="Summary" type="Node2D" parent="."]
+position = Vector2( 0, 3 )
+
+[node name="Passing" type="Label" parent="Summary"]
+margin_top = 10.0
+margin_right = 40.0
+margin_bottom = 24.0
+custom_colors/font_color = Color( 0, 0, 0, 1 )
+text = "0"
+align = 1
+valign = 1
+
+[node name="Failing" type="Label" parent="Summary"]
+margin_left = 40.0
+margin_top = 10.0
+margin_right = 80.0
+margin_bottom = 24.0
+custom_colors/font_color = Color( 0, 0, 0, 1 )
+text = "0"
+align = 1
+valign = 1
+[connection signal="mouse_entered" from="TitleBar" to="." method="_on_TitleBar_mouse_entered"]
+[connection signal="mouse_exited" from="TitleBar" to="." method="_on_TitleBar_mouse_exited"]
+[connection signal="draw" from="TitleBar/Maximize" to="." method="_on_Maximize_draw"]
+[connection signal="pressed" from="TitleBar/Maximize" to="." method="_on_Maximize_pressed"]
+[connection signal="gui_input" from="TextDisplay/RichTextLabel" to="." method="_on_RichTextLabel_gui_input"]
+[connection signal="gui_input" from="TextDisplay/FocusBlocker" to="." method="_on_FocusBlocker_gui_input"]
+[connection signal="pressed" from="Navigation/Previous" to="." method="_on_Previous_pressed"]
+[connection signal="pressed" from="Navigation/Next" to="." method="_on_Next_pressed"]
+[connection signal="pressed" from="Navigation/Run" to="." method="_on_Run_pressed"]
+[connection signal="pressed" from="Navigation/CurrentScript" to="." method="_on_CurrentScript_pressed"]
+[connection signal="pressed" from="Navigation/ShowScripts" to="." method="_on_ShowScripts_pressed"]
+[connection signal="value_changed" from="LogLevelSlider" to="." method="_on_LogLevelSlider_value_changed"]
+[connection signal="item_selected" from="ScriptsList" to="." method="_on_ScriptsList_item_selected"]
+[connection signal="pressed" from="ExtraOptions/IgnorePause" to="." method="_on_IgnorePause_pressed"]
+[connection signal="toggled" from="ExtraOptions/DisableBlocker" to="." method="_on_DisableBlocker_toggled"]
+[connection signal="pressed" from="ExtraOptions/Copy" to="." method="_on_Copy_pressed"]
+[connection signal="mouse_entered" from="ResizeHandle" to="." method="_on_ResizeHandle_mouse_entered"]
+[connection signal="mouse_exited" from="ResizeHandle" to="." method="_on_ResizeHandle_mouse_exited"]
+[connection signal="pressed" from="Continue/Continue" to="." method="_on_Continue_pressed"]
+[connection signal="draw" from="Continue/ShowExtras" to="." method="_on_ShowExtras_draw"]
+[connection signal="toggled" from="Continue/ShowExtras" to="." method="_on_ShowExtras_toggled"]
diff --git a/addons/gut/LICENSE.md b/addons/gut/LICENSE.md
new file mode 100644
index 0000000..a38ac23
--- /dev/null
+++ b/addons/gut/LICENSE.md
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+=====================
+
+Copyright (c) 2018 Tom "Butch" Wesley
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/addons/gut/double_templates/function_template.gd b/addons/gut/double_templates/function_template.gd
new file mode 100644
index 0000000..666952e
--- /dev/null
+++ b/addons/gut/double_templates/function_template.gd
@@ -0,0 +1,6 @@
+{func_decleration}
+ __gut_spy('{method_name}', {param_array})
+ if(__gut_should_call_super('{method_name}', {param_array})):
+ return {super_call}
+ else:
+ return __gut_get_stubbed_return('{method_name}', {param_array})
diff --git a/addons/gut/double_templates/script_template.gd b/addons/gut/double_templates/script_template.gd
new file mode 100644
index 0000000..4766a30
--- /dev/null
+++ b/addons/gut/double_templates/script_template.gd
@@ -0,0 +1,36 @@
+{extends}
+
+var __gut_metadata_ = {
+ path = '{path}',
+ subpath = '{subpath}',
+ stubber = __gut_instance_from_id({stubber_id}),
+ spy = __gut_instance_from_id({spy_id}),
+}
+
+func __gut_instance_from_id(inst_id):
+ if(inst_id == -1):
+ return null
+ else:
+ return instance_from_id(inst_id)
+
+func __gut_should_call_super(method_name, called_with):
+ if(__gut_metadata_.stubber != null):
+ return __gut_metadata_.stubber.should_call_super(self, method_name, called_with)
+ else:
+ return false
+
+var __gut_utils_ = load('res://addons/gut/utils.gd').new()
+
+func __gut_spy(method_name, called_with):
+ if(__gut_metadata_.spy != null):
+ __gut_metadata_.spy.add_call(self, method_name, called_with)
+
+func __gut_get_stubbed_return(method_name, called_with):
+ if(__gut_metadata_.stubber != null):
+ return __gut_metadata_.stubber.get_return(self, method_name, called_with)
+ else:
+ return null
+
+# ------------------------------------------------------------------------------
+# Methods start here
+# ------------------------------------------------------------------------------
diff --git a/addons/gut/doubler.gd b/addons/gut/doubler.gd
new file mode 100644
index 0000000..790e566
--- /dev/null
+++ b/addons/gut/doubler.gd
@@ -0,0 +1,525 @@
+# ------------------------------------------------------------------------------
+# Utility class to hold the local and built in methods separately. Add all local
+# methods FIRST, then add built ins.
+# ------------------------------------------------------------------------------
+class ScriptMethods:
+ # List of methods that should not be overloaded when they are not defined
+ # in the class being doubled. These either break things if they are
+ # overloaded or do not have a "super" equivalent so we can't just pass
+ # through.
+ var _blacklist = [
+ 'has_method',
+ 'get_script',
+ 'get',
+ '_notification',
+ 'get_path',
+ '_enter_tree',
+ '_exit_tree',
+ '_process',
+ '_draw',
+ '_physics_process',
+ '_input',
+ '_unhandled_input',
+ '_unhandled_key_input',
+ '_set',
+ '_get', # probably
+ 'emit_signal', # can't handle extra parameters to be sent with signal.
+ 'draw_mesh', # issue with one parameter, value is `Null((..), (..), (..))``
+ '_to_string', # nonexistant function ._to_string
+ '_get_minimum_size', # Nonexistent function _get_minimum_size
+ ]
+
+ var built_ins = []
+ var local_methods = []
+ var _method_names = []
+
+ func is_blacklisted(method_meta):
+ return _blacklist.find(method_meta.name) != -1
+
+ func _add_name_if_does_not_have(method_name):
+ var should_add = _method_names.find(method_name) == -1
+ if(should_add):
+ _method_names.append(method_name)
+ return should_add
+
+ func add_built_in_method(method_meta):
+ var did_add = _add_name_if_does_not_have(method_meta.name)
+ if(did_add and !is_blacklisted(method_meta)):
+ built_ins.append(method_meta)
+
+ func add_local_method(method_meta):
+ var did_add = _add_name_if_does_not_have(method_meta.name)
+ if(did_add):
+ local_methods.append(method_meta)
+
+ func to_s():
+ var text = "Locals\n"
+ for i in range(local_methods.size()):
+ text += str(" ", local_methods[i].name, "\n")
+ text += "Built-Ins\n"
+ for i in range(built_ins.size()):
+ text += str(" ", built_ins[i].name, "\n")
+ return text
+
+# ------------------------------------------------------------------------------
+# Helper class to deal with objects and inner classes.
+# ------------------------------------------------------------------------------
+class ObjectInfo:
+ var _path = null
+ var _subpaths = []
+ var _utils = load('res://addons/gut/utils.gd').new()
+ var _method_strategy = null
+ var make_partial_double = false
+ var scene_path = null
+ var _native_class = null
+ var _native_class_instance = null
+
+ func _init(path, subpath=null):
+ _path = path
+ if(subpath != null):
+ _subpaths = _utils.split_string(subpath, '/')
+
+ # Returns an instance of the class/inner class
+ func instantiate():
+ var to_return = null
+ if(is_native()):
+ to_return = _native_class.new()
+ else:
+ to_return = get_loaded_class().new()
+ return to_return
+
+ # Can't call it get_class because that is reserved so it gets this ugly name.
+ # Loads up the class and then any inner classes to give back a reference to
+ # the desired Inner class (if there is any)
+ func get_loaded_class():
+ var LoadedClass = load(_path)
+ for i in range(_subpaths.size()):
+ LoadedClass = LoadedClass.get(_subpaths[i])
+ return LoadedClass
+
+ func to_s():
+ return str(_path, '[', get_subpath(), ']')
+
+ func get_path():
+ return _path
+
+ func get_subpath():
+ return _utils.join_array(_subpaths, '/')
+
+ func has_subpath():
+ return _subpaths.size() != 0
+
+ func get_extends_text():
+ var extend = null
+ if(is_native()):
+ extend = str("extends ", get_native_class_name())
+ else:
+ extend = str("extends '", get_path(), "'")
+
+ if(has_subpath()):
+ extend += str('.', get_subpath().replace('/', '.'))
+
+ return extend
+
+ func get_method_strategy():
+ return _method_strategy
+
+ func set_method_strategy(method_strategy):
+ _method_strategy = method_strategy
+
+ func is_native():
+ return _native_class != null
+
+ func set_native_class(native_class):
+ _native_class = native_class
+ _native_class_instance = native_class.new()
+ _path = _native_class_instance.get_class()
+
+ func get_native_class_name():
+ return _native_class_instance.get_class()
+
+# ------------------------------------------------------------------------------
+# Allows for interacting with a file but only creating a string. This was done
+# to ease the transition from files being created for doubles to loading
+# doubles from a string. This allows the files to be created for debugging
+# purposes since reading a file is easier than reading a dumped out string.
+# ------------------------------------------------------------------------------
+class FileOrString:
+ extends File
+
+ var _do_file = false
+ var _contents = ''
+ var _path = null
+
+ func open(path, mode):
+ _path = path
+ if(_do_file):
+ return .open(path, mode)
+ else:
+ return OK
+
+ func close():
+ if(_do_file):
+ return .close()
+
+ func store_string(s):
+ if(_do_file):
+ .store_string(s)
+ _contents += s
+
+ func get_contents():
+ return _contents
+
+ func get_path():
+ return _path
+
+ func load_it():
+ if(_contents != ''):
+ var script = GDScript.new()
+ script.set_source_code(get_contents())
+ script.reload()
+ return script
+ else:
+ return load(_path)
+
+# ------------------------------------------------------------------------------
+# A stroke of genius if I do say so. This allows for doubling a scene without
+# having to write any files. By overloading instance we can make whatever
+# we want.
+# ------------------------------------------------------------------------------
+class PackedSceneDouble:
+ extends PackedScene
+ var _script = null
+ var _scene = null
+
+ func set_script_obj(obj):
+ _script = obj
+
+ func instance(edit_state=0):
+ var inst = _scene.instance(edit_state)
+ if(_script != null):
+ inst.set_script(_script)
+ return inst
+
+ func load_scene(path):
+ _scene = load(path)
+
+
+
+
+# ------------------------------------------------------------------------------
+# START Doubler
+# ------------------------------------------------------------------------------
+var _utils = load('res://addons/gut/utils.gd').new()
+
+var _ignored_methods = _utils.OneToMany.new()
+var _stubber = _utils.Stubber.new()
+var _lgr = _utils.get_logger()
+var _method_maker = _utils.MethodMaker.new()
+
+var _output_dir = 'user://gut_temp_directory'
+var _double_count = 0 # used in making files names unique
+var _spy = null
+var _strategy = null
+var _base_script_text = _utils.get_file_as_text('res://addons/gut/double_templates/script_template.gd')
+var _make_files = false
+
+# These methods all call super implicitly. Stubbing them to call super causes
+# super to be called twice.
+var _non_super_methods = [
+ "_init",
+ "_ready",
+ "_notification",
+ "_enter_world",
+ "_exit_world",
+ "_process",
+ "_physics_process",
+ "_exit_tree",
+ "_gui_input ",
+]
+
+func _init(strategy=_utils.DOUBLE_STRATEGY.PARTIAL):
+ set_logger(_utils.get_logger())
+ _strategy = strategy
+
+# ###############
+# Private
+# ###############
+func _get_indented_line(indents, text):
+ var to_return = ''
+ for _i in range(indents):
+ to_return += "\t"
+ return str(to_return, text, "\n")
+
+
+func _stub_to_call_super(obj_info, method_name):
+ if(_non_super_methods.has(method_name)):
+ return
+ var path = obj_info.get_path()
+ if(obj_info.scene_path != null):
+ path = obj_info.scene_path
+ var params = _utils.StubParams.new(path, method_name, obj_info.get_subpath())
+ params.to_call_super()
+ _stubber.add_stub(params)
+
+func _get_base_script_text(obj_info, override_path):
+ var path = obj_info.get_path()
+ if(override_path != null):
+ path = override_path
+
+ var stubber_id = -1
+ if(_stubber != null):
+ stubber_id = _stubber.get_instance_id()
+
+ var spy_id = -1
+ if(_spy != null):
+ spy_id = _spy.get_instance_id()
+
+ var values = {
+ "path":path,
+ "subpath":obj_info.get_subpath(),
+ "stubber_id":stubber_id,
+ "spy_id":spy_id,
+ "extends":obj_info.get_extends_text()
+ }
+ return _base_script_text.format(values)
+
+func _write_file(obj_info, dest_path, override_path=null):
+ var base_script = _get_base_script_text(obj_info, override_path)
+ var script_methods = _get_methods(obj_info)
+
+ var f = FileOrString.new()
+ f._do_file = _make_files
+ var f_result = f.open(dest_path, f.WRITE)
+
+ if(f_result != OK):
+ _lgr.error(str('Error creating file ', dest_path))
+ _lgr.error(str('Could not create double for :', obj_info.to_s()))
+ return
+
+ f.store_string(base_script)
+
+ for i in range(script_methods.local_methods.size()):
+ if(obj_info.make_partial_double):
+ _stub_to_call_super(obj_info, script_methods.local_methods[i].name)
+ f.store_string(_get_func_text(script_methods.local_methods[i]))
+
+ for i in range(script_methods.built_ins.size()):
+ _stub_to_call_super(obj_info, script_methods.built_ins[i].name)
+ f.store_string(_get_func_text(script_methods.built_ins[i]))
+
+ f.close()
+ return f
+
+func _double_scene_and_script(scene_info):
+ var to_return = PackedSceneDouble.new()
+ to_return.load_scene(scene_info.get_path())
+
+ var inst = load(scene_info.get_path()).instance()
+ var script_path = null
+ if(inst.get_script()):
+ script_path = inst.get_script().get_path()
+ inst.free()
+
+ if(script_path):
+ var oi = ObjectInfo.new(script_path)
+ oi.set_method_strategy(scene_info.get_method_strategy())
+ oi.make_partial_double = scene_info.make_partial_double
+ oi.scene_path = scene_info.get_path()
+ to_return.set_script_obj(_double(oi, scene_info.get_path()).load_it())
+
+ return to_return
+
+func _get_methods(object_info):
+ var obj = object_info.instantiate()
+ # any method in the script or super script
+ var script_methods = ScriptMethods.new()
+ var methods = obj.get_method_list()
+
+ # first pass is for local methods only
+ for i in range(methods.size()):
+ # 65 is a magic number for methods in script, though documentation
+ # says 64. This picks up local overloads of base class methods too.
+ if(methods[i].flags == 65 and !_ignored_methods.has(object_info.get_path(), methods[i]['name'])):
+ script_methods.add_local_method(methods[i])
+
+
+ if(object_info.get_method_strategy() == _utils.DOUBLE_STRATEGY.FULL):
+ # second pass is for anything not local
+ for i in range(methods.size()):
+ # 65 is a magic number for methods in script, though documentation
+ # says 64. This picks up local overloads of base class methods too.
+ if(methods[i].flags != 65 and !_ignored_methods.has(object_info.get_path(), methods[i]['name'])):
+ script_methods.add_built_in_method(methods[i])
+
+ return script_methods
+
+func _get_inst_id_ref_str(inst):
+ var ref_str = 'null'
+ if(inst):
+ ref_str = str('instance_from_id(', inst.get_instance_id(),')')
+ return ref_str
+
+func _get_func_text(method_hash):
+ return _method_maker.get_function_text(method_hash) + "\n"
+
+# returns the path to write the double file to
+func _get_temp_path(object_info):
+ var file_name = null
+ var extension = null
+ if(object_info.is_native()):
+ file_name = object_info.get_native_class_name()
+ extension = 'gd'
+ else:
+ file_name = object_info.get_path().get_file().get_basename()
+ extension = object_info.get_path().get_extension()
+
+ if(object_info.has_subpath()):
+ file_name += '__' + object_info.get_subpath().replace('/', '__')
+
+ file_name += str('__dbl', _double_count, '__.', extension)
+
+ var to_return = _output_dir.plus_file(file_name)
+ return to_return
+
+func _load_double(fileOrString):
+ return fileOrString.load_it()
+
+func _double(obj_info, override_path=null):
+ var temp_path = _get_temp_path(obj_info)
+ var result = _write_file(obj_info, temp_path, override_path)
+ _double_count += 1
+ return result
+
+func _double_script(path, make_partial, strategy):
+ var oi = ObjectInfo.new(path)
+ oi.make_partial_double = make_partial
+ oi.set_method_strategy(strategy)
+ return _double(oi).load_it()
+
+func _double_inner(path, subpath, make_partial, strategy):
+ var oi = ObjectInfo.new(path, subpath)
+ oi.set_method_strategy(strategy)
+ oi.make_partial_double = make_partial
+ return _double(oi).load_it()
+
+func _double_scene(path, make_partial, strategy):
+ var oi = ObjectInfo.new(path)
+ oi.set_method_strategy(strategy)
+ oi.make_partial_double = make_partial
+ return _double_scene_and_script(oi)
+
+func _double_gdnative(native_class, make_partial, strategy):
+ var oi = ObjectInfo.new(null)
+ oi.set_native_class(native_class)
+ oi.set_method_strategy(strategy)
+ oi.make_partial_double = make_partial
+ return _double(oi).load_it()
+
+# ###############
+# Public
+# ###############
+func get_output_dir():
+ return _output_dir
+
+func set_output_dir(output_dir):
+ if(output_dir != null):
+ _output_dir = output_dir
+ if(_make_files):
+ var d = Directory.new()
+ d.make_dir_recursive(output_dir)
+
+func get_spy():
+ return _spy
+
+func set_spy(spy):
+ _spy = spy
+
+func get_stubber():
+ return _stubber
+
+func set_stubber(stubber):
+ _stubber = stubber
+
+func get_logger():
+ return _lgr
+
+func set_logger(logger):
+ _lgr = logger
+ _method_maker.set_logger(logger)
+
+func get_strategy():
+ return _strategy
+
+func set_strategy(strategy):
+ _strategy = strategy
+
+func partial_double_scene(path, strategy=_strategy):
+ return _double_scene(path, true, strategy)
+
+# double a scene
+func double_scene(path, strategy=_strategy):
+ return _double_scene(path, false, strategy)
+
+# double a script/object
+func double(path, strategy=_strategy):
+ return _double_script(path, false, strategy)
+
+func partial_double(path, strategy=_strategy):
+ return _double_script(path, true, strategy)
+
+func partial_double_inner(path, subpath, strategy=_strategy):
+ return _double_inner(path, subpath, true, strategy)
+
+# double an inner class in a script
+func double_inner(path, subpath, strategy=_strategy):
+ return _double_inner(path, subpath, false, strategy)
+
+# must always use FULL strategy since this is a native class and you won't get
+# any methods if you don't use FULL
+func double_gdnative(native_class):
+ return _double_gdnative(native_class, false, _utils.DOUBLE_STRATEGY.FULL)
+
+# must always use FULL strategy since this is a native class and you won't get
+# any methods if you don't use FULL
+func partial_double_gdnative(native_class):
+ return _double_gdnative(native_class, true, _utils.DOUBLE_STRATEGY.FULL)
+
+func clear_output_directory():
+ if(!_make_files):
+ return false
+
+ var did = false
+ if(_output_dir.find('user://') == 0):
+ var d = Directory.new()
+ var result = d.open(_output_dir)
+ # BIG GOTCHA HERE. If it cannot open the dir w/ erro 31, then the
+ # directory becomes res:// and things go on normally and gut clears out
+ # out res:// which is SUPER BAD.
+ if(result == OK):
+ d.list_dir_begin(true)
+ var f = d.get_next()
+ while(f != ''):
+ d.remove(f)
+ f = d.get_next()
+ did = true
+ return did
+
+func delete_output_directory():
+ var did = clear_output_directory()
+ if(did):
+ var d = Directory.new()
+ d.remove(_output_dir)
+
+func add_ignored_method(path, method_name):
+ _ignored_methods.add(path, method_name)
+
+func get_ignored_methods():
+ return _ignored_methods
+
+func get_make_files():
+ return _make_files
+
+func set_make_files(make_files):
+ _make_files = make_files
+ set_output_dir(_output_dir)
diff --git a/addons/gut/gut.gd b/addons/gut/gut.gd
new file mode 100644
index 0000000..34f18c7
--- /dev/null
+++ b/addons/gut/gut.gd
@@ -0,0 +1,1343 @@
+################################################################################
+#(G)odot (U)nit (T)est class
+#
+################################################################################
+#The MIT License (MIT)
+#=====================
+#
+#Copyright (c) 2019 Tom "Butch" Wesley
+#
+#Permission is hereby granted, free of charge, to any person obtaining a copy
+#of this software and associated documentation files (the "Software"), to deal
+#in the Software without restriction, including without limitation the rights
+#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#copies of the Software, and to permit persons to whom the Software is
+#furnished to do so, subject to the following conditions:
+#
+#The above copyright notice and this permission notice shall be included in
+#all copies or substantial portions of the Software.
+#
+#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+#THE SOFTWARE.
+#
+################################################################################
+# View readme for usage details.
+#
+# Version 6.8.2
+################################################################################
+#extends "res://addons/gut/gut_gui.gd"
+tool
+extends Control
+var _version = '6.8.2'
+
+var _utils = load('res://addons/gut/utils.gd').new()
+var _lgr = _utils.get_logger()
+# Used to prevent multiple messages for deprecated setup/teardown messages
+var _deprecated_tracker = _utils.ThingCounter.new()
+
+# ###########################
+# Editor Variables
+# ###########################
+export(String) var _select_script = ''
+export(String) var _tests_like = ''
+export(String) var _inner_class_name = ''
+
+export var _run_on_load = false
+export var _should_maximize = false setget set_should_maximize, get_should_maximize
+
+export var _should_print_to_console = true setget set_should_print_to_console, get_should_print_to_console
+export(int, 'Failures only', 'Tests and failures', 'Everything') var _log_level = 1 setget set_log_level, get_log_level
+# This var is JUST used to expose this setting in the editor
+# the var that is used is in the _yield_between hash.
+export var _yield_between_tests = true setget set_yield_between_tests, get_yield_between_tests
+export var _disable_strict_datatype_checks = false setget disable_strict_datatype_checks, is_strict_datatype_checks_disabled
+# The prefix used to get tests.
+export var _test_prefix = 'test_'
+export var _file_prefix = 'test_'
+export var _file_extension = '.gd'
+export var _inner_class_prefix = 'Test'
+
+export(String) var _temp_directory = 'user://gut_temp_directory'
+export(String) var _export_path = '' setget set_export_path, get_export_path
+
+export var _include_subdirectories = false setget set_include_subdirectories, get_include_subdirectories
+# Allow user to add test directories via editor. This is done with strings
+# instead of an array because the interface for editing arrays is really
+# cumbersome and complicates testing because arrays set through the editor
+# apply to ALL instances. This also allows the user to use the built in
+# dialog to pick a directory.
+export(String, DIR) var _directory1 = ''
+export(String, DIR) var _directory2 = ''
+export(String, DIR) var _directory3 = ''
+export(String, DIR) var _directory4 = ''
+export(String, DIR) var _directory5 = ''
+export(String, DIR) var _directory6 = ''
+export(int, 'FULL', 'PARTIAL') var _double_strategy = _utils.DOUBLE_STRATEGY.PARTIAL setget set_double_strategy, get_double_strategy
+export(String, FILE) var _pre_run_script = '' setget set_pre_run_script, get_pre_run_script
+export(String, FILE) var _post_run_script = '' setget set_post_run_script, get_post_run_script
+export(bool) var _color_output = false setget set_color_output, get_color_output
+
+# The instance that is created from _pre_run_script. Accessible from
+# get_pre_run_script_instance.
+var _pre_run_script_instance = null
+var _post_run_script_instance = null # This is not used except in tests.
+
+# ###########################
+# Other Vars
+# ###########################
+const LOG_LEVEL_FAIL_ONLY = 0
+const LOG_LEVEL_TEST_AND_FAILURES = 1
+const LOG_LEVEL_ALL_ASSERTS = 2
+const WAITING_MESSAGE = '/# waiting #/'
+const PAUSE_MESSAGE = '/# Pausing. Press continue button...#/'
+
+var _script_name = null
+var _test_collector = _utils.TestCollector.new()
+
+# The instanced scripts. This is populated as the scripts are run.
+var _test_script_objects = []
+
+var _waiting = false
+var _done = false
+var _is_running = false
+
+var _current_test = null
+var _log_text = ""
+
+var _pause_before_teardown = false
+# when true _pause_before_teardown will be ignored. useful
+# when batch processing and you don't want to watch.
+var _ignore_pause_before_teardown = false
+var _wait_timer = Timer.new()
+
+var _yield_between = {
+ should = false,
+ timer = Timer.new(),
+ after_x_tests = 5,
+ tests_since_last_yield = 0
+}
+
+var _was_yield_method_called = false
+# used when yielding to gut instead of some other
+# signal. Start with set_yield_time()
+var _yield_timer = Timer.new()
+
+var _unit_test_name = ''
+var _new_summary = null
+
+var _yielding_to = {
+ obj = null,
+ signal_name = ''
+}
+
+var _stubber = _utils.Stubber.new()
+var _doubler = _utils.Doubler.new()
+var _spy = _utils.Spy.new()
+var _gui = null
+
+const SIGNAL_TESTS_FINISHED = 'tests_finished'
+const SIGNAL_STOP_YIELD_BEFORE_TEARDOWN = 'stop_yield_before_teardown'
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _init():
+ # This min size has to be what the min size of the GutScene's min size is
+ # but it has to be set here and not inferred i think.
+ rect_min_size =Vector2(740, 250)
+
+ add_user_signal(SIGNAL_TESTS_FINISHED)
+ add_user_signal(SIGNAL_STOP_YIELD_BEFORE_TEARDOWN)
+ add_user_signal('timeout')
+ add_user_signal('done_waiting')
+ _doubler.set_output_dir(_temp_directory)
+ _doubler.set_stubber(_stubber)
+ _doubler.set_spy(_spy)
+
+ _lgr.set_gut(self)
+ _doubler.set_logger(_lgr)
+ _spy.set_logger(_lgr)
+
+
+ _stubber.set_logger(_lgr)
+ _test_collector.set_logger(_lgr)
+ _gui = load('res://addons/gut/GutScene.tscn').instance()
+
+# ------------------------------------------------------------------------------
+# Initialize controls
+# ------------------------------------------------------------------------------
+func _ready():
+ _lgr.info(str('using [', OS.get_user_data_dir(), '] for temporary output.'))
+
+ set_process_input(true)
+
+ add_child(_wait_timer)
+ _wait_timer.set_wait_time(1)
+ _wait_timer.set_one_shot(true)
+
+ add_child(_yield_between.timer)
+ _wait_timer.set_one_shot(true)
+
+ add_child(_yield_timer)
+ _yield_timer.set_one_shot(true)
+ _yield_timer.connect('timeout', self, '_yielding_callback')
+
+ _setup_gui()
+
+ add_directory(_directory1)
+ add_directory(_directory2)
+ add_directory(_directory3)
+ add_directory(_directory4)
+ add_directory(_directory5)
+ add_directory(_directory6)
+
+ if(_select_script != null):
+ select_script(_select_script)
+
+ if(_tests_like != null):
+ set_unit_test_name(_tests_like)
+
+ if(_run_on_load):
+ test_scripts(_select_script == null)
+
+ if(_should_maximize):
+ maximize()
+
+ # hide the panel that IS gut so that only the GUI is seen
+ self.self_modulate = Color(1,1,1,0)
+ show()
+ var v_info = Engine.get_version_info()
+ p(str('Godot version: ', v_info.major, '.', v_info.minor, '.', v_info.patch))
+ p(str('GUT version: ', get_version()))
+
+
+################################################################################
+#
+# GUI Events and setup
+#
+################################################################################
+func _setup_gui():
+ # This is how we get the size of the control to translate to the gui when
+ # the scene is run. This is also another reason why the min_rect_size
+ # must match between both gut and the gui.
+ _gui.rect_size = self.rect_size
+ add_child(_gui)
+ _gui.set_anchor(MARGIN_RIGHT, ANCHOR_END)
+ _gui.set_anchor(MARGIN_BOTTOM, ANCHOR_END)
+ _gui.connect('run_single_script', self, '_on_run_one')
+ _gui.connect('run_script', self, '_on_new_gui_run_script')
+ _gui.connect('end_pause', self, '_on_new_gui_end_pause')
+ _gui.connect('ignore_pause', self, '_on_new_gui_ignore_pause')
+ _gui.connect('log_level_changed', self, '_on_log_level_changed')
+ var _foo = connect('tests_finished', _gui, 'end_run')
+
+func _add_scripts_to_gui():
+ var scripts = []
+ for i in range(_test_collector.scripts.size()):
+ var s = _test_collector.scripts[i]
+ var txt = ''
+ if(s.has_inner_class()):
+ txt = str(' - ', s.inner_class_name, ' (', s.tests.size(), ')')
+ else:
+ txt = str(s.get_full_name(), ' (', s.tests.size(), ')')
+ scripts.append(txt)
+ _gui.set_scripts(scripts)
+
+func _on_run_one(index):
+ clear_text()
+ var indexes = [index]
+ if(!_test_collector.scripts[index].has_inner_class()):
+ indexes = _get_indexes_matching_path(_test_collector.scripts[index].path)
+ _test_the_scripts(indexes)
+
+func _on_new_gui_run_script(index):
+ var indexes = []
+ clear_text()
+ for i in range(index, _test_collector.scripts.size()):
+ indexes.append(i)
+ _test_the_scripts(indexes)
+
+func _on_new_gui_end_pause():
+ _pause_before_teardown = false
+ emit_signal(SIGNAL_STOP_YIELD_BEFORE_TEARDOWN)
+
+func _on_new_gui_ignore_pause(should):
+ _ignore_pause_before_teardown = should
+
+func _on_log_level_changed(value):
+ _log_level = value
+
+#####################
+#
+# Events
+#
+#####################
+
+# ------------------------------------------------------------------------------
+# Timeout for the built in timer. emits the timeout signal. Start timer
+# with set_yield_time()
+# ------------------------------------------------------------------------------
+func _yielding_callback(from_obj=false):
+ if(_yielding_to.obj):
+ _yielding_to.obj.call_deferred(
+ "disconnect",
+ _yielding_to.signal_name, self,
+ '_yielding_callback')
+ _yielding_to.obj = null
+ _yielding_to.signal_name = ''
+
+ if(from_obj):
+ # we must yiled for a little longer after the signal is emitted so that
+ # the signal can propagate to other objects. This was discovered trying
+ # to assert that obj/signal_name was emitted. Without this extra delay
+ # the yield returns and processing finishes before the rest of the
+ # objects can get the signal. This works b/c the timer will timeout
+ # and come back into this method but from_obj will be false.
+ _yield_timer.set_wait_time(.1)
+ _yield_timer.start()
+ else:
+ emit_signal('timeout')
+
+# ------------------------------------------------------------------------------
+# completed signal for GDScriptFucntionState returned from a test script that
+# has yielded
+# ------------------------------------------------------------------------------
+func _on_test_script_yield_completed():
+ _waiting = false
+
+#####################
+#
+# Private
+#
+#####################
+
+# ------------------------------------------------------------------------------
+# Convert the _summary dictionary into text
+# ------------------------------------------------------------------------------
+func _get_summary_text():
+ var to_return = "\n\n*****************\nRun Summary\n*****************"
+
+ to_return += "\n" + _new_summary.get_summary_text() + "\n"
+
+ var logger_text = ''
+ if(_lgr.get_errors().size() > 0):
+ logger_text += str("\n * ", _lgr.get_errors().size(), ' Errors.')
+ if(_lgr.get_warnings().size() > 0):
+ logger_text += str("\n * ", _lgr.get_warnings().size(), ' Warnings.')
+ if(_lgr.get_deprecated().size() > 0):
+ logger_text += str("\n * ", _lgr.get_deprecated().size(), ' Deprecated calls.')
+ if(logger_text != ''):
+ logger_text = "\nWarnings/Errors:" + logger_text + "\n\n"
+ to_return += logger_text
+
+ if(_new_summary.get_totals().tests > 0):
+ to_return += '+++ ' + str(_new_summary.get_totals().passing) + ' passed ' + str(_new_summary.get_totals().failing) + ' failed. ' + \
+ "Tests finished in: " + str(_gui.get_run_duration()) + ' +++'
+ var c = Color(0, 1, 0)
+ if(_new_summary.get_totals().failing > 0):
+ c = Color(1, 0, 0)
+ elif(_new_summary.get_totals().pending > 0):
+ c = Color(1, 1, .8)
+
+ _gui.get_text_box().add_color_region('+++', '+++', c)
+ else:
+ to_return += '+++ No tests ran +++'
+ _gui.get_text_box().add_color_region('+++', '+++', Color(1, 0, 0))
+
+ return to_return
+
+func _validate_hook_script(path):
+ var result = {
+ valid = true,
+ instance = null
+ }
+
+ # empty path is valid but will have a null instance
+ if(path == ''):
+ return result
+
+ var f = File.new()
+ if(f.file_exists(path)):
+ var inst = load(path).new()
+ if(inst and inst is _utils.HookScript):
+ result.instance = inst
+ result.valid = true
+ else:
+ result.valid = false
+ _lgr.error('The hook script [' + path + '] does not extend res://addons/gut/hook_script.gd')
+ else:
+ result.valid = false
+ _lgr.error('The hook script [' + path + '] does not exist.')
+
+ return result
+
+
+# ------------------------------------------------------------------------------
+# Runs a hook script. Script must exist, and must extend
+# res://addons/gut/hook_script.gd
+# ------------------------------------------------------------------------------
+func _run_hook_script(inst):
+ if(inst != null):
+ inst.gut = self
+ inst.run()
+ return inst
+
+# ------------------------------------------------------------------------------
+# Initialize variables for each run of a single test script.
+# ------------------------------------------------------------------------------
+func _init_run():
+ var valid = true
+ _test_collector.set_test_class_prefix(_inner_class_prefix)
+ _test_script_objects = []
+ _new_summary = _utils.Summary.new()
+
+ _log_text = ""
+
+ _current_test = null
+
+ _is_running = true
+
+ _yield_between.tests_since_last_yield = 0
+
+ _gui.get_text_box().clear_colors()
+ _gui.get_text_box().add_keyword_color("PASSED", Color(0, 1, 0))
+ _gui.get_text_box().add_keyword_color("FAILED", Color(1, 0, 0))
+ _gui.get_text_box().add_color_region('/#', '#/', Color(.9, .6, 0))
+ _gui.get_text_box().add_color_region('/-', '-/', Color(1, 1, 0))
+ _gui.get_text_box().add_color_region('/*', '*/', Color(.5, .5, 1))
+
+ var pre_hook_result = _validate_hook_script(_pre_run_script)
+ _pre_run_script_instance = pre_hook_result.instance
+ var post_hook_result = _validate_hook_script(_post_run_script)
+ _post_run_script_instance = post_hook_result.instance
+
+ valid = pre_hook_result.valid and post_hook_result.valid
+
+ return valid
+
+
+
+
+# ------------------------------------------------------------------------------
+# Print out run information and close out the run.
+# ------------------------------------------------------------------------------
+func _end_run():
+ p(_get_summary_text(), 0)
+ p("\n")
+ if(!_utils.is_null_or_empty(_select_script)):
+ p('Ran Scripts matching ' + _select_script)
+ if(!_utils.is_null_or_empty(_unit_test_name)):
+ p('Ran Tests matching ' + _unit_test_name)
+ if(!_utils.is_null_or_empty(_inner_class_name)):
+ p('Ran Inner Classes matching ' + _inner_class_name)
+
+ # For some reason the text edit control isn't scrolling to the bottom after
+ # the summary is printed. As a workaround, yield for a short time and
+ # then move the cursor. I found this workaround through trial and error.
+ _yield_between.timer.set_wait_time(0.1)
+ _yield_between.timer.start()
+ yield(_yield_between.timer, 'timeout')
+ _gui.get_text_box().cursor_set_line(_gui.get_text_box().get_line_count())
+
+ _is_running = false
+ update()
+ _run_hook_script(_post_run_script_instance)
+ emit_signal(SIGNAL_TESTS_FINISHED)
+ _gui.set_title("Finished. " + str(get_fail_count()) + " failures.")
+
+
+# ------------------------------------------------------------------------------
+# Checks the passed in thing to see if it is a "function state" object that gets
+# returned when a function yields.
+# ------------------------------------------------------------------------------
+func _is_function_state(script_result):
+ return script_result != null and \
+ typeof(script_result) == TYPE_OBJECT and \
+ script_result is GDScriptFunctionState
+
+# ------------------------------------------------------------------------------
+# Print out the heading for a new script
+# ------------------------------------------------------------------------------
+func _print_script_heading(script):
+ if(_does_class_name_match(_inner_class_name, script.inner_class_name)):
+ p("\n/-----------------------------------------")
+ if(script.inner_class_name == null):
+ p("Running Script " + script.path, 0)
+ else:
+ p("Running Class [" + script.inner_class_name + "] in " + script.path, 0)
+
+ if(!_utils.is_null_or_empty(_inner_class_name) and _does_class_name_match(_inner_class_name, script.inner_class_name)):
+ p(str(' [',script.inner_class_name, '] matches [', _inner_class_name, ']'))
+
+ if(!_utils.is_null_or_empty(_unit_test_name)):
+ p(' Only running tests like: "' + _unit_test_name + '"')
+
+ p("-----------------------------------------/")
+
+# ------------------------------------------------------------------------------
+# Just gets more logic out of _test_the_scripts. Decides if we should yield after
+# this test based on flags and counters.
+# ------------------------------------------------------------------------------
+func _should_yield_now():
+ var should = _yield_between.should and \
+ _yield_between.tests_since_last_yield == _yield_between.after_x_tests
+ if(should):
+ _yield_between.tests_since_last_yield = 0
+ else:
+ _yield_between.tests_since_last_yield += 1
+ return should
+
+# ------------------------------------------------------------------------------
+# Yes if the class name is null or the script's class name includes class_name
+# ------------------------------------------------------------------------------
+func _does_class_name_match(the_class_name, script_class_name):
+ return (the_class_name == null or the_class_name == '') or (script_class_name != null and script_class_name.find(the_class_name) != -1)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _setup_script(test_script):
+ test_script.gut = self
+ test_script.set_logger(_lgr)
+ add_child(test_script)
+ _test_script_objects.append(test_script)
+
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _do_yield_between(time):
+ _yield_between.timer.set_wait_time(time)
+ _yield_between.timer.start()
+ return _yield_between.timer
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _wait_for_done(result):
+ var iter_counter = 0
+ var print_after = 3
+
+ # sets waiting to false.
+ result.connect('completed', self, '_on_test_script_yield_completed')
+
+ if(!_was_yield_method_called):
+ p('/# Yield detected, waiting #/')
+
+ _was_yield_method_called = false
+ _waiting = true
+ _wait_timer.set_wait_time(0.25)
+
+ while(_waiting):
+ iter_counter += 1
+ if(iter_counter > print_after):
+ p(WAITING_MESSAGE, 2)
+ iter_counter = 0
+ _wait_timer.start()
+ yield(_wait_timer, 'timeout')
+
+ emit_signal('done_waiting')
+
+# ------------------------------------------------------------------------------
+# returns self so it can be integrated into the yield call.
+# ------------------------------------------------------------------------------
+func _wait_for_continue_button():
+ p(PAUSE_MESSAGE, 0)
+ _waiting = true
+ return self
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _call_deprecated_script_method(script, method, alt):
+ if(script.has_method(method)):
+ var txt = str(script, '-', method)
+ if(!_deprecated_tracker.has(txt)):
+ # Removing the deprecated line. I think it's still too early to
+ # start bothering people with this. Left everything here though
+ # because I don't want to remember how I did this last time.
+ #_lgr.deprecated(str('The method ', method, ' has been deprecated, use ', alt, ' instead.'))
+ _deprecated_tracker.add(txt)
+ script.call(method)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _get_indexes_matching_script_name(name):
+ var indexes = [] # empty runs all
+ for i in range(_test_collector.scripts.size()):
+ if(_test_collector.scripts[i].get_filename().find(name) != -1):
+ indexes.append(i)
+ return indexes
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _get_indexes_matching_path(path):
+ var indexes = []
+ for i in range(_test_collector.scripts.size()):
+ if(_test_collector.scripts[i].path == path):
+ indexes.append(i)
+ return indexes
+
+# ------------------------------------------------------------------------------
+# Run all tests in a script. This is the core logic for running tests.
+#
+# Note, this has to stay as a giant monstrosity of a method because of the
+# yields.
+# ------------------------------------------------------------------------------
+func _test_the_scripts(indexes=[]):
+ var is_valid = _init_run()
+ if(!is_valid):
+ _lgr.error('Something went wrong and the run was aborted.')
+ return
+
+ _run_hook_script(_pre_run_script_instance)
+ if(_pre_run_script_instance!= null and _pre_run_script_instance.should_abort()):
+ _lgr.error('pre-run abort')
+ emit_signal(SIGNAL_TESTS_FINISHED)
+ return
+
+ _gui.run_mode()
+
+ var indexes_to_run = []
+ if(indexes.size()==0):
+ for i in range(_test_collector.scripts.size()):
+ indexes_to_run.append(i)
+ else:
+ indexes_to_run = indexes
+
+ _gui.set_progress_script_max(indexes_to_run.size()) # New way
+ _gui.set_progress_script_value(0)
+
+ if(_doubler.get_strategy() == _utils.DOUBLE_STRATEGY.FULL):
+ _lgr.info("Using Double Strategy FULL as default strategy. Keep an eye out for weirdness, this is still experimental.")
+
+ # loop through scripts
+ for test_indexes in range(indexes_to_run.size()):
+ var the_script = _test_collector.scripts[indexes_to_run[test_indexes]]
+
+ if(the_script.tests.size() > 0):
+ _gui.set_title('Running: ' + the_script.get_full_name())
+ _print_script_heading(the_script)
+ _new_summary.add_script(the_script.get_full_name())
+
+ var test_script = the_script.get_new()
+ var script_result = null
+ _setup_script(test_script)
+ _doubler.set_strategy(_double_strategy)
+
+ # yield between test scripts so things paint
+ if(_yield_between.should):
+ yield(_do_yield_between(0.01), 'timeout')
+
+ # !!!
+ # Hack so there isn't another indent to this monster of a method. if
+ # inner class is set and we do not have a match then empty the tests
+ # for the current test.
+ # !!!
+ if(!_does_class_name_match(_inner_class_name, the_script.inner_class_name)):
+ the_script.tests = []
+ else:
+ # call both pre-all-tests methods until prerun_setup is removed
+ _call_deprecated_script_method(test_script, 'prerun_setup', 'before_all')
+ test_script.before_all()
+
+ _gui.set_progress_test_max(the_script.tests.size()) # New way
+
+ # Each test in the script
+ for i in range(the_script.tests.size()):
+ _stubber.clear()
+ _spy.clear()
+ _doubler.clear_output_directory()
+ _current_test = the_script.tests[i]
+
+ if((_unit_test_name != '' and _current_test.name.find(_unit_test_name) > -1) or
+ (_unit_test_name == '')):
+ p(_current_test.name, 1)
+ _new_summary.add_test(_current_test.name)
+
+ # yield so things paint
+ if(_should_yield_now()):
+ yield(_do_yield_between(0.001), 'timeout')
+
+ _call_deprecated_script_method(test_script, 'setup', 'before_each')
+ test_script.before_each()
+
+
+ #When the script yields it will return a GDScriptFunctionState object
+ script_result = test_script.call(_current_test.name)
+ if(_is_function_state(script_result)):
+ _wait_for_done(script_result)
+ yield(self, 'done_waiting')
+
+ #if the test called pause_before_teardown then yield until
+ #the continue button is pressed.
+ if(_pause_before_teardown and !_ignore_pause_before_teardown):
+ _gui.pause()
+ yield(_wait_for_continue_button(), SIGNAL_STOP_YIELD_BEFORE_TEARDOWN)
+
+ test_script.clear_signal_watcher()
+
+ # call each post-each-test method until teardown is removed.
+ _call_deprecated_script_method(test_script, 'teardown', 'after_each')
+ test_script.after_each()
+
+ if(_current_test.passed):
+ _gui.get_text_box().add_keyword_color(_current_test.name, Color(0, 1, 0))
+ else:
+ _gui.get_text_box().add_keyword_color(_current_test.name, Color(1, 0, 0))
+
+ _gui.set_progress_test_value(i + 1)
+ _doubler.get_ignored_methods().clear()
+
+ # call both post-all-tests methods until postrun_teardown is removed.
+ if(_does_class_name_match(_inner_class_name, the_script.inner_class_name)):
+ _call_deprecated_script_method(test_script, 'postrun_teardown', 'after_all')
+ test_script.after_all()
+
+ # This might end up being very resource intensive if the scripts
+ # don't clean up after themselves. Might have to consolidate output
+ # into some other structure and kill the script objects with
+ # test_script.free() instead of remove child.
+ remove_child(test_script)
+ #END TESTS IN SCRIPT LOOP
+ _current_test = null
+ _gui.set_progress_script_value(test_indexes + 1) # new way
+ #END TEST SCRIPT LOOP
+
+ _end_run()
+
+func _pass(text=''):
+ _gui.add_passing()
+ if(_current_test):
+ _new_summary.add_pass(_current_test.name, text)
+
+func _fail(text=''):
+ _gui.add_failing()
+ if(_current_test != null):
+ var line_text = ' at line ' + str(_extractLineNumber( _current_test))
+ p(line_text, LOG_LEVEL_FAIL_ONLY)
+ # format for summary
+ line_text = "\n " + line_text
+
+ _new_summary.add_fail(_current_test.name, text + line_text)
+ _current_test.passed = false
+
+# Extracts the line number from curren stacktrace by matching the test case name
+func _extractLineNumber(current_test):
+ var line_number = current_test.line_number
+ # if stack trace available than extraxt the test case line number
+ var stackTrace = get_stack()
+ if(stackTrace!=null):
+ for index in stackTrace.size():
+ var line = stackTrace[index]
+ var function = line.get("function")
+ if function == current_test.name:
+ line_number = line.get("line")
+ return line_number
+
+func _pending(text=''):
+ if(_current_test):
+ _new_summary.add_pending(_current_test.name, text)
+
+# Gets all the files in a directory and all subdirectories if get_include_subdirectories
+# is true. The files returned are all sorted by name.
+func _get_files(path, prefix, suffix):
+ var files = []
+ var directories = []
+
+ var d = Directory.new()
+ d.open(path)
+ # true parameter tells list_dir_begin not to include "." and ".." directories.
+ d.list_dir_begin(true)
+
+ # Traversing a directory is kinda odd. You have to start the process of listing
+ # the contents of a directory with list_dir_begin then use get_next until it
+ # returns an empty string. Then I guess you should end it.
+ var fs_item = d.get_next()
+ var full_path = ''
+ while(fs_item != ''):
+ full_path = path.plus_file(fs_item)
+
+ #file_exists returns fasle for directories
+ if(d.file_exists(full_path)):
+ if(fs_item.begins_with(prefix) and fs_item.ends_with(suffix)):
+ files.append(full_path)
+ elif(get_include_subdirectories() and d.dir_exists(full_path)):
+ directories.append(full_path)
+
+ fs_item = d.get_next()
+ d.list_dir_end()
+
+ for dir in range(directories.size()):
+ var dir_files = _get_files(directories[dir], prefix, suffix)
+ for i in range(dir_files.size()):
+ files.append(dir_files[i])
+
+ files.sort()
+ return files
+#########################
+#
+# public
+#
+#########################
+
+# ------------------------------------------------------------------------------
+# Conditionally prints the text to the console/results variable based on the
+# current log level and what level is passed in. Whenever currently in a test,
+# the text will be indented under the test. It can be further indented if
+# desired.
+#
+# The first time output is generated when in a test, the test name will be
+# printed.
+# ------------------------------------------------------------------------------
+func p(text, level=0, indent=0):
+ var str_text = str(text)
+ var to_print = ""
+ var printing_test_name = false
+
+ if(level <= _utils.nvl(_log_level, 0)):
+ if(_current_test != null):
+ # make sure everything printed during the execution
+ # of a test is at least indented once under the test
+ if(indent == 0):
+ indent = 1
+
+ # Print the name of the current test if we haven't
+ # printed it already.
+ if(!_current_test.has_printed_name):
+ to_print = "* " + _current_test.name
+ _current_test.has_printed_name = true
+ printing_test_name = str_text == _current_test.name
+
+ if(!printing_test_name):
+ if(to_print != ""):
+ to_print += "\n"
+ # Make the indent
+ var pad = ""
+ for _i in range(0, indent):
+ pad += " "
+ to_print += pad + str_text
+ to_print = to_print.replace("\n", "\n" + pad)
+
+ if(_should_print_to_console):
+ var formatted = to_print
+ if(_color_output):
+ formatted = _utils.colorize_text(to_print)
+ print(formatted)
+
+ _log_text += to_print + "\n"
+
+ _gui.get_text_box().insert_text_at_cursor(to_print + "\n")
+
+################
+#
+# RUN TESTS/ADD SCRIPTS
+#
+################
+func get_minimum_size():
+ return Vector2(810, 380)
+
+# ------------------------------------------------------------------------------
+# Runs all the scripts that were added using add_script
+# ------------------------------------------------------------------------------
+func test_scripts(run_rest=false):
+ clear_text()
+
+ if(_script_name != null and _script_name != ''):
+ var indexes = _get_indexes_matching_script_name(_script_name)
+ if(indexes == []):
+ _lgr.error('Could not find script matching ' + _script_name)
+ else:
+ _test_the_scripts(indexes)
+ else:
+ _test_the_scripts([])
+
+
+# ------------------------------------------------------------------------------
+# Runs a single script passed in.
+# ------------------------------------------------------------------------------
+func test_script(script):
+ _test_collector.set_test_class_prefix(_inner_class_prefix)
+ _test_collector.clear()
+ _test_collector.add_script(script)
+ _test_the_scripts()
+
+# ------------------------------------------------------------------------------
+# Adds a script to be run when test_scripts called
+#
+# No longer supports selecting a script via this method.
+# ------------------------------------------------------------------------------
+func add_script(script, was_select_this_one=null):
+ if(was_select_this_one != null):
+ _lgr.error('The option to select a script when using add_script has been removed. Calling add_script with 2 parameters will be removed in a later release.')
+
+ if(!Engine.is_editor_hint()):
+ _test_collector.set_test_class_prefix(_inner_class_prefix)
+ _test_collector.add_script(script)
+ _add_scripts_to_gui()
+
+# ------------------------------------------------------------------------------
+# Add all scripts in the specified directory that start with the prefix and end
+# with the suffix. Does not look in sub directories. Can be called multiple
+# times.
+# ------------------------------------------------------------------------------
+func add_directory(path, prefix=_file_prefix, suffix=_file_extension):
+ var d = Directory.new()
+ # check for '' b/c the calls to addin the exported directories 1-6 will pass
+ # '' if the field has not been populated. This will cause res:// to be
+ # processed which will include all files if include_subdirectories is true.
+ if(path == '' or !d.dir_exists(path)):
+ if(path != ''):
+ _lgr.error(str('The path [', path, '] does not exist.'))
+ return
+
+ var files = _get_files(path, prefix, suffix)
+ for i in range(files.size()):
+ add_script(files[i])
+
+# ------------------------------------------------------------------------------
+# This will try to find a script in the list of scripts to test that contains
+# the specified script name. It does not have to be a full match. It will
+# select the first matching occurrence so that this script will run when run_tests
+# is called. Works the same as the select_this_one option of add_script.
+#
+# returns whether it found a match or not
+# ------------------------------------------------------------------------------
+func select_script(script_name):
+ _script_name = script_name
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func export_tests(path=_export_path):
+ if(path == null):
+ _lgr.error('You must pass a path or set the export_path before calling export_tests')
+ else:
+ var result = _test_collector.export_tests(path)
+ if(result):
+ p(_test_collector.to_s())
+ p("Exported to " + path)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func import_tests(path=_export_path):
+ if(!_utils.file_exists(path)):
+ _lgr.error(str('Cannot import tests: the path [', path, '] does not exist.'))
+ else:
+ _test_collector.clear()
+ var result = _test_collector.import_tests(path)
+ if(result):
+ p(_test_collector.to_s())
+ p("Imported from " + path)
+ _add_scripts_to_gui()
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func import_tests_if_none_found():
+ if(_test_collector.scripts.size() == 0):
+ import_tests()
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func export_if_tests_found():
+ if(_test_collector.scripts.size() > 0):
+ export_tests()
+################
+#
+# MISC
+#
+################
+
+# ------------------------------------------------------------------------------
+# Maximize test runner window to fit the viewport.
+# ------------------------------------------------------------------------------
+func set_should_maximize(should):
+ _should_maximize = should
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_should_maximize():
+ return _should_maximize
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func maximize():
+ _gui.maximize()
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func disable_strict_datatype_checks(should):
+ _disable_strict_datatype_checks = should
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func is_strict_datatype_checks_disabled():
+ return _disable_strict_datatype_checks
+
+# ------------------------------------------------------------------------------
+# Pauses the test and waits for you to press a confirmation button. Useful when
+# you want to watch a test play out onscreen or inspect results.
+# ------------------------------------------------------------------------------
+func end_yielded_test():
+ _lgr.deprecated('end_yielded_test is no longer necessary, you can remove it.')
+
+# ------------------------------------------------------------------------------
+# Clears the text of the text box. This resets all counters.
+# ------------------------------------------------------------------------------
+func clear_text():
+ _gui.get_text_box().set_text("")
+ _gui.get_text_box().clear_colors()
+ update()
+
+# ------------------------------------------------------------------------------
+# Get the number of tests that were ran
+# ------------------------------------------------------------------------------
+func get_test_count():
+ return _new_summary.get_totals().tests
+
+# ------------------------------------------------------------------------------
+# Get the number of assertions that were made
+# ------------------------------------------------------------------------------
+func get_assert_count():
+ var t = _new_summary.get_totals()
+ return t.passing + t.failing
+
+# ------------------------------------------------------------------------------
+# Get the number of assertions that passed
+# ------------------------------------------------------------------------------
+func get_pass_count():
+ return _new_summary.get_totals().passing
+
+# ------------------------------------------------------------------------------
+# Get the number of assertions that failed
+# ------------------------------------------------------------------------------
+func get_fail_count():
+ return _new_summary.get_totals().failing
+
+# ------------------------------------------------------------------------------
+# Get the number of tests flagged as pending
+# ------------------------------------------------------------------------------
+func get_pending_count():
+ return _new_summary.get_totals().pending
+
+# ------------------------------------------------------------------------------
+# Set whether it should print to console or not. Default is yes.
+# ------------------------------------------------------------------------------
+func set_should_print_to_console(should):
+ _should_print_to_console = should
+
+# ------------------------------------------------------------------------------
+# Get whether it is printing to the console
+# ------------------------------------------------------------------------------
+func get_should_print_to_console():
+ return _should_print_to_console
+
+# ------------------------------------------------------------------------------
+# Get the results of all tests ran as text. This string is the same as is
+# displayed in the text box, and similar to what is printed to the console.
+# ------------------------------------------------------------------------------
+func get_result_text():
+ return _log_text
+
+# ------------------------------------------------------------------------------
+# Set the log level. Use one of the various LOG_LEVEL_* constants.
+# ------------------------------------------------------------------------------
+func set_log_level(level):
+ _log_level = level
+ if(!Engine.is_editor_hint()):
+ _gui.set_log_level(level)
+
+# ------------------------------------------------------------------------------
+# Get the current log level.
+# ------------------------------------------------------------------------------
+func get_log_level():
+ return _log_level
+
+# ------------------------------------------------------------------------------
+# Call this method to make the test pause before teardown so that you can inspect
+# anything that you have rendered to the screen.
+# ------------------------------------------------------------------------------
+func pause_before_teardown():
+ _pause_before_teardown = true;
+
+# ------------------------------------------------------------------------------
+# For batch processing purposes, you may want to ignore any calls to
+# pause_before_teardown that you forgot to remove.
+# ------------------------------------------------------------------------------
+func set_ignore_pause_before_teardown(should_ignore):
+ _ignore_pause_before_teardown = should_ignore
+ _gui.set_ignore_pause(should_ignore)
+
+func get_ignore_pause_before_teardown():
+ return _ignore_pause_before_teardown
+
+# ------------------------------------------------------------------------------
+# Set to true so that painting of the screen will occur between tests. Allows you
+# to see the output as tests occur. Especially useful with long running tests that
+# make it appear as though it has humg.
+#
+# NOTE: not compatible with 1.0 so this is disabled by default. This will
+# change in future releases.
+# ------------------------------------------------------------------------------
+func set_yield_between_tests(should):
+ _yield_between.should = should
+
+func get_yield_between_tests():
+ return _yield_between.should
+
+# ------------------------------------------------------------------------------
+# Call _process or _fixed_process, if they exist, on obj and all it's children
+# and their children and so and so forth. Delta will be passed through to all
+# the _process or _fixed_process methods.
+# ------------------------------------------------------------------------------
+func simulate(obj, times, delta):
+ for _i in range(times):
+ if(obj.has_method("_process")):
+ obj._process(delta)
+ if(obj.has_method("_physics_process")):
+ obj._physics_process(delta)
+
+ for kid in obj.get_children():
+ simulate(kid, 1, delta)
+
+# ------------------------------------------------------------------------------
+# Starts an internal timer with a timeout of the passed in time. A 'timeout'
+# signal will be sent when the timer ends. Returns itself so that it can be
+# used in a call to yield...cutting down on lines of code.
+#
+# Example, yield to the Gut object for 10 seconds:
+# yield(gut.set_yield_time(10), 'timeout')
+# ------------------------------------------------------------------------------
+func set_yield_time(time, text=''):
+ _yield_timer.set_wait_time(time)
+ _yield_timer.start()
+ var msg = '/# Yielding (' + str(time) + 's)'
+ if(text == ''):
+ msg += ' #/'
+ else:
+ msg += ': ' + text + ' #/'
+ p(msg, 1)
+ _was_yield_method_called = true
+ return self
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_yield_signal_or_time(obj, signal_name, max_wait, text=''):
+ obj.connect(signal_name, self, '_yielding_callback', [true])
+ _yielding_to.obj = obj
+ _yielding_to.signal_name = signal_name
+
+ _yield_timer.set_wait_time(max_wait)
+ _yield_timer.start()
+ _was_yield_method_called = true
+ p(str('/# Yielding to signal "', signal_name, '" or for ', max_wait, ' seconds #/ ', text))
+ return self
+
+# ------------------------------------------------------------------------------
+# get the specific unit test that should be run
+# ------------------------------------------------------------------------------
+func get_unit_test_name():
+ return _unit_test_name
+
+# ------------------------------------------------------------------------------
+# set the specific unit test that should be run.
+# ------------------------------------------------------------------------------
+func set_unit_test_name(test_name):
+ _unit_test_name = test_name
+
+# ------------------------------------------------------------------------------
+# Creates an empty file at the specified path
+# ------------------------------------------------------------------------------
+func file_touch(path):
+ var f = File.new()
+ f.open(path, f.WRITE)
+ f.close()
+
+# ------------------------------------------------------------------------------
+# deletes the file at the specified path
+# ------------------------------------------------------------------------------
+func file_delete(path):
+ var d = Directory.new()
+ var result = d.open(path.get_base_dir())
+ if(result == OK):
+ d.remove(path)
+
+# ------------------------------------------------------------------------------
+# Checks to see if the passed in file has any data in it.
+# ------------------------------------------------------------------------------
+func is_file_empty(path):
+ var f = File.new()
+ f.open(path, f.READ)
+ var empty = f.get_len() == 0
+ f.close()
+ return empty
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_file_as_text(path):
+ return _utils.get_file_as_text(path)
+
+# ------------------------------------------------------------------------------
+# deletes all files in a given directory
+# ------------------------------------------------------------------------------
+func directory_delete_files(path):
+ var d = Directory.new()
+ var result = d.open(path)
+
+ # SHORTCIRCUIT
+ if(result != OK):
+ return
+
+ # Traversing a directory is kinda odd. You have to start the process of listing
+ # the contents of a directory with list_dir_begin then use get_next until it
+ # returns an empty string. Then I guess you should end it.
+ d.list_dir_begin()
+ var thing = d.get_next() # could be a dir or a file or something else maybe?
+ var full_path = ''
+ while(thing != ''):
+ full_path = path + "/" + thing
+ #file_exists returns fasle for directories
+ if(d.file_exists(full_path)):
+ d.remove(full_path)
+ thing = d.get_next()
+ d.list_dir_end()
+
+# ------------------------------------------------------------------------------
+# Returns the instantiated script object that is currently being run.
+# ------------------------------------------------------------------------------
+func get_current_script_object():
+ var to_return = null
+ if(_test_script_objects.size() > 0):
+ to_return = _test_script_objects[-1]
+ return to_return
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_current_test_object():
+ return _current_test
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_stubber():
+ return _stubber
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_doubler():
+ return _doubler
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_spy():
+ return _spy
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_temp_directory():
+ return _temp_directory
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_temp_directory(temp_directory):
+ _temp_directory = temp_directory
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_inner_class_name():
+ return _inner_class_name
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_inner_class_name(inner_class_name):
+ _inner_class_name = inner_class_name
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_summary():
+ return _new_summary
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_double_strategy():
+ return _double_strategy
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_double_strategy(double_strategy):
+ _double_strategy = double_strategy
+ _doubler.set_strategy(double_strategy)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_include_subdirectories():
+ return _include_subdirectories
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_logger():
+ return _lgr
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_logger(logger):
+ _lgr = logger
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_include_subdirectories(include_subdirectories):
+ _include_subdirectories = include_subdirectories
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_test_collector():
+ return _test_collector
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_export_path():
+ return _export_path
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_export_path(export_path):
+ _export_path = export_path
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_version():
+ return _version
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_pre_run_script():
+ return _pre_run_script
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_pre_run_script(pre_run_script):
+ _pre_run_script = pre_run_script
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_post_run_script():
+ return _post_run_script
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func set_post_run_script(post_run_script):
+ _post_run_script = post_run_script
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_pre_run_script_instance():
+ return _pre_run_script_instance
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_post_run_script_instance():
+ return _post_run_script_instance
+
+func get_color_output():
+ return _color_output
+
+func set_color_output(color_output):
+ _color_output = color_output
diff --git a/addons/gut/gut_cmdln.gd b/addons/gut/gut_cmdln.gd
new file mode 100644
index 0000000..ef89144
--- /dev/null
+++ b/addons/gut/gut_cmdln.gd
@@ -0,0 +1,366 @@
+################################################################################
+#(G)odot (U)nit (T)est class
+#
+################################################################################
+#The MIT License (MIT)
+#=====================
+#
+#Copyright (c) 2019 Tom "Butch" Wesley
+#
+#Permission is hereby granted, free of charge, to any person obtaining a copy
+#of this software and associated documentation files (the "Software"), to deal
+#in the Software without restriction, including without limitation the rights
+#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#copies of the Software, and to permit persons to whom the Software is
+#furnished to do so, subject to the following conditions:
+#
+#The above copyright notice and this permission notice shall be included in
+#all copies or substantial portions of the Software.
+#
+#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+#THE SOFTWARE.
+#
+################################################################################
+# Description
+# -----------
+# Command line interface for the GUT unit testing tool. Allows you to run tests
+# from the command line instead of running a scene. Place this script along with
+# gut.gd into your scripts directory at the root of your project. Once there you
+# can run this script (from the root of your project) using the following command:
+# godot -s -d test/gut/gut_cmdln.gd
+#
+# See the readme for a list of options and examples. You can also use the -gh
+# option to get more information about how to use the command line interface.
+#
+# Version 6.8.2
+################################################################################
+extends SceneTree
+
+
+var Optparse = load('res://addons/gut/optparse.gd')
+var Gut = load('res://addons/gut/gut.gd')
+
+#-------------------------------------------------------------------------------
+# Helper class to resolve the various different places where an option can
+# be set. Using the get_value method will enforce the order of precedence of:
+# 1. command line value
+# 2. config file value
+# 3. default value
+#
+# The idea is that you set the base_opts. That will get you a copies of the
+# hash with null values for the other types of values. Lower precedented hashes
+# will punch through null values of higher precedented hashes.
+#-------------------------------------------------------------------------------
+class OptionResolver:
+ var base_opts = null
+ var cmd_opts = null
+ var config_opts = null
+
+
+ func get_value(key):
+ return _nvl(cmd_opts[key], _nvl(config_opts[key], base_opts[key]))
+
+ func set_base_opts(opts):
+ base_opts = opts
+ cmd_opts = _null_copy(opts)
+ config_opts = _null_copy(opts)
+
+ # creates a copy of a hash with all values null.
+ func _null_copy(h):
+ var new_hash = {}
+ for key in h:
+ new_hash[key] = null
+ return new_hash
+
+ func _nvl(a, b):
+ if(a == null):
+ return b
+ else:
+ return a
+ func _string_it(h):
+ var to_return = ''
+ for key in h:
+ to_return += str('(',key, ':', _nvl(h[key], 'NULL'), ')')
+ return to_return
+
+ func to_s():
+ return str("base:\n", _string_it(base_opts), "\n", \
+ "config:\n", _string_it(config_opts), "\n", \
+ "cmd:\n", _string_it(cmd_opts), "\n", \
+ "resolved:\n", _string_it(get_resolved_values()))
+
+ func get_resolved_values():
+ var to_return = {}
+ for key in base_opts:
+ to_return[key] = get_value(key)
+ return to_return
+
+ func to_s_verbose():
+ var to_return = ''
+ var resolved = get_resolved_values()
+ for key in base_opts:
+ to_return += str(key, "\n")
+ to_return += str(' default: ', _nvl(base_opts[key], 'NULL'), "\n")
+ to_return += str(' config: ', _nvl(config_opts[key], ' --'), "\n")
+ to_return += str(' cmd: ', _nvl(cmd_opts[key], ' --'), "\n")
+ to_return += str(' final: ', _nvl(resolved[key], 'NULL'), "\n")
+
+ return to_return
+
+#-------------------------------------------------------------------------------
+# Here starts the actual script that uses the Options class to kick off Gut
+# and run your tests.
+#-------------------------------------------------------------------------------
+var _utils = load('res://addons/gut/utils.gd').new()
+# instance of gut
+var _tester = null
+# array of command line options specified
+var _final_opts = []
+# Hash for easier access to the options in the code. Options will be
+# extracted into this hash and then the hash will be used afterwards so
+# that I don't make any dumb typos and get the neat code-sense when I
+# type a dot.
+var options = {
+ config_file = 'res://.gutconfig.json',
+ dirs = [],
+ disable_colors = false,
+ double_strategy = 'partial',
+ ignore_pause = false,
+ include_subdirs = false,
+ inner_class = '',
+ log_level = 1,
+ opacity = 100,
+ post_run_script = '',
+ pre_run_script = '',
+ prefix = 'test_',
+ selected = '',
+ should_exit = false,
+ should_exit_on_success = false,
+ should_maximize = false,
+ show_help = false,
+ suffix = '.gd',
+ tests = [],
+ unit_test_name = '',
+}
+
+# flag to indicate if only a single script should be run.
+var _run_single = false
+
+func setup_options():
+ var opts = Optparse.new()
+ opts.set_banner(('This is the command line interface for the unit testing tool Gut. With this ' +
+ 'interface you can run one or more test scripts from the command line. In order ' +
+ 'for the Gut options to not clash with any other godot options, each option starts ' +
+ 'with a "g". Also, any option that requires a value will take the form of ' +
+ '"-g=". There cannot be any spaces between the option, the "=", or ' +
+ 'inside a specified value or godot will think you are trying to run a scene.'))
+ opts.add('-gtest', [], 'Comma delimited list of full paths to test scripts to run.')
+ opts.add('-gdir', [], 'Comma delimited list of directories to add tests from.')
+ opts.add('-gprefix', 'test_', 'Prefix used to find tests when specifying -gdir. Default "[default]"')
+ opts.add('-gsuffix', '.gd', 'Suffix used to find tests when specifying -gdir. Default "[default]"')
+ opts.add('-gmaximize', false, 'Maximizes test runner window to fit the viewport.')
+ opts.add('-gexit', false, 'Exit after running tests. If not specified you have to manually close the window.')
+ opts.add('-gexit_on_success', false, 'Only exit if all tests pass.')
+ opts.add('-glog', 1, 'Log level. Default [default]')
+ opts.add('-gignore_pause', false, 'Ignores any calls to gut.pause_before_teardown.')
+ opts.add('-gselect', '', ('Select a script to run initially. The first script that ' +
+ 'was loaded using -gtest or -gdir that contains the specified ' +
+ 'string will be executed. You may run others by interacting ' +
+ 'with the GUI.'))
+ opts.add('-gunit_test_name', '', ('Name of a test to run. Any test that contains the specified ' +
+ 'text will be run, all others will be skipped.'))
+ opts.add('-gh', false, 'Print this help, then quit')
+ opts.add('-gconfig', 'res://.gutconfig.json', 'A config file that contains configuration information. Default is res://.gutconfig.json')
+ opts.add('-ginner_class', '', 'Only run inner classes that contain this string')
+ opts.add('-gopacity', 100, 'Set opacity of test runner window. Use range 0 - 100. 0 = transparent, 100 = opaque.')
+ opts.add('-gpo', false, 'Print option values from all sources and the value used, then quit.')
+ opts.add('-ginclude_subdirs', false, 'Include subdirectories of -gdir.')
+ opts.add('-gdouble_strategy', 'partial', 'Default strategy to use when doubling. Valid values are [partial, full]. Default "[default]"')
+ opts.add('-gdisable_colors', false, 'Disable command line colors.')
+ opts.add('-gpre_run_script', '', 'pre-run hook script path')
+ opts.add('-gpost_run_script', '', 'post-run hook script path')
+ opts.add('-gprint_gutconfig_sample', false, 'Print out json that can be used to make a gutconfig file then quit.')
+ return opts
+
+
+# Parses options, applying them to the _tester or setting values
+# in the options struct.
+func extract_command_line_options(from, to):
+ to.config_file = from.get_value('-gconfig')
+ to.dirs = from.get_value('-gdir')
+ to.disable_colors = from.get_value('-gdisable_colors')
+ to.double_strategy = from.get_value('-gdouble_strategy')
+ to.ignore_pause = from.get_value('-gignore_pause')
+ to.include_subdirs = from.get_value('-ginclude_subdirs')
+ to.inner_class = from.get_value('-ginner_class')
+ to.log_level = from.get_value('-glog')
+ to.opacity = from.get_value('-gopacity')
+ to.post_run_script = from.get_value('-gpost_run_script')
+ to.pre_run_script = from.get_value('-gpre_run_script')
+ to.prefix = from.get_value('-gprefix')
+ to.selected = from.get_value('-gselect')
+ to.should_exit = from.get_value('-gexit')
+ to.should_exit_on_success = from.get_value('-gexit_on_success')
+ to.should_maximize = from.get_value('-gmaximize')
+ to.suffix = from.get_value('-gsuffix')
+ to.tests = from.get_value('-gtest')
+ to.unit_test_name = from.get_value('-gunit_test_name')
+
+
+func load_options_from_config_file(file_path, into):
+ # SHORTCIRCUIT
+ var f = File.new()
+ if(!f.file_exists(file_path)):
+ if(file_path != 'res://.gutconfig.json'):
+ print('ERROR: Config File "', file_path, '" does not exist.')
+ return -1
+ else:
+ return 1
+
+ f.open(file_path, f.READ)
+ var json = f.get_as_text()
+ f.close()
+
+ var results = JSON.parse(json)
+ # SHORTCIRCUIT
+ if(results.error != OK):
+ print("\n\n",'!! ERROR parsing file: ', file_path)
+ print(' at line ', results.error_line, ':')
+ print(' ', results.error_string)
+ return -1
+
+ # Get all the options out of the config file using the option name. The
+ # options hash is now the default source of truth for the name of an option.
+ for key in into:
+ if(results.result.has(key)):
+ into[key] = results.result[key]
+
+ return 1
+
+# Apply all the options specified to _tester. This is where the rubber meets
+# the road.
+func apply_options(opts):
+ _tester = Gut.new()
+ get_root().add_child(_tester)
+ _tester.connect('tests_finished', self, '_on_tests_finished', [opts.should_exit, opts.should_exit_on_success])
+ _tester.set_yield_between_tests(true)
+ _tester.set_modulate(Color(1.0, 1.0, 1.0, min(1.0, float(opts.opacity) / 100)))
+ _tester.show()
+
+ _tester.set_include_subdirectories(opts.include_subdirs)
+
+ if(opts.should_maximize):
+ _tester.maximize()
+
+ if(opts.inner_class != ''):
+ _tester.set_inner_class_name(opts.inner_class)
+ _tester.set_log_level(opts.log_level)
+ _tester.set_ignore_pause_before_teardown(opts.ignore_pause)
+
+ for i in range(opts.dirs.size()):
+ _tester.add_directory(opts.dirs[i], opts.prefix, opts.suffix)
+
+ for i in range(opts.tests.size()):
+ _tester.add_script(opts.tests[i])
+
+ if(opts.selected != ''):
+ _tester.select_script(opts.selected)
+ _run_single = true
+
+ if(opts.double_strategy == 'full'):
+ _tester.set_double_strategy(_utils.DOUBLE_STRATEGY.FULL)
+ elif(opts.double_strategy == 'partial'):
+ _tester.set_double_strategy(_utils.DOUBLE_STRATEGY.PARTIAL)
+
+ _tester.set_unit_test_name(opts.unit_test_name)
+ _tester.set_pre_run_script(opts.pre_run_script)
+ _tester.set_post_run_script(opts.post_run_script)
+ _tester.set_color_output(!opts.disable_colors)
+
+func _print_gutconfigs(values):
+ var header = """Here is a sample of a full .gutconfig.json file.
+You do not need to specify all values in your own file. The values supplied in
+this sample are what would be used if you ran gut w/o the -gprint_gutconfig_sample
+option (the resolved values where default < .gutconfig < command line)."""
+ print("\n", header.replace("\n", ' '), "\n\n")
+ var resolved = values
+
+ # remove some options that don't make sense to be in config
+ resolved.erase("config_file")
+ resolved.erase("show_help")
+
+ print("Here's a config with all the properties set based off of your current command and config.")
+ var text = JSON.print(resolved)
+ print(text.replace(',', ",\n"))
+
+ for key in resolved:
+ resolved[key] = null
+
+ print("\n\nAnd here's an empty config for you fill in what you want.")
+ text = JSON.print(resolved)
+ print(text.replace(',', ",\n"))
+
+
+# parse options and run Gut
+func _init():
+ var opt_resolver = OptionResolver.new()
+ opt_resolver.set_base_opts(options)
+
+ print("\n\n", ' --- Gut ---')
+ var o = setup_options()
+
+ var all_options_valid = o.parse()
+ extract_command_line_options(o, opt_resolver.cmd_opts)
+ var load_result = \
+ load_options_from_config_file(opt_resolver.get_value('config_file'), opt_resolver.config_opts)
+
+ if(load_result == -1): # -1 indicates json parse error
+ quit()
+ else:
+ if(!all_options_valid):
+ quit()
+ elif(o.get_value('-gh')):
+ var v_info = Engine.get_version_info()
+ print(str('Godot version: ', v_info.major, '.', v_info.minor, '.', v_info.patch))
+ print(str('GUT version: ', Gut.new().get_version()))
+
+ o.print_help()
+ quit()
+ elif(o.get_value('-gpo')):
+ print('All command line options and where they are specified. ' +
+ 'The "final" value shows which value will actually be used ' +
+ 'based on order of precedence (default < .gutconfig < cmd line).' + "\n")
+ print(opt_resolver.to_s_verbose())
+ quit()
+ elif(o.get_value('-gprint_gutconfig_sample')):
+ _print_gutconfigs(opt_resolver.get_resolved_values())
+ quit()
+ else:
+ _final_opts = opt_resolver.get_resolved_values();
+ apply_options(_final_opts)
+ _tester.test_scripts(!_run_single)
+
+# exit if option is set.
+func _on_tests_finished(should_exit, should_exit_on_success):
+ if(_final_opts.dirs.size() == 0):
+ if(_tester.get_summary().get_totals().scripts == 0):
+ var lgr = _tester.get_logger()
+ lgr.error('No directories configured. Add directories with options or a .gutconfig.json file. Use the -gh option for more information.')
+
+ if(_tester.get_fail_count()):
+ OS.exit_code = 1
+
+ # Overwrite the exit code with the post_script
+ var post_inst = _tester.get_post_run_script_instance()
+ if(post_inst != null and post_inst.get_exit_code() != null):
+ OS.exit_code = post_inst.get_exit_code()
+
+ if(should_exit or (should_exit_on_success and _tester.get_fail_count() == 0)):
+ quit()
+ else:
+ print("Tests finished, exit manually")
diff --git a/addons/gut/gut_plugin.gd b/addons/gut/gut_plugin.gd
new file mode 100644
index 0000000..0517c1c
--- /dev/null
+++ b/addons/gut/gut_plugin.gd
@@ -0,0 +1,12 @@
+tool
+extends EditorPlugin
+
+func _enter_tree():
+ # Initialization of the plugin goes here
+ # Add the new type with a name, a parent type, a script and an icon
+ add_custom_type("Gut", "Control", preload("gut.gd"), preload("icon.png"))
+
+func _exit_tree():
+ # Clean-up of the plugin goes here
+ # Always remember to remove it from the engine when deactivated
+ remove_custom_type("Gut")
diff --git a/addons/gut/hook_script.gd b/addons/gut/hook_script.gd
new file mode 100644
index 0000000..10a5e12
--- /dev/null
+++ b/addons/gut/hook_script.gd
@@ -0,0 +1,35 @@
+# ------------------------------------------------------------------------------
+# This script is the base for custom scripts to be used in pre and post
+# run hooks.
+# ------------------------------------------------------------------------------
+
+# This is the instance of GUT that is running the tests. You can get
+# information about the run from this object. This is set by GUT when the
+# script is instantiated.
+var gut = null
+
+# the exit code to be used by gut_cmdln. See set method.
+var _exit_code = null
+
+var _should_abort = false
+# Virtual method that will be called by GUT after instantiating
+# this script.
+func run():
+ pass
+
+# Set the exit code when running from the command line. If not set then the
+# default exit code will be returned (0 when no tests fail, 1 when any tests
+# fail).
+func set_exit_code(code):
+ _exit_code = code
+
+func get_exit_code():
+ return _exit_code
+
+# Usable by pre-run script to cause the run to end AFTER the run() method
+# finishes. post-run script will not be ran.
+func abort():
+ _should_abort = true
+
+func should_abort():
+ return _should_abort
diff --git a/addons/gut/icon.png b/addons/gut/icon.png
new file mode 100644
index 0000000..7c58987
Binary files /dev/null and b/addons/gut/icon.png differ
diff --git a/addons/gut/icon.png.import b/addons/gut/icon.png.import
new file mode 100644
index 0000000..848473e
--- /dev/null
+++ b/addons/gut/icon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/icon.png-91b084043b8aaf2f1c906e7b9fa92969.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/gut/icon.png"
+dest_files=[ "res://.import/icon.png-91b084043b8aaf2f1c906e7b9fa92969.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/addons/gut/logger.gd b/addons/gut/logger.gd
new file mode 100644
index 0000000..c7cd458
--- /dev/null
+++ b/addons/gut/logger.gd
@@ -0,0 +1,105 @@
+extends Node2D
+
+var _gut = null
+
+var types = {
+ warn = 'WARNING',
+ error = 'ERROR',
+ info = 'INFO',
+ debug = 'DEBUG',
+ deprecated = 'DEPRECATED'
+}
+
+var _logs = {
+ types.warn: [],
+ types.error: [],
+ types.info: [],
+ types.debug: [],
+ types.deprecated: []
+}
+
+var _suppress_output = false
+
+func _gut_log_level_for_type(log_type):
+ if(log_type == types.warn or log_type == types.error or log_type == types.deprecated):
+ return 0
+ else:
+ return 2
+
+func _log(type, text):
+ _logs[type].append(text)
+ var formatted = str('[', type, '] ', text)
+ if(!_suppress_output):
+ if(_gut):
+ # this will keep the text indented under test for readability
+ _gut.p(formatted, _gut_log_level_for_type(type))
+ # IDEA! We could store the current script and test that generated
+ # this output, which could be useful later if we printed out a summary.
+ else:
+ print(formatted)
+ return formatted
+
+# ---------------
+# Get Methods
+# ---------------
+func get_warnings():
+ return get_log_entries(types.warn)
+
+func get_errors():
+ return get_log_entries(types.error)
+
+func get_infos():
+ return get_log_entries(types.info)
+
+func get_debugs():
+ return get_log_entries(types.debug)
+
+func get_deprecated():
+ return get_log_entries(types.deprecated)
+
+func get_count(log_type=null):
+ var count = 0
+ if(log_type == null):
+ for key in _logs:
+ count += _logs[key].size()
+ else:
+ count = _logs[log_type].size()
+ return count
+
+func get_log_entries(log_type):
+ return _logs[log_type]
+
+# ---------------
+# Log methods
+# ---------------
+func warn(text):
+ return _log(types.warn, text)
+
+func error(text):
+ return _log(types.error, text)
+
+func info(text):
+ return _log(types.info, text)
+
+func debug(text):
+ return _log(types.debug, text)
+
+# supply some text or the name of the deprecated method and the replacement.
+func deprecated(text, alt_method=null):
+ var msg = text
+ if(alt_method):
+ msg = str('The method ', text, ' is deprecated, use ', alt_method , ' instead.')
+ return _log(types.deprecated, msg)
+
+# ---------------
+# Misc
+# ---------------
+func get_gut():
+ return _gut
+
+func set_gut(gut):
+ _gut = gut
+
+func clear():
+ for key in _logs:
+ _logs[key].clear()
diff --git a/addons/gut/method_maker.gd b/addons/gut/method_maker.gd
new file mode 100644
index 0000000..80503e4
--- /dev/null
+++ b/addons/gut/method_maker.gd
@@ -0,0 +1,211 @@
+# This class will generate method declaration lines based on method meta
+# data. It will create defaults that match the method data.
+#
+# --------------------
+# function meta data
+# --------------------
+# name:
+# flags:
+# args: [{
+# (class_name:),
+# (hint:0),
+# (hint_string:),
+# (name:),
+# (type:4),
+# (usage:7)
+# }]
+# default_args []
+
+var _utils = load('res://addons/gut/utils.gd').new()
+var _lgr = _utils.get_logger()
+const PARAM_PREFIX = 'p_'
+
+# ------------------------------------------------------
+# _supported_defaults
+#
+# This array contains all the data types that are supported for default values.
+# If a value is supported it will contain either an empty string or a prefix
+# that should be used when setting the parameter default value.
+# For example int, real, bool do not need anything func(p1=1, p2=2.2, p3=false)
+# but things like Vectors and Colors do since only the parameters to create a
+# new Vector or Color are included in the metadata.
+# ------------------------------------------------------
+ # TYPE_NIL = 0 — Variable is of type nil (only applied for null).
+ # TYPE_BOOL = 1 — Variable is of type bool.
+ # TYPE_INT = 2 — Variable is of type int.
+ # TYPE_REAL = 3 — Variable is of type float/real.
+ # TYPE_STRING = 4 — Variable is of type String.
+ # TYPE_VECTOR2 = 5 — Variable is of type Vector2.
+ # TYPE_RECT2 = 6 — Variable is of type Rect2.
+ # TYPE_VECTOR3 = 7 — Variable is of type Vector3.
+ # TYPE_COLOR = 14 — Variable is of type Color.
+ # TYPE_OBJECT = 17 — Variable is of type Object.
+ # TYPE_DICTIONARY = 18 — Variable is of type Dictionary.
+ # TYPE_ARRAY = 19 — Variable is of type Array.
+ # TYPE_VECTOR2_ARRAY = 24 — Variable is of type PoolVector2Array.
+
+
+
+# TYPE_TRANSFORM2D = 8 — Variable is of type Transform2D.
+# TYPE_PLANE = 9 — Variable is of type Plane.
+# TYPE_QUAT = 10 — Variable is of type Quat.
+# TYPE_AABB = 11 — Variable is of type AABB.
+# TYPE_BASIS = 12 — Variable is of type Basis.
+# TYPE_TRANSFORM = 13 — Variable is of type Transform.
+# TYPE_NODE_PATH = 15 — Variable is of type NodePath.
+# TYPE_RID = 16 — Variable is of type RID.
+# TYPE_RAW_ARRAY = 20 — Variable is of type PoolByteArray.
+# TYPE_INT_ARRAY = 21 — Variable is of type PoolIntArray.
+# TYPE_REAL_ARRAY = 22 — Variable is of type PoolRealArray.
+# TYPE_STRING_ARRAY = 23 — Variable is of type PoolStringArray.
+# TYPE_VECTOR3_ARRAY = 25 — Variable is of type PoolVector3Array.
+# TYPE_COLOR_ARRAY = 26 — Variable is of type PoolColorArray.
+# TYPE_MAX = 27 — Marker for end of type constants.
+# ------------------------------------------------------
+var _supported_defaults = []
+
+func _init():
+ for _i in range(TYPE_MAX):
+ _supported_defaults.append(null)
+
+ # These types do not require a prefix for defaults
+ _supported_defaults[TYPE_NIL] = ''
+ _supported_defaults[TYPE_BOOL] = ''
+ _supported_defaults[TYPE_INT] = ''
+ _supported_defaults[TYPE_REAL] = ''
+ _supported_defaults[TYPE_OBJECT] = ''
+ _supported_defaults[TYPE_ARRAY] = ''
+ _supported_defaults[TYPE_STRING] = ''
+ _supported_defaults[TYPE_DICTIONARY] = ''
+ _supported_defaults[TYPE_VECTOR2_ARRAY] = ''
+
+ # These require a prefix for whatever default is provided
+ _supported_defaults[TYPE_VECTOR2] = 'Vector2'
+ _supported_defaults[TYPE_RECT2] = 'Rect2'
+ _supported_defaults[TYPE_VECTOR3] = 'Vector3'
+ _supported_defaults[TYPE_COLOR] = 'Color'
+
+# ###############
+# Private
+# ###############
+var _func_text = _utils.get_file_as_text('res://addons/gut/double_templates/function_template.gd')
+
+func _is_supported_default(type_flag):
+ return type_flag >= 0 and type_flag < _supported_defaults.size() and [type_flag] != null
+
+# Creates a list of parameters with defaults of null unless a default value is
+# found in the metadata. If a default is found in the meta then it is used if
+# it is one we know how support.
+#
+# If a default is found that we don't know how to handle then this method will
+# return null.
+func _get_arg_text(method_meta):
+ var text = ''
+ var args = method_meta.args
+ var defaults = []
+ var has_unsupported_defaults = false
+
+ # fill up the defaults with null defaults for everything that doesn't have
+ # a default in the meta data. default_args is an array of default values
+ # for the last n parameters where n is the size of default_args so we only
+ # add nulls for everything up to the first parameter with a default.
+ for _i in range(args.size() - method_meta.default_args.size()):
+ defaults.append('null')
+
+ # Add meta-data defaults.
+ for i in range(method_meta.default_args.size()):
+ var t = args[defaults.size()]['type']
+ var value = ''
+ if(_is_supported_default(t)):
+ # strings are special, they need quotes around the value
+ if(t == TYPE_STRING):
+ value = str("'", str(method_meta.default_args[i]), "'")
+ # Colors need the parens but things like Vector2 and Rect2 don't
+ elif(t == TYPE_COLOR):
+ value = str(_supported_defaults[t], '(', str(method_meta.default_args[i]), ')')
+ elif(t == TYPE_OBJECT):
+ if(str(method_meta.default_args[i]) == "[Object:null]"):
+ value = str(_supported_defaults[t], 'null')
+ else:
+ value = str(_supported_defaults[t], str(method_meta.default_args[i]).to_lower())
+
+ # Everything else puts the prefix (if one is there) form _supported_defaults
+ # in front. The to_lower is used b/c for some reason the defaults for
+ # null, true, false are all "Null", "True", "False".
+ else:
+ value = str(_supported_defaults[t], str(method_meta.default_args[i]).to_lower())
+ else:
+ _lgr.warn(str(
+ 'Unsupported default param type: ',method_meta.name, '-', args[defaults.size()].name, ' ', t, ' = ', method_meta.default_args[i]))
+ value = str('unsupported=',t)
+ has_unsupported_defaults = true
+
+ defaults.append(value)
+
+ # construct the string of parameters
+ for i in range(args.size()):
+ text += str(PARAM_PREFIX, args[i].name, '=', defaults[i])
+ if(i != args.size() -1):
+ text += ', '
+
+ # if we don't know how to make a default then we have to return null b/c
+ # it will cause a runtime error and it's one thing we could return to let
+ # callers know it didn't work.
+ if(has_unsupported_defaults):
+ text = null
+
+ return text
+
+# ###############
+# Public
+# ###############
+
+# Creates a delceration for a function based off of function metadata. All
+# types whose defaults are supported will have their values. If a datatype
+# is not supported and the parameter has a default, a warning message will be
+# printed and the declaration will return null.
+func get_function_text(meta):
+ var method_params = _get_arg_text(meta)
+ var text = null
+
+ var param_array = get_spy_call_parameters_text(meta)
+ if(param_array == 'null'):
+ param_array = '[]'
+
+ if(method_params != null):
+ var decleration = str('func ', meta.name, '(', method_params, '):')
+ text = _func_text.format({
+ "func_decleration":decleration,
+ "method_name":meta.name,
+ "param_array":param_array,
+ "super_call":get_super_call_text(meta)
+ })
+ return text
+
+# creates a call to the function in meta in the super's class.
+func get_super_call_text(meta):
+ var params = ''
+
+ for i in range(meta.args.size()):
+ params += PARAM_PREFIX + meta.args[i].name
+ if(meta.args.size() > 1 and i != meta.args.size() -1):
+ params += ', '
+
+ return str('.', meta.name, '(', params, ')')
+
+func get_spy_call_parameters_text(meta):
+ var called_with = 'null'
+ if(meta.args.size() > 0):
+ called_with = '['
+ for i in range(meta.args.size()):
+ called_with += str(PARAM_PREFIX, meta.args[i].name)
+ if(i < meta.args.size() - 1):
+ called_with += ', '
+ called_with += ']'
+ return called_with
+
+func get_logger():
+ return _lgr
+
+func set_logger(logger):
+ _lgr = logger
diff --git a/addons/gut/one_to_many.gd b/addons/gut/one_to_many.gd
new file mode 100644
index 0000000..6a0f818
--- /dev/null
+++ b/addons/gut/one_to_many.gd
@@ -0,0 +1,38 @@
+# ------------------------------------------------------------------------------
+# This datastructure represents a simple one-to-many relationship. It manages
+# a dictionary of value/array pairs. It ignores duplicates of both the "one"
+# and the "many".
+# ------------------------------------------------------------------------------
+var _items = {}
+
+# return the size of _items or the size of an element in _items if "one" was
+# specified.
+func size(one=null):
+ var to_return = 0
+ if(one == null):
+ to_return = _items.size()
+ elif(_items.has(one)):
+ to_return = _items[one].size()
+ return to_return
+
+# Add an element to "one" if it does not already exist
+func add(one, many_item):
+ if(_items.has(one) and !_items[one].has(many_item)):
+ _items[one].append(many_item)
+ else:
+ _items[one] = [many_item]
+
+func clear():
+ _items.clear()
+
+func has(one, many_item):
+ var to_return = false
+ if(_items.has(one)):
+ to_return = _items[one].has(many_item)
+ return to_return
+
+func to_s():
+ var to_return = ''
+ for key in _items:
+ to_return += str(key, ": ", _items[key], "\n")
+ return to_return
diff --git a/addons/gut/optparse.gd b/addons/gut/optparse.gd
new file mode 100644
index 0000000..0f6ccf5
--- /dev/null
+++ b/addons/gut/optparse.gd
@@ -0,0 +1,250 @@
+################################################################################
+#(G)odot (U)nit (T)est class
+#
+################################################################################
+#The MIT License (MIT)
+#=====================
+#
+#Copyright (c) 2019 Tom "Butch" Wesley
+#
+#Permission is hereby granted, free of charge, to any person obtaining a copy
+#of this software and associated documentation files (the "Software"), to deal
+#in the Software without restriction, including without limitation the rights
+#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#copies of the Software, and to permit persons to whom the Software is
+#furnished to do so, subject to the following conditions:
+#
+#The above copyright notice and this permission notice shall be included in
+#all copies or substantial portions of the Software.
+#
+#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+#THE SOFTWARE.
+#
+################################################################################
+# Description
+# -----------
+# Command line interface for the GUT unit testing tool. Allows you to run tests
+# from the command line instead of running a scene. Place this script along with
+# gut.gd into your scripts directory at the root of your project. Once there you
+# can run this script (from the root of your project) using the following command:
+# godot -s -d test/gut/gut_cmdln.gd
+#
+# See the readme for a list of options and examples. You can also use the -gh
+# option to get more information about how to use the command line interface.
+#
+# Version 6.8.2
+################################################################################
+
+#-------------------------------------------------------------------------------
+# Parses the command line arguments supplied into an array that can then be
+# examined and parsed based on how the gut options work.
+#-------------------------------------------------------------------------------
+class CmdLineParser:
+ var _used_options = []
+ # an array of arrays. Each element in this array will contain an option
+ # name and if that option contains a value then it will have a sedond
+ # element. For example:
+ # [[-gselect, test.gd], [-gexit]]
+ var _opts = []
+
+ func _init():
+ for i in range(OS.get_cmdline_args().size()):
+ var opt_val = OS.get_cmdline_args()[i].split('=')
+ _opts.append(opt_val)
+
+ # Parse out multiple comma delimited values from a command line
+ # option. Values are separated from option name with "=" and
+ # additional values are comma separated.
+ func _parse_array_value(full_option):
+ var value = _parse_option_value(full_option)
+ var split = value.split(',')
+ return split
+
+ # Parse out the value of an option. Values are separated from
+ # the option name with "="
+ func _parse_option_value(full_option):
+ if(full_option.size() > 1):
+ return full_option[1]
+ else:
+ return null
+
+ # Search _opts for an element that starts with the option name
+ # specified.
+ func find_option(name):
+ var found = false
+ var idx = 0
+
+ while(idx < _opts.size() and !found):
+ if(_opts[idx][0] == name):
+ found = true
+ else:
+ idx += 1
+
+ if(found):
+ return idx
+ else:
+ return -1
+
+ func get_array_value(option):
+ _used_options.append(option)
+ var to_return = []
+ var opt_loc = find_option(option)
+ if(opt_loc != -1):
+ to_return = _parse_array_value(_opts[opt_loc])
+ _opts.remove(opt_loc)
+
+ return to_return
+
+ # returns the value of an option if it was specified, null otherwise. This
+ # used to return the default but that became problemnatic when trying to
+ # punch through the different places where values could be specified.
+ func get_value(option):
+ _used_options.append(option)
+ var to_return = null
+ var opt_loc = find_option(option)
+ if(opt_loc != -1):
+ to_return = _parse_option_value(_opts[opt_loc])
+ _opts.remove(opt_loc)
+
+ return to_return
+
+ # returns true if it finds the option, false if not.
+ func was_specified(option):
+ _used_options.append(option)
+ return find_option(option) != -1
+
+ # Returns any unused command line options. I found that only the -s and
+ # script name come through from godot, all other options that godot uses
+ # are not sent through OS.get_cmdline_args().
+ #
+ # This is a onetime thing b/c i kill all items in _used_options
+ func get_unused_options():
+ var to_return = []
+ for i in range(_opts.size()):
+ to_return.append(_opts[i][0])
+
+ var script_option = to_return.find('-s')
+ if script_option != -1:
+ to_return.remove(script_option + 1)
+ to_return.remove(script_option)
+
+ while(_used_options.size() > 0):
+ var index = to_return.find(_used_options[0].split("=")[0])
+ if(index != -1):
+ to_return.remove(index)
+ _used_options.remove(0)
+
+ return to_return
+
+#-------------------------------------------------------------------------------
+# Simple class to hold a command line option
+#-------------------------------------------------------------------------------
+class Option:
+ var value = null
+ var option_name = ''
+ var default = null
+ var description = ''
+
+ func _init(name, default_value, desc=''):
+ option_name = name
+ default = default_value
+ description = desc
+ value = null#default_value
+
+ func pad(to_pad, size, pad_with=' '):
+ var to_return = to_pad
+ for _i in range(to_pad.length(), size):
+ to_return += pad_with
+
+ return to_return
+
+ func to_s(min_space=0):
+ var subbed_desc = description
+ if(subbed_desc.find('[default]') != -1):
+ subbed_desc = subbed_desc.replace('[default]', str(default))
+ return pad(option_name, min_space) + subbed_desc
+
+#-------------------------------------------------------------------------------
+# The high level interface between this script and the command line options
+# supplied. Uses Option class and CmdLineParser to extract information from
+# the command line and make it easily accessible.
+#-------------------------------------------------------------------------------
+var options = []
+var _opts = []
+var _banner = ''
+
+func add(name, default, desc):
+ options.append(Option.new(name, default, desc))
+
+func get_value(name):
+ var found = false
+ var idx = 0
+
+ while(idx < options.size() and !found):
+ if(options[idx].option_name == name):
+ found = true
+ else:
+ idx += 1
+
+ if(found):
+ return options[idx].value
+ else:
+ print("COULD NOT FIND OPTION " + name)
+ return null
+
+func set_banner(banner):
+ _banner = banner
+
+func print_help():
+ var longest = 0
+ for i in range(options.size()):
+ if(options[i].option_name.length() > longest):
+ longest = options[i].option_name.length()
+
+ print('---------------------------------------------------------')
+ print(_banner)
+
+ print("\nOptions\n-------")
+ for i in range(options.size()):
+ print(' ' + options[i].to_s(longest + 2))
+ print('---------------------------------------------------------')
+
+func print_options():
+ for i in range(options.size()):
+ print(options[i].option_name + '=' + str(options[i].value))
+
+func parse():
+ var parser = CmdLineParser.new()
+
+ for i in range(options.size()):
+ var t = typeof(options[i].default)
+ # only set values that were specified at the command line so that
+ # we can punch through default and config values correctly later.
+ # Without this check, you can't tell the difference between the
+ # defaults and what was specified, so you can't punch through
+ # higher level options.
+ if(parser.was_specified(options[i].option_name)):
+ if(t == TYPE_INT):
+ options[i].value = int(parser.get_value(options[i].option_name))
+ elif(t == TYPE_STRING):
+ options[i].value = parser.get_value(options[i].option_name)
+ elif(t == TYPE_ARRAY):
+ options[i].value = parser.get_array_value(options[i].option_name)
+ elif(t == TYPE_BOOL):
+ options[i].value = parser.was_specified(options[i].option_name)
+ elif(t == TYPE_NIL):
+ print(options[i].option_name + ' cannot be processed, it has a nil datatype')
+ else:
+ print(options[i].option_name + ' cannot be processed, it has unknown datatype:' + str(t))
+
+ var unused = parser.get_unused_options()
+ if(unused.size() > 0):
+ print("Unrecognized options: ", unused)
+ return false
+
+ return true
diff --git a/addons/gut/plugin.cfg b/addons/gut/plugin.cfg
new file mode 100644
index 0000000..31fd316
--- /dev/null
+++ b/addons/gut/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Gut"
+description="Unit Testing tool for Godot."
+author="Butch Wesley"
+version="6.8.2"
+script="gut_plugin.gd"
diff --git a/addons/gut/signal_watcher.gd b/addons/gut/signal_watcher.gd
new file mode 100644
index 0000000..5fae3fe
--- /dev/null
+++ b/addons/gut/signal_watcher.gd
@@ -0,0 +1,166 @@
+################################################################################
+#The MIT License (MIT)
+#=====================
+#
+#Copyright (c) 2019 Tom "Butch" Wesley
+#
+#Permission is hereby granted, free of charge, to any person obtaining a copy
+#of this software and associated documentation files (the "Software"), to deal
+#in the Software without restriction, including without limitation the rights
+#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#copies of the Software, and to permit persons to whom the Software is
+#furnished to do so, subject to the following conditions:
+#
+#The above copyright notice and this permission notice shall be included in
+#all copies or substantial portions of the Software.
+#
+#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+#THE SOFTWARE.
+#
+################################################################################
+
+# Some arbitrary string that should never show up by accident. If it does, then
+# shame on you.
+const ARG_NOT_SET = '_*_argument_*_is_*_not_set_*_'
+
+# This hash holds the objects that are being watched, the signals that are being
+# watched, and an array of arrays that contains arguments that were passed
+# each time the signal was emitted.
+#
+# For example:
+# _watched_signals => {
+# ref1 => {
+# 'signal1' => [[], [], []],
+# 'signal2' => [[p1, p2]],
+# 'signal3' => [[p1]]
+# },
+# ref2 => {
+# 'some_signal' => [],
+# 'other_signal' => [[p1, p2, p3], [p1, p2, p3], [p1, p2, p3]]
+# }
+# }
+#
+# In this sample:
+# - signal1 on the ref1 object was emitted 3 times and each time, zero
+# parameters were passed.
+# - signal3 on ref1 was emitted once and passed a single parameter
+# - some_signal on ref2 was never emitted.
+# - other_signal on ref2 was emitted 3 times, each time with 3 parameters.
+var _watched_signals = {}
+var _utils = load('res://addons/gut/utils.gd').new()
+
+func _add_watched_signal(obj, name):
+ # SHORTCIRCUIT - ignore dupes
+ if(_watched_signals.has(obj) and _watched_signals[obj].has(name)):
+ return
+
+ if(!_watched_signals.has(obj)):
+ _watched_signals[obj] = {name:[]}
+ else:
+ _watched_signals[obj][name] = []
+ obj.connect(name, self, '_on_watched_signal', [obj, name])
+
+# This handles all the signals that are watched. It supports up to 9 parameters
+# which could be emitted by the signal and the two parameters used when it is
+# connected via watch_signal. I chose 9 since you can only specify up to 9
+# parameters when dynamically calling a method via call (per the Godot
+# documentation, i.e. some_object.call('some_method', 1, 2, 3...)).
+#
+# Based on the documentation of emit_signal, it appears you can only pass up
+# to 4 parameters when firing a signal. I haven't verified this, but this should
+# future proof this some if the value ever grows.
+func _on_watched_signal(arg1=ARG_NOT_SET, arg2=ARG_NOT_SET, arg3=ARG_NOT_SET, \
+ arg4=ARG_NOT_SET, arg5=ARG_NOT_SET, arg6=ARG_NOT_SET, \
+ arg7=ARG_NOT_SET, arg8=ARG_NOT_SET, arg9=ARG_NOT_SET, \
+ arg10=ARG_NOT_SET, arg11=ARG_NOT_SET):
+ var args = [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11]
+
+ # strip off any unused vars.
+ var idx = args.size() -1
+ while(str(args[idx]) == ARG_NOT_SET):
+ args.remove(idx)
+ idx -= 1
+
+ # retrieve object and signal name from the array and remove them. These
+ # will always be at the end since they are added when the connect happens.
+ var signal_name = args[args.size() -1]
+ args.pop_back()
+ var object = args[args.size() -1]
+ args.pop_back()
+
+ _watched_signals[object][signal_name].append(args)
+
+func does_object_have_signal(object, signal_name):
+ var signals = object.get_signal_list()
+ for i in range(signals.size()):
+ if(signals[i]['name'] == signal_name):
+ return true
+ return false
+
+func watch_signals(object):
+ var signals = object.get_signal_list()
+ for i in range(signals.size()):
+ _add_watched_signal(object, signals[i]['name'])
+
+func watch_signal(object, signal_name):
+ var did = false
+ if(does_object_have_signal(object, signal_name)):
+ _add_watched_signal(object, signal_name)
+ did = true
+ return did
+
+func get_emit_count(object, signal_name):
+ var to_return = -1
+ if(is_watching(object, signal_name)):
+ to_return = _watched_signals[object][signal_name].size()
+ return to_return
+
+func did_emit(object, signal_name):
+ var did = false
+ if(is_watching(object, signal_name)):
+ did = get_emit_count(object, signal_name) != 0
+ return did
+
+func print_object_signals(object):
+ var list = object.get_signal_list()
+ for i in range(list.size()):
+ print(list[i].name, "\n ", list[i])
+
+func get_signal_parameters(object, signal_name, index=-1):
+ var params = null
+ if(is_watching(object, signal_name)):
+ var all_params = _watched_signals[object][signal_name]
+ if(all_params.size() > 0):
+ if(index == -1):
+ index = all_params.size() -1
+ params = all_params[index]
+ return params
+
+func is_watching_object(object):
+ return _watched_signals.has(object)
+
+func is_watching(object, signal_name):
+ return _watched_signals.has(object) and _watched_signals[object].has(signal_name)
+
+func clear():
+ for obj in _watched_signals:
+ for signal_name in _watched_signals[obj]:
+ if(_utils.is_not_freed(obj)):
+ obj.disconnect(signal_name, self, '_on_watched_signal')
+ _watched_signals.clear()
+
+# Returns a list of all the signal names that were emitted by the object.
+# If the object is not being watched then an empty list is returned.
+func get_signals_emitted(obj):
+ var emitted = []
+ if(is_watching_object(obj)):
+ for signal_name in _watched_signals[obj]:
+ if(_watched_signals[obj][signal_name].size() > 0):
+ emitted.append(signal_name)
+
+ return emitted
diff --git a/addons/gut/source_code_pro.fnt b/addons/gut/source_code_pro.fnt
new file mode 100644
index 0000000..3367650
Binary files /dev/null and b/addons/gut/source_code_pro.fnt differ
diff --git a/addons/gut/spy.gd b/addons/gut/spy.gd
new file mode 100644
index 0000000..32ae414
--- /dev/null
+++ b/addons/gut/spy.gd
@@ -0,0 +1,96 @@
+# {
+# instance_id_or_path1:{
+# method1:[ [p1, p2], [p1, p2] ],
+# method2:[ [p1, p2], [p1, p2] ]
+# },
+# instance_id_or_path1:{
+# method1:[ [p1, p2], [p1, p2] ],
+# method2:[ [p1, p2], [p1, p2] ]
+# },
+# }
+var _calls = {}
+var _utils = load('res://addons/gut/utils.gd').new()
+var _lgr = _utils.get_logger()
+
+func _get_params_as_string(params):
+ var to_return = ''
+ if(params == null):
+ return ''
+
+ for i in range(params.size()):
+ if(params[i] == null):
+ to_return += 'null'
+ else:
+ if(typeof(params[i]) == TYPE_STRING):
+ to_return += str('"', params[i], '"')
+ else:
+ to_return += str(params[i])
+ if(i != params.size() -1):
+ to_return += ', '
+ return to_return
+
+func add_call(variant, method_name, parameters=null):
+ if(!_calls.has(variant)):
+ _calls[variant] = {}
+
+ if(!_calls[variant].has(method_name)):
+ _calls[variant][method_name] = []
+
+ _calls[variant][method_name].append(parameters)
+
+func was_called(variant, method_name, parameters=null):
+ var to_return = false
+ if(_calls.has(variant) and _calls[variant].has(method_name)):
+ if(parameters):
+ to_return = _calls[variant][method_name].has(parameters)
+ else:
+ to_return = true
+ return to_return
+
+func get_call_parameters(variant, method_name, index=-1):
+ var to_return = null
+ var get_index = -1
+
+ if(_calls.has(variant) and _calls[variant].has(method_name)):
+ var call_size = _calls[variant][method_name].size()
+ if(index == -1):
+ # get the most recent call by default
+ get_index = call_size -1
+ else:
+ get_index = index
+
+ if(get_index < call_size):
+ to_return = _calls[variant][method_name][get_index]
+ else:
+ _lgr.error(str('Specified index ', index, ' is outside range of the number of registered calls: ', call_size))
+
+ return to_return
+
+func call_count(instance, method_name, parameters=null):
+ var to_return = 0
+
+ if(was_called(instance, method_name)):
+ if(parameters):
+ for i in range(_calls[instance][method_name].size()):
+ if(_calls[instance][method_name][i] == parameters):
+ to_return += 1
+ else:
+ to_return = _calls[instance][method_name].size()
+ return to_return
+
+func clear():
+ _calls = {}
+
+func get_call_list_as_string(instance):
+ var to_return = ''
+ if(_calls.has(instance)):
+ for method in _calls[instance]:
+ for i in range(_calls[instance][method].size()):
+ to_return += str(method, '(', _get_params_as_string(_calls[instance][method][i]), ")\n")
+ return to_return
+
+func get_logger():
+ return _lgr
+
+func set_logger(logger):
+ _lgr = logger
diff --git a/addons/gut/stub_params.gd b/addons/gut/stub_params.gd
new file mode 100644
index 0000000..8a749f4
--- /dev/null
+++ b/addons/gut/stub_params.gd
@@ -0,0 +1,43 @@
+var return_val = null
+var stub_target = null
+var target_subpath = null
+var parameters = null
+var stub_method = null
+var call_super = false
+
+const NOT_SET = '|_1_this_is_not_set_1_|'
+
+func _init(target=null, method=null, subpath=null):
+ stub_target = target
+ stub_method = method
+ target_subpath = subpath
+
+func to_return(val):
+ return_val = val
+ call_super = false
+ return self
+
+func to_do_nothing():
+ return to_return(null)
+
+func to_call_super():
+ call_super = true
+ return self
+
+func when_passed(p1=NOT_SET,p2=NOT_SET,p3=NOT_SET,p4=NOT_SET,p5=NOT_SET,p6=NOT_SET,p7=NOT_SET,p8=NOT_SET,p9=NOT_SET,p10=NOT_SET):
+ parameters = [p1,p2,p3,p4,p5,p6,p7,p8,p9,p10]
+ var idx = 0
+ while(idx < parameters.size()):
+ if(str(parameters[idx]) == NOT_SET):
+ parameters.remove(idx)
+ else:
+ idx += 1
+ return self
+
+func to_s():
+ var base_string = str(stub_target, '[', target_subpath, '].', stub_method)
+ if(call_super):
+ base_string += " to call SUPER"
+ else:
+ base_string += str(' with (', parameters, ') = ', return_val)
+ return base_string
diff --git a/addons/gut/stubber.gd b/addons/gut/stubber.gd
new file mode 100644
index 0000000..c0e74ff
--- /dev/null
+++ b/addons/gut/stubber.gd
@@ -0,0 +1,162 @@
+# {
+# inst_id_or_path1:{
+# method_name1: [StubParams, StubParams],
+# method_name2: [StubParams, StubParams]
+# },
+# inst_id_or_path2:{
+# method_name1: [StubParams, StubParams],
+# method_name2: [StubParams, StubParams]
+# }
+# }
+var returns = {}
+var _utils = load('res://addons/gut/utils.gd').new()
+var _lgr = _utils.get_logger()
+
+func _is_instance(obj):
+ return typeof(obj) == TYPE_OBJECT and !obj.has_method('new')
+
+func _make_key_from_metadata(doubled):
+ var to_return = doubled.__gut_metadata_.path
+ if(doubled.__gut_metadata_.subpath != ''):
+ to_return += str('-', doubled.__gut_metadata_.subpath)
+ return to_return
+
+# Creates they key for the returns hash based on the type of object passed in
+# obj could be a string of a path to a script with an optional subpath or
+# it could be an instance of a doubled object.
+func _make_key_from_variant(obj, subpath=null):
+ var to_return = null
+
+ match typeof(obj):
+ TYPE_STRING:
+ # this has to match what is done in _make_key_from_metadata
+ to_return = obj
+ if(subpath != null and subpath != ''):
+ to_return += str('-', subpath)
+ TYPE_OBJECT:
+ if(_is_instance(obj)):
+ to_return = _make_key_from_metadata(obj)
+ elif(_utils.is_native_class(obj)):
+ to_return = _utils.get_native_class_name(obj)
+ else:
+ to_return = obj.resource_path
+ return to_return
+
+func _add_obj_method(obj, method, subpath=null):
+ var key = _make_key_from_variant(obj, subpath)
+ if(_is_instance(obj)):
+ key = obj
+
+ if(!returns.has(key)):
+ returns[key] = {}
+ if(!returns[key].has(method)):
+ returns[key][method] = []
+
+ return key
+
+# ##############
+# Public
+# ##############
+
+# TODO: This method is only used in tests and should be refactored out. It
+# does not support inner classes and isn't helpful.
+func set_return(obj, method, value, parameters=null):
+ var key = _add_obj_method(obj, method)
+ var sp = _utils.StubParams.new(key, method)
+ sp.parameters = parameters
+ sp.return_val = value
+ returns[key][method].append(sp)
+
+func add_stub(stub_params):
+ var key = _add_obj_method(stub_params.stub_target, stub_params.stub_method, stub_params.target_subpath)
+ returns[key][stub_params.stub_method].append(stub_params)
+
+# Searches returns for an entry that matches the instance or the class that
+# passed in obj is.
+#
+# obj can be an instance, class, or a path.
+func _find_stub(obj, method, parameters=null):
+ var key = _make_key_from_variant(obj)
+ var to_return = null
+
+ if(_is_instance(obj)):
+ if(returns.has(obj) and returns[obj].has(method)):
+ key = obj
+ elif(obj.get('__gut_metadata_')):
+ key = _make_key_from_metadata(obj)
+
+ if(returns.has(key) and returns[key].has(method)):
+ var param_idx = -1
+ var null_idx = -1
+
+ for i in range(returns[key][method].size()):
+ if(returns[key][method][i].parameters == parameters):
+ param_idx = i
+ if(returns[key][method][i].parameters == null):
+ null_idx = i
+
+ # We have matching parameter values so return the stub value for that
+ if(param_idx != -1):
+ to_return = returns[key][method][param_idx]
+ # We found a case where the parameters were not specified so return
+ # parameters for that
+ elif(null_idx != -1):
+ to_return = returns[key][method][null_idx]
+ else:
+ _lgr.warn(str('Call to [', method, '] was not stubbed for the supplied parameters ', parameters, '. Null was returned.'))
+
+ return to_return
+
+# Gets a stubbed return value for the object and method passed in. If the
+# instance was stubbed it will use that, otherwise it will use the path and
+# subpath of the object to try to find a value.
+#
+# It will also use the optional list of parameter values to find a value. If
+# the object was stubbed with no parameters than any parameters will match.
+# If it was stubbed with specific parameter values then it will try to match.
+# If the parameters do not match BUT there was also an empty parameter list stub
+# then it will return those.
+# If it cannot find anything that matches then null is returned.for
+#
+# Parameters
+# obj: this should be an instance of a doubled object.
+# method: the method called
+# parameters: optional array of parameter vales to find a return value for.
+func get_return(obj, method, parameters=null):
+ var stub_info = _find_stub(obj, method, parameters)
+
+ if(stub_info != null):
+ return stub_info.return_val
+ else:
+ return null
+
+func should_call_super(obj, method, parameters=null):
+ var stub_info = _find_stub(obj, method, parameters)
+ if(stub_info != null):
+ return stub_info.call_super
+ else:
+ # this log message is here because of how the generated doubled scripts
+ # are structured. With this log msg here, you will only see one
+ # "unstubbed" info instead of multiple.
+ _lgr.info('Unstubbed call to ' + method + '::' + str(obj))
+ return false
+
+
+func clear():
+ returns.clear()
+
+func get_logger():
+ return _lgr
+
+func set_logger(logger):
+ _lgr = logger
+
+func to_s():
+ var text = ''
+ for thing in returns:
+ text += str(thing) + "\n"
+ for method in returns[thing]:
+ text += str("\t", method, "\n")
+ for i in range(returns[thing][method].size()):
+ text += "\t\t" + returns[thing][method][i].to_s() + "\n"
+ return text
diff --git a/addons/gut/summary.gd b/addons/gut/summary.gd
new file mode 100644
index 0000000..1b26b53
--- /dev/null
+++ b/addons/gut/summary.gd
@@ -0,0 +1,152 @@
+# ------------------------------------------------------------------------------
+# Contains all the results of a single test. Allows for multiple asserts results
+# and pending calls.
+# ------------------------------------------------------------------------------
+class Test:
+ var pass_texts = []
+ var fail_texts = []
+ var pending_texts = []
+
+ func to_s():
+ var pad = ' '
+ var to_return = ''
+ for i in range(fail_texts.size()):
+ to_return += str(pad, 'FAILED: ', fail_texts[i], "\n")
+ for i in range(pending_texts.size()):
+ to_return += str(pad, 'PENDING: ', pending_texts[i], "\n")
+ return to_return
+
+# ------------------------------------------------------------------------------
+# Contains all the results for a single test-script/inner class. Persists the
+# names of the tests and results and the order in which the tests were run.
+# ------------------------------------------------------------------------------
+class TestScript:
+ var name = 'NOT_SET'
+ #
+ var _tests = {}
+ var _test_order = []
+
+ func _init(script_name):
+ name = script_name
+
+ func get_pass_count():
+ var count = 0
+ for key in _tests:
+ count += _tests[key].pass_texts.size()
+ return count
+
+ func get_fail_count():
+ var count = 0
+ for key in _tests:
+ count += _tests[key].fail_texts.size()
+ return count
+
+ func get_pending_count():
+ var count = 0
+ for key in _tests:
+ count += _tests[key].pending_texts.size()
+ return count
+
+ func get_test_obj(obj_name):
+ if(!_tests.has(obj_name)):
+ _tests[obj_name] = Test.new()
+ _test_order.append(obj_name)
+ return _tests[obj_name]
+
+ func add_pass(test_name, reason):
+ var t = get_test_obj(test_name)
+ t.pass_texts.append(reason)
+
+ func add_fail(test_name, reason):
+ var t = get_test_obj(test_name)
+ t.fail_texts.append(reason)
+
+ func add_pending(test_name, reason):
+ var t = get_test_obj(test_name)
+ t.pending_texts.append(reason)
+
+# ------------------------------------------------------------------------------
+# Summary Class
+#
+# This class holds the results of all the test scripts and Inner Classes that
+# were run.
+# -------------------------------------------d-----------------------------------
+var _scripts = []
+
+func add_script(name):
+ _scripts.append(TestScript.new(name))
+
+func get_scripts():
+ return _scripts
+
+func get_current_script():
+ return _scripts[_scripts.size() - 1]
+
+func add_test(test_name):
+ get_current_script().get_test_obj(test_name)
+
+func add_pass(test_name, reason = ''):
+ get_current_script().add_pass(test_name, reason)
+
+func add_fail(test_name, reason = ''):
+ get_current_script().add_fail(test_name, reason)
+
+func add_pending(test_name, reason = ''):
+ get_current_script().add_pending(test_name, reason)
+
+func get_test_text(test_name):
+ return test_name + "\n" + get_current_script().get_test_obj(test_name).to_s()
+
+# Gets the count of unique script names minus the . at the
+# end. Used for displaying the number of scripts without including all the
+# Inner Classes.
+func get_non_inner_class_script_count():
+ var unique_scripts = {}
+ for i in range(_scripts.size()):
+ var ext_loc = _scripts[i].name.find_last('.gd.')
+ if(ext_loc == -1):
+ unique_scripts[_scripts[i].name] = 1
+ else:
+ unique_scripts[_scripts[i].name.substr(0, ext_loc + 3)] = 1
+ return unique_scripts.keys().size()
+
+func get_totals():
+ var totals = {
+ passing = 0,
+ pending = 0,
+ failing = 0,
+ tests = 0,
+ scripts = 0
+ }
+
+ for s in range(_scripts.size()):
+ totals.passing += _scripts[s].get_pass_count()
+ totals.pending += _scripts[s].get_pending_count()
+ totals.failing += _scripts[s].get_fail_count()
+ totals.tests += _scripts[s]._test_order.size()
+
+ totals.scripts = get_non_inner_class_script_count()
+
+ return totals
+
+func get_summary_text():
+ var _totals = get_totals()
+
+ var to_return = ''
+ for s in range(_scripts.size()):
+ if(_scripts[s].get_fail_count() > 0 or _scripts[s].get_pending_count() > 0):
+ to_return += _scripts[s].name + "\n"
+ for t in range(_scripts[s]._test_order.size()):
+ var tname = _scripts[s]._test_order[t]
+ var test = _scripts[s].get_test_obj(tname)
+ if(test.fail_texts.size() > 0 or test.pending_texts.size() > 0):
+ to_return += str(' - ', tname, "\n", test.to_s())
+
+ var header = "*** Totals ***\n"
+ header += str(' Scripts: ', get_non_inner_class_script_count(), "\n")
+ header += str(' Tests: ', _totals.tests, "\n")
+ header += str(' Passing asserts: ', _totals.passing, "\n")
+ header += str(' Failing asserts: ',_totals.failing, "\n")
+ header += str(' Pending: ', _totals.pending, "\n")
+
+ return to_return + "\n" + header
diff --git a/addons/gut/test.gd b/addons/gut/test.gd
new file mode 100644
index 0000000..0be9b15
--- /dev/null
+++ b/addons/gut/test.gd
@@ -0,0 +1,1173 @@
+################################################################################
+#(G)odot (U)nit (T)est class
+#
+################################################################################
+#The MIT License (MIT)
+#=====================
+#
+#Copyright (c) 2019 Tom "Butch" Wesley
+#
+#Permission is hereby granted, free of charge, to any person obtaining a copy
+#of this software and associated documentation files (the "Software"), to deal
+#in the Software without restriction, including without limitation the rights
+#to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+#copies of the Software, and to permit persons to whom the Software is
+#furnished to do so, subject to the following conditions:
+#
+#The above copyright notice and this permission notice shall be included in
+#all copies or substantial portions of the Software.
+#
+#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+#THE SOFTWARE.
+#
+################################################################################
+# View readme for usage details.
+#
+# Version - see gut.gd
+################################################################################
+# Class that all test scripts must extend.
+#
+# This provides all the asserts and other testing features. Test scripts are
+# run by the Gut class in gut.gd
+################################################################################
+extends Node
+
+# ------------------------------------------------------------------------------
+# Helper class to hold info for objects to double. This extracts info and has
+# some convenience methods. This is key in being able to make the "smart double"
+# method which makes doubling much easier for the user.
+# ------------------------------------------------------------------------------
+class DoubleInfo:
+ var path
+ var subpath
+ var strategy
+ var make_partial
+ var extension
+ var _utils = load('res://addons/gut/utils.gd').new()
+ var _is_native = false
+
+ # Flexible init method. p2 can be subpath or stategy unless p3 is
+ # specified, then p2 must be subpath and p3 is strategy.
+ #
+ # Examples:
+ # (object_to_double)
+ # (object_to_double, subpath)
+ # (object_to_double, strategy)
+ # (object_to_double, subpath, strategy)
+ func _init(thing, p2=null, p3=null):
+ strategy = p2
+
+ if(typeof(p2) == TYPE_STRING):
+ strategy = p3
+ subpath = p2
+
+ if(typeof(thing) == TYPE_OBJECT):
+ if(_utils.is_native_class(thing)):
+ path = thing
+ _is_native = true
+ extension = 'native_class_not_used'
+ else:
+ path = thing.resource_path
+ else:
+ path = thing
+
+ if(!_is_native):
+ extension = path.get_extension()
+
+ func is_scene():
+ return extension == 'tscn'
+
+ func is_script():
+ return extension == 'gd'
+
+ func is_native():
+ return _is_native
+
+# ------------------------------------------------------------------------------
+# Begin test.gd
+# ------------------------------------------------------------------------------
+
+# constant for signal when calling yield_for
+const YIELD = 'timeout'
+
+# Need a reference to the instance that is running the tests. This
+# is set by the gut class when it runs the tests. This gets you
+# access to the asserts in the tests you write.
+var gut = null
+var passed = false
+var failed = false
+var _disable_strict_datatype_checks = false
+# Holds all the text for a test's fail/pass. This is used for testing purposes
+# to see the text of a failed sub-test in test_test.gd
+var _fail_pass_text = []
+
+# Hash containing all the built in types in Godot. This provides an English
+# name for the types that corosponds with the type constants defined in the
+# engine. This is used for priting out messages when comparing types fails.
+var types = {}
+
+func _init_types_dictionary():
+ types[TYPE_NIL] = 'TYPE_NIL'
+ types[TYPE_BOOL] = 'Bool'
+ types[TYPE_INT] = 'Int'
+ types[TYPE_REAL] = 'Float/Real'
+ types[TYPE_STRING] = 'String'
+ types[TYPE_VECTOR2] = 'Vector2'
+ types[TYPE_RECT2] = 'Rect2'
+ types[TYPE_VECTOR3] = 'Vector3'
+ #types[8] = 'Matrix32'
+ types[TYPE_PLANE] = 'Plane'
+ types[TYPE_QUAT] = 'QUAT'
+ types[TYPE_AABB] = 'AABB'
+ #types[12] = 'Matrix3'
+ types[TYPE_TRANSFORM] = 'Transform'
+ types[TYPE_COLOR] = 'Color'
+ #types[15] = 'Image'
+ types[TYPE_NODE_PATH] = 'Node Path'
+ types[TYPE_RID] = 'RID'
+ types[TYPE_OBJECT] = 'TYPE_OBJECT'
+ #types[19] = 'TYPE_INPUT_EVENT'
+ types[TYPE_DICTIONARY] = 'Dictionary'
+ types[TYPE_ARRAY] = 'Array'
+ types[TYPE_RAW_ARRAY] = 'TYPE_RAW_ARRAY'
+ types[TYPE_INT_ARRAY] = 'TYPE_INT_ARRAY'
+ types[TYPE_REAL_ARRAY] = 'TYPE_REAL_ARRAY'
+ types[TYPE_STRING_ARRAY] = 'TYPE_STRING_ARRAY'
+ types[TYPE_VECTOR2_ARRAY] = 'TYPE_VECTOR2_ARRAY'
+ types[TYPE_VECTOR3_ARRAY] = 'TYPE_VECTOR3_ARRAY'
+ types[TYPE_COLOR_ARRAY] = 'TYPE_COLOR_ARRAY'
+ types[TYPE_MAX] = 'TYPE_MAX'
+
+const EDITOR_PROPERTY = PROPERTY_USAGE_SCRIPT_VARIABLE | PROPERTY_USAGE_DEFAULT
+const VARIABLE_PROPERTY = PROPERTY_USAGE_SCRIPT_VARIABLE
+
+# Summary counts for the test.
+var _summary = {
+ asserts = 0,
+ passed = 0,
+ failed = 0,
+ tests = 0,
+ pending = 0
+}
+
+# This is used to watch signals so we can make assertions about them.
+var _signal_watcher = load('res://addons/gut/signal_watcher.gd').new()
+
+# Convenience copy of _utils.DOUBLE_STRATEGY
+var DOUBLE_STRATEGY = null
+var _utils = load('res://addons/gut/utils.gd').new()
+var _lgr = _utils.get_logger()
+
+func _init():
+ _init_types_dictionary()
+ DOUBLE_STRATEGY = _utils.DOUBLE_STRATEGY # yes, this is right
+
+# ------------------------------------------------------------------------------
+# Fail an assertion. Causes test and script to fail as well.
+# ------------------------------------------------------------------------------
+func _fail(text):
+ _summary.asserts += 1
+ _summary.failed += 1
+ var msg = 'FAILED: ' + text
+ _fail_pass_text.append(msg)
+ if(gut):
+ gut.p(msg, gut.LOG_LEVEL_FAIL_ONLY)
+ gut._fail(text)
+
+# ------------------------------------------------------------------------------
+# Pass an assertion.
+# ------------------------------------------------------------------------------
+func _pass(text):
+ _summary.asserts += 1
+ _summary.passed += 1
+ var msg = "PASSED: " + text
+ _fail_pass_text.append(msg)
+ if(gut):
+ gut.p(msg, gut.LOG_LEVEL_ALL_ASSERTS)
+ gut._pass(text)
+
+# ------------------------------------------------------------------------------
+# Checks if the datatypes passed in match. If they do not then this will cause
+# a fail to occur. If they match then TRUE is returned, FALSE if not. This is
+# used in all the assertions that compare values.
+# ------------------------------------------------------------------------------
+func _do_datatypes_match__fail_if_not(got, expected, text):
+ var did_pass = true
+
+ if(!_disable_strict_datatype_checks):
+ var got_type = typeof(got)
+ var expect_type = typeof(expected)
+ if(got_type != expect_type and got != null and expected != null):
+ # If we have a mismatch between float and int (types 2 and 3) then
+ # print out a warning but do not fail.
+ if([2, 3].has(got_type) and [2, 3].has(expect_type)):
+ _lgr.warn(str('Warn: Float/Int comparison. Got ', types[got_type], ' but expected ', types[expect_type]))
+ else:
+ _fail('Cannot compare ' + types[got_type] + '[' + str(got) + '] to ' + types[expect_type] + '[' + str(expected) + ']. ' + text)
+ did_pass = false
+
+ return did_pass
+
+# ------------------------------------------------------------------------------
+# Create a string that lists all the methods that were called on an spied
+# instance.
+# ------------------------------------------------------------------------------
+func _get_desc_of_calls_to_instance(inst):
+ var BULLET = ' * '
+ var calls = gut.get_spy().get_call_list_as_string(inst)
+ # indent all the calls
+ calls = BULLET + calls.replace("\n", "\n" + BULLET)
+ # remove trailing newline and bullet
+ calls = calls.substr(0, calls.length() - BULLET.length() - 1)
+ return "Calls made on " + str(inst) + "\n" + calls
+
+# ------------------------------------------------------------------------------
+# Signal assertion helper. Do not call directly, use _can_make_signal_assertions
+# ------------------------------------------------------------------------------
+func _fail_if_does_not_have_signal(object, signal_name):
+ var did_fail = false
+ if(!_signal_watcher.does_object_have_signal(object, signal_name)):
+ _fail(str('Object ', object, ' does not have the signal [', signal_name, ']'))
+ did_fail = true
+ return did_fail
+# ------------------------------------------------------------------------------
+# Signal assertion helper. Do not call directly, use _can_make_signal_assertions
+# ------------------------------------------------------------------------------
+func _fail_if_not_watching(object):
+ var did_fail = false
+ if(!_signal_watcher.is_watching_object(object)):
+ _fail(str('Cannot make signal assertions because the object ', object, \
+ ' is not being watched. Call watch_signals(some_object) to be able to make assertions about signals.'))
+ did_fail = true
+ return did_fail
+
+# ------------------------------------------------------------------------------
+# Returns text that contains original text and a list of all the signals that
+# were emitted for the passed in object.
+# ------------------------------------------------------------------------------
+func _get_fail_msg_including_emitted_signals(text, object):
+ return str(text," (Signals emitted: ", _signal_watcher.get_signals_emitted(object), ")")
+
+# ------------------------------------------------------------------------------
+# This validates that parameters is an array and generates a specific error
+# and a failure with a specific message
+# ------------------------------------------------------------------------------
+func _fail_if_parameters_not_array(parameters):
+ var invalid = parameters != null and typeof(parameters) != TYPE_ARRAY
+ if(invalid):
+ _lgr.error('The "parameters" parameter must be an array of expected parameter values.')
+ _fail('Cannot compare paramter values because an array was not passed.')
+ return invalid
+# #######################
+# Virtual Methods
+# #######################
+
+# alias for prerun_setup
+func before_all():
+ pass
+
+# alias for setup
+func before_each():
+ pass
+
+# alias for postrun_teardown
+func after_all():
+ pass
+
+# alias for teardown
+func after_each():
+ pass
+
+# #######################
+# Public
+# #######################
+
+func get_logger():
+ return _lgr
+
+func set_logger(logger):
+ _lgr = logger
+
+
+# #######################
+# Asserts
+# #######################
+
+# ------------------------------------------------------------------------------
+# Asserts that the expected value equals the value got.
+# ------------------------------------------------------------------------------
+func assert_eq(got, expected, text=""):
+ var disp = "[" + str(got) + "] expected to equal [" + str(expected) + "]: " + text
+ if(_do_datatypes_match__fail_if_not(got, expected, text)):
+ if(expected != got):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that the value got does not equal the "not expected" value.
+# ------------------------------------------------------------------------------
+func assert_ne(got, not_expected, text=""):
+ var disp = "[" + str(got) + "] expected to be anything except [" + str(not_expected) + "]: " + text
+ if(_do_datatypes_match__fail_if_not(got, not_expected, text)):
+ if(got == not_expected):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that the expected value almost equals the value got.
+# ------------------------------------------------------------------------------
+func assert_almost_eq(got, expected, error_interval, text=''):
+ var disp = "[" + str(got) + "] expected to equal [" + str(expected) + "] +/- [" + str(error_interval) + "]: " + text
+ if(_do_datatypes_match__fail_if_not(got, expected, text) and _do_datatypes_match__fail_if_not(got, error_interval, text)):
+ if(got < (expected - error_interval) or got > (expected + error_interval)):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that the expected value does not almost equal the value got.
+# ------------------------------------------------------------------------------
+func assert_almost_ne(got, not_expected, error_interval, text=''):
+ var disp = "[" + str(got) + "] expected to not equal [" + str(not_expected) + "] +/- [" + str(error_interval) + "]: " + text
+ if(_do_datatypes_match__fail_if_not(got, not_expected, text) and _do_datatypes_match__fail_if_not(got, error_interval, text)):
+ if(got < (not_expected - error_interval) or got > (not_expected + error_interval)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts got is greater than expected
+# ------------------------------------------------------------------------------
+func assert_gt(got, expected, text=""):
+ var disp = "[" + str(got) + "] expected to be > than [" + str(expected) + "]: " + text
+ if(_do_datatypes_match__fail_if_not(got, expected, text)):
+ if(got > expected):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts got is less than expected
+# ------------------------------------------------------------------------------
+func assert_lt(got, expected, text=""):
+ var disp = "[" + str(got) + "] expected to be < than [" + str(expected) + "]: " + text
+ if(_do_datatypes_match__fail_if_not(got, expected, text)):
+ if(got < expected):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# asserts that got is true
+# ------------------------------------------------------------------------------
+func assert_true(got, text=""):
+ if(!got):
+ _fail(text)
+ else:
+ _pass(text)
+
+# ------------------------------------------------------------------------------
+# Asserts that got is false
+# ------------------------------------------------------------------------------
+func assert_false(got, text=""):
+ if(got):
+ _fail(text)
+ else:
+ _pass(text)
+
+# ------------------------------------------------------------------------------
+# Asserts value is between (inclusive) the two expected values.
+# ------------------------------------------------------------------------------
+func assert_between(got, expect_low, expect_high, text=""):
+ var disp = "[" + str(got) + "] expected to be between [" + str(expect_low) + "] and [" + str(expect_high) + "]: " + text
+
+ if(_do_datatypes_match__fail_if_not(got, expect_low, text) and _do_datatypes_match__fail_if_not(got, expect_high, text)):
+ if(expect_low > expect_high):
+ disp = "INVALID range. [" + str(expect_low) + "] is not less than [" + str(expect_high) + "]"
+ _fail(disp)
+ else:
+ if(got < expect_low or got > expect_high):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Uses the 'has' method of the object passed in to determine if it contains
+# the passed in element.
+# ------------------------------------------------------------------------------
+func assert_has(obj, element, text=""):
+ var disp = str('Expected [', obj, '] to contain value: [', element, ']: ', text)
+ if(obj.has(element)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func assert_does_not_have(obj, element, text=""):
+ var disp = str('Expected [', obj, '] to NOT contain value: [', element, ']: ', text)
+ if(obj.has(element)):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that a file exists
+# ------------------------------------------------------------------------------
+func assert_file_exists(file_path):
+ var disp = 'expected [' + file_path + '] to exist.'
+ var f = File.new()
+ if(f.file_exists(file_path)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that a file should not exist
+# ------------------------------------------------------------------------------
+func assert_file_does_not_exist(file_path):
+ var disp = 'expected [' + file_path + '] to NOT exist'
+ var f = File.new()
+ if(!f.file_exists(file_path)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts the specified file is empty
+# ------------------------------------------------------------------------------
+func assert_file_empty(file_path):
+ var disp = 'expected [' + file_path + '] to be empty'
+ var f = File.new()
+ if(f.file_exists(file_path) and gut.is_file_empty(file_path)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts the specified file is not empty
+# ------------------------------------------------------------------------------
+func assert_file_not_empty(file_path):
+ var disp = 'expected [' + file_path + '] to contain data'
+ if(!gut.is_file_empty(file_path)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts the object has the specified method
+# ------------------------------------------------------------------------------
+func assert_has_method(obj, method):
+ assert_true(obj.has_method(method), 'Should have method: ' + method)
+
+# Old deprecated method name
+func assert_get_set_methods(obj, property, default, set_to):
+ _lgr.deprecated('assert_get_set_methods', 'assert_accessors')
+ assert_accessors(obj, property, default, set_to)
+
+# ------------------------------------------------------------------------------
+# Verifies the object has get and set methods for the property passed in. The
+# property isn't tied to anything, just a name to be appended to the end of
+# get_ and set_. Asserts the get_ and set_ methods exist, if not, it stops there.
+# If they exist then it asserts get_ returns the expected default then calls
+# set_ and asserts get_ has the value it was set to.
+# ------------------------------------------------------------------------------
+func assert_accessors(obj, property, default, set_to):
+ var fail_count = _summary.failed
+ var get = 'get_' + property
+ var set = 'set_' + property
+ assert_has_method(obj, get)
+ assert_has_method(obj, set)
+ # SHORT CIRCUIT
+ if(_summary.failed > fail_count):
+ return
+ assert_eq(obj.call(get), default, 'It should have the expected default value.')
+ obj.call(set, set_to)
+ assert_eq(obj.call(get), set_to, 'The set value should have been returned.')
+
+
+# ---------------------------------------------------------------------------
+# Property search helper. Used to retrieve Dictionary of specified property
+# from passed object. Returns null if not found.
+# If provided, property_usage constrains the type of property returned by
+# passing either:
+# EDITOR_PROPERTY for properties defined as: export(int) var some_value
+# VARIABLE_PROPERTY for properties defunded as: var another_value
+# ---------------------------------------------------------------------------
+func _find_object_property(obj, property_name, property_usage=null):
+ var result = null
+ var found = false
+ var properties = obj.get_property_list()
+
+ while !found and !properties.empty():
+ var property = properties.pop_back()
+ if property['name'] == property_name:
+ if property_usage == null or property['usage'] == property_usage:
+ result = property
+ found = true
+ return result
+
+# ------------------------------------------------------------------------------
+# Asserts a class exports a variable.
+# ------------------------------------------------------------------------------
+func assert_exports(obj, property_name, type):
+ var disp = 'expected %s to have editor property [%s]' % [obj, property_name]
+ var property = _find_object_property(obj, property_name, EDITOR_PROPERTY)
+ if property != null:
+ disp += ' of type [%s]. Got type [%s].' % [types[type], types[property['type']]]
+ if property['type'] == type:
+ _pass(disp)
+ else:
+ _fail(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Signal assertion helper.
+#
+# Verifies that the object and signal are valid for making signal assertions.
+# This will fail with specific messages that indicate why they are not valid.
+# This returns true/false to indicate if the object and signal are valid.
+# ------------------------------------------------------------------------------
+func _can_make_signal_assertions(object, signal_name):
+ return !(_fail_if_not_watching(object) or _fail_if_does_not_have_signal(object, signal_name))
+
+# ------------------------------------------------------------------------------
+# Check if an object is connected to a signal on another object. Returns True
+# if it is and false otherwise
+# ------------------------------------------------------------------------------
+func _is_connected(signaler_obj, connect_to_obj, signal_name, method_name=""):
+ if(method_name != ""):
+ return signaler_obj.is_connected(signal_name, connect_to_obj, method_name)
+ else:
+ var connections = signaler_obj.get_signal_connection_list(signal_name)
+ for conn in connections:
+ if((conn.source == signaler_obj) and (conn.target == connect_to_obj)):
+ return true
+ return false
+# ------------------------------------------------------------------------------
+# Watch the signals for an object. This must be called before you can make
+# any assertions about the signals themselves.
+# ------------------------------------------------------------------------------
+func watch_signals(object):
+ _signal_watcher.watch_signals(object)
+
+# ------------------------------------------------------------------------------
+# Asserts that an object is connected to a signal on another object
+#
+# This will fail with specific messages if the target object is not connected
+# to the specified signal on the source object.
+# ------------------------------------------------------------------------------
+func assert_connected(signaler_obj, connect_to_obj, signal_name, method_name=""):
+ pass
+ var method_disp = ''
+ if (method_name != ""):
+ method_disp = str(' using method: [', method_name, '] ')
+ var disp = str('Expected object ', signaler_obj,\
+ ' to be connected to signal: [', signal_name, '] on ',\
+ connect_to_obj, method_disp)
+ if(_is_connected(signaler_obj, connect_to_obj, signal_name, method_name)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that an object is not connected to a signal on another object
+#
+# This will fail with specific messages if the target object is connected
+# to the specified signal on the source object.
+# ------------------------------------------------------------------------------
+func assert_not_connected(signaler_obj, connect_to_obj, signal_name, method_name=""):
+ var method_disp = ''
+ if (method_name != ""):
+ method_disp = str(' using method: [', method_name, '] ')
+ var disp = str('Expected object ', signaler_obj,\
+ ' to not be connected to signal: [', signal_name, '] on ',\
+ connect_to_obj, method_disp)
+ if(_is_connected(signaler_obj, connect_to_obj, signal_name, method_name)):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that a signal has been emitted at least once.
+#
+# This will fail with specific messages if the object is not being watched or
+# the object does not have the specified signal
+# ------------------------------------------------------------------------------
+func assert_signal_emitted(object, signal_name, text=""):
+ var disp = str('Expected object ', object, ' to have emitted signal [', signal_name, ']: ', text)
+ if(_can_make_signal_assertions(object, signal_name)):
+ if(_signal_watcher.did_emit(object, signal_name)):
+ _pass(disp)
+ else:
+ _fail(_get_fail_msg_including_emitted_signals(disp, object))
+
+# ------------------------------------------------------------------------------
+# Asserts that a signal has not been emitted.
+#
+# This will fail with specific messages if the object is not being watched or
+# the object does not have the specified signal
+# ------------------------------------------------------------------------------
+func assert_signal_not_emitted(object, signal_name, text=""):
+ var disp = str('Expected object ', object, ' to NOT emit signal [', signal_name, ']: ', text)
+ if(_can_make_signal_assertions(object, signal_name)):
+ if(_signal_watcher.did_emit(object, signal_name)):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts that a signal was fired with the specified parameters. The expected
+# parameters should be passed in as an array. An optional index can be passed
+# when a signal has fired more than once. The default is to retrieve the most
+# recent emission of the signal.
+#
+# This will fail with specific messages if the object is not being watched or
+# the object does not have the specified signal
+# ------------------------------------------------------------------------------
+func assert_signal_emitted_with_parameters(object, signal_name, parameters, index=-1):
+ var disp = str('Expected object ', object, ' to emit signal [', signal_name, '] with parameters ', parameters, ', got ')
+ if(_can_make_signal_assertions(object, signal_name)):
+ if(_signal_watcher.did_emit(object, signal_name)):
+ var parms_got = _signal_watcher.get_signal_parameters(object, signal_name, index)
+ if(parameters == parms_got):
+ _pass(str(disp, parms_got))
+ else:
+ _fail(str(disp, parms_got))
+ else:
+ var text = str('Object ', object, ' did not emit signal [', signal_name, ']')
+ _fail(_get_fail_msg_including_emitted_signals(text, object))
+
+# ------------------------------------------------------------------------------
+# Assert that a signal has been emitted a specific number of times.
+#
+# This will fail with specific messages if the object is not being watched or
+# the object does not have the specified signal
+# ------------------------------------------------------------------------------
+func assert_signal_emit_count(object, signal_name, times, text=""):
+
+ if(_can_make_signal_assertions(object, signal_name)):
+ var count = _signal_watcher.get_emit_count(object, signal_name)
+ var disp = str('Expected the signal [', signal_name, '] emit count of [', count, '] to equal [', times, ']: ', text)
+ if(count== times):
+ _pass(disp)
+ else:
+ _fail(_get_fail_msg_including_emitted_signals(disp, object))
+
+# ------------------------------------------------------------------------------
+# Assert that the passed in object has the specified signal
+# ------------------------------------------------------------------------------
+func assert_has_signal(object, signal_name, text=""):
+ var disp = str('Expected object ', object, ' to have signal [', signal_name, ']: ', text)
+ if(_signal_watcher.does_object_have_signal(object, signal_name)):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Returns the number of times a signal was emitted. -1 returned if the object
+# is not being watched.
+# ------------------------------------------------------------------------------
+func get_signal_emit_count(object, signal_name):
+ return _signal_watcher.get_emit_count(object, signal_name)
+
+# ------------------------------------------------------------------------------
+# Get the parmaters of a fired signal. If the signal was not fired null is
+# returned. You can specify an optional index (use get_signal_emit_count to
+# determine the number of times it was emitted). The default index is the
+# latest time the signal was fired (size() -1 insetead of 0). The parameters
+# returned are in an array.
+# ------------------------------------------------------------------------------
+func get_signal_parameters(object, signal_name, index=-1):
+ return _signal_watcher.get_signal_parameters(object, signal_name, index)
+
+# ------------------------------------------------------------------------------
+# Get the parameters for a method call to a doubled object. By default it will
+# return the most recent call. You can optionally specify an index.
+#
+# Returns:
+# * an array of parameter values if a call the method was found
+# * null when a call to the method was not found or the index specified was
+# invalid.
+# ------------------------------------------------------------------------------
+func get_call_parameters(object, method_name, index=-1):
+ var to_return = null
+ if(_utils.is_double(object)):
+ to_return = gut.get_spy().get_call_parameters(object, method_name, index)
+ else:
+ _lgr.error('You must pass a doulbed object to get_call_parameters.')
+
+ return to_return
+
+# ------------------------------------------------------------------------------
+# Assert that object is an instance of a_class
+# ------------------------------------------------------------------------------
+func assert_extends(object, a_class, text=''):
+ _lgr.deprecated('assert_extends', 'assert_is')
+ assert_is(object, a_class, text)
+
+# Alias for assert_extends
+func assert_is(object, a_class, text=''):
+ var disp = str('Expected [', object, '] to be type of [', a_class, ']: ', text)
+ var NATIVE_CLASS = 'GDScriptNativeClass'
+ var GDSCRIPT_CLASS = 'GDScript'
+ var bad_param_2 = 'Parameter 2 must be a Class (like Node2D or Label). You passed '
+
+ if(typeof(object) != TYPE_OBJECT):
+ _fail(str('Parameter 1 must be an instance of an object. You passed: ', types[typeof(object)]))
+ elif(typeof(a_class) != TYPE_OBJECT):
+ _fail(str(bad_param_2, types[typeof(a_class)]))
+ else:
+ disp = str('Expected [', object.get_class(), '] to extend [', a_class.get_class(), ']: ', text)
+ if(a_class.get_class() != NATIVE_CLASS and a_class.get_class() != GDSCRIPT_CLASS):
+ _fail(str(bad_param_2, a_class.get_class(), ' ', types[typeof(a_class)]))
+ else:
+ if(object is a_class):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+func _get_typeof_string(the_type):
+ var to_return = ""
+ if(types.has(the_type)):
+ to_return += str(the_type, '(', types[the_type], ')')
+ else:
+ to_return += str(the_type)
+ return to_return
+
+ # ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func assert_typeof(object, type, text=''):
+ var disp = str('Expected [typeof(', object, ') = ')
+ disp += _get_typeof_string(typeof(object))
+ disp += '] to equal ['
+ disp += _get_typeof_string(type) + ']'
+ disp += '. ' + text
+ if(typeof(object) == type):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func assert_not_typeof(object, type, text=''):
+ var disp = str('Expected [typeof(', object, ') = ')
+ disp += _get_typeof_string(typeof(object))
+ disp += '] to not equal ['
+ disp += _get_typeof_string(type) + ']'
+ disp += '. ' + text
+ if(typeof(object) != type):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Assert that text contains given search string.
+# The match_case flag determines case sensitivity.
+# ------------------------------------------------------------------------------
+func assert_string_contains(text, search, match_case=true):
+ var empty_search = 'Expected text and search strings to be non-empty. You passed \'%s\' and \'%s\'.'
+ var disp = 'Expected \'%s\' to contain \'%s\', match_case=%s' % [text, search, match_case]
+ if(text == '' or search == ''):
+ _fail(empty_search % [text, search])
+ elif(match_case):
+ if(text.find(search) == -1):
+ _fail(disp)
+ else:
+ _pass(disp)
+ else:
+ if(text.to_lower().find(search.to_lower()) == -1):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Assert that text starts with given search string.
+# match_case flag determines case sensitivity.
+# ------------------------------------------------------------------------------
+func assert_string_starts_with(text, search, match_case=true):
+ var empty_search = 'Expected text and search strings to be non-empty. You passed \'%s\' and \'%s\'.'
+ var disp = 'Expected \'%s\' to start with \'%s\', match_case=%s' % [text, search, match_case]
+ if(text == '' or search == ''):
+ _fail(empty_search % [text, search])
+ elif(match_case):
+ if(text.find(search) == 0):
+ _pass(disp)
+ else:
+ _fail(disp)
+ else:
+ if(text.to_lower().find(search.to_lower()) == 0):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Assert that text ends with given search string.
+# match_case flag determines case sensitivity.
+# ------------------------------------------------------------------------------
+func assert_string_ends_with(text, search, match_case=true):
+ var empty_search = 'Expected text and search strings to be non-empty. You passed \'%s\' and \'%s\'.'
+ var disp = 'Expected \'%s\' to end with \'%s\', match_case=%s' % [text, search, match_case]
+ var required_index = len(text) - len(search)
+ if(text == '' or search == ''):
+ _fail(empty_search % [text, search])
+ elif(match_case):
+ if(text.find(search) == required_index):
+ _pass(disp)
+ else:
+ _fail(disp)
+ else:
+ if(text.to_lower().find(search.to_lower()) == required_index):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Assert that a method was called on an instance of a doubled class. If
+# parameters are supplied then the params passed in when called must match.
+# TODO make 3rd parameter "param_or_text" and add fourth parameter of "text" and
+# then work some magic so this can have a "text" parameter without being
+# annoying.
+# ------------------------------------------------------------------------------
+func assert_called(inst, method_name, parameters=null):
+ var disp = str('Expected [',method_name,'] to have been called on ',inst)
+
+ if(_fail_if_parameters_not_array(parameters)):
+ return
+
+ if(!_utils.is_double(inst)):
+ _fail('You must pass a doubled instance to assert_called. Check the wiki for info on using double.')
+ else:
+ if(gut.get_spy().was_called(inst, method_name, parameters)):
+ _pass(disp)
+ else:
+ if(parameters != null):
+ disp += str(' with parameters ', parameters)
+ _fail(str(disp, "\n", _get_desc_of_calls_to_instance(inst)))
+
+# ------------------------------------------------------------------------------
+# Assert that a method was not called on an instance of a doubled class. If
+# parameters are specified then this will only fail if it finds a call that was
+# sent matching parameters.
+# ------------------------------------------------------------------------------
+func assert_not_called(inst, method_name, parameters=null):
+ var disp = str('Expected [', method_name, '] to NOT have been called on ', inst)
+
+ if(_fail_if_parameters_not_array(parameters)):
+ return
+
+ if(!_utils.is_double(inst)):
+ _fail('You must pass a doubled instance to assert_not_called. Check the wiki for info on using double.')
+ else:
+ if(gut.get_spy().was_called(inst, method_name, parameters)):
+ if(parameters != null):
+ disp += str(' with parameters ', parameters)
+ _fail(str(disp, "\n", _get_desc_of_calls_to_instance(inst)))
+ else:
+ _pass(disp)
+
+# ------------------------------------------------------------------------------
+# Assert that a method on an instance of a doubled class was called a number
+# of times. If parameters are specified then only calls with matching
+# parameter values will be counted.
+# ------------------------------------------------------------------------------
+func assert_call_count(inst, method_name, expected_count, parameters=null):
+ var count = gut.get_spy().call_count(inst, method_name, parameters)
+
+ if(_fail_if_parameters_not_array(parameters)):
+ return
+
+ var param_text = ''
+ if(parameters):
+ param_text = ' with parameters ' + str(parameters)
+ var disp = 'Expected [%s] on %s to be called [%s] times%s. It was called [%s] times.'
+ disp = disp % [method_name, inst, expected_count, param_text, count]
+
+ if(!_utils.is_double(inst)):
+ _fail('You must pass a doubled instance to assert_call_count. Check the wiki for info on using double.')
+ else:
+ if(count == expected_count):
+ _pass(disp)
+ else:
+ _fail(str(disp, "\n", _get_desc_of_calls_to_instance(inst)))
+
+# ------------------------------------------------------------------------------
+# Asserts the passed in value is null
+# ------------------------------------------------------------------------------
+func assert_null(got, text=''):
+ var disp = str('Expected [', got, '] to be NULL: ', text)
+ if(got == null):
+ _pass(disp)
+ else:
+ _fail(disp)
+
+# ------------------------------------------------------------------------------
+# Asserts the passed in value is null
+# ------------------------------------------------------------------------------
+func assert_not_null(got, text=''):
+ var disp = str('Expected [', got, '] to be anything but NULL: ', text)
+ if(got == null):
+ _fail(disp)
+ else:
+ _pass(disp)
+
+# -----------------------------------------------------------------------------
+# Asserts object has been freed from memory
+# We pass in a title (since if it is freed, we lost all identity data)
+# -----------------------------------------------------------------------------
+func assert_freed(obj, title):
+ assert_true(not is_instance_valid(obj), "Object %s is freed" % title)
+
+# ------------------------------------------------------------------------------
+# Asserts Object has not been freed from memory
+# -----------------------------------------------------------------------------
+func assert_not_freed(obj, title):
+ assert_true(is_instance_valid(obj), "Object %s is not freed" % title)
+
+# ------------------------------------------------------------------------------
+# Mark the current test as pending.
+# ------------------------------------------------------------------------------
+func pending(text=""):
+ _summary.pending += 1
+ if(gut):
+ if(text == ""):
+ gut.p("PENDING")
+ else:
+ gut.p("PENDING: " + text)
+ gut._pending(text)
+
+# ------------------------------------------------------------------------------
+# Returns the number of times a signal was emitted. -1 returned if the object
+# is not being watched.
+# ------------------------------------------------------------------------------
+
+# ------------------------------------------------------------------------------
+# Yield for the time sent in. The optional message will be printed when
+# Gut detects the yield. When the time expires the YIELD signal will be
+# emitted.
+# ------------------------------------------------------------------------------
+func yield_for(time, msg=''):
+ return gut.set_yield_time(time, msg)
+
+# ------------------------------------------------------------------------------
+# Yield to a signal or a maximum amount of time, whichever comes first. When
+# the conditions are met the YIELD signal will be emitted.
+# ------------------------------------------------------------------------------
+func yield_to(obj, signal_name, max_wait, msg=''):
+ watch_signals(obj)
+ gut.set_yield_signal_or_time(obj, signal_name, max_wait, msg)
+
+ return gut
+
+# ------------------------------------------------------------------------------
+# Ends a test that had a yield in it. You only need to use this if you do
+# not make assertions after a yield.
+# ------------------------------------------------------------------------------
+func end_test():
+ _lgr.deprecated('end_test is no longer necessary, you can remove it.')
+ #gut.end_yielded_test()
+
+func get_summary():
+ return _summary
+
+func get_fail_count():
+ return _summary.failed
+
+func get_pass_count():
+ return _summary.passed
+
+func get_pending_count():
+ return _summary.pending
+
+func get_assert_count():
+ return _summary.asserts
+
+func clear_signal_watcher():
+ _signal_watcher.clear()
+
+func get_double_strategy():
+ return gut.get_doubler().get_strategy()
+
+func set_double_strategy(double_strategy):
+ gut.get_doubler().set_strategy(double_strategy)
+
+func pause_before_teardown():
+ gut.pause_before_teardown()
+# ------------------------------------------------------------------------------
+# Convert the _summary dictionary into text
+# ------------------------------------------------------------------------------
+func get_summary_text():
+ var to_return = get_script().get_path() + "\n"
+ to_return += str(' ', _summary.passed, ' of ', _summary.asserts, ' passed.')
+ if(_summary.pending > 0):
+ to_return += str("\n ", _summary.pending, ' pending')
+ if(_summary.failed > 0):
+ to_return += str("\n ", _summary.failed, ' failed.')
+ return to_return
+
+# ------------------------------------------------------------------------------
+# Double a script, inner class, or scene using a path or a loaded script/scene.
+#
+#
+# ------------------------------------------------------------------------------
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func _smart_double(double_info):
+ var override_strat = _utils.nvl(double_info.strategy, gut.get_doubler().get_strategy())
+ var to_return = null
+
+ if(double_info.is_scene()):
+ if(double_info.make_partial):
+ to_return = gut.get_doubler().partial_double_scene(double_info.path, override_strat)
+ else:
+ to_return = gut.get_doubler().double_scene(double_info.path, override_strat)
+
+ elif(double_info.is_native()):
+ if(double_info.make_partial):
+ to_return = gut.get_doubler().partial_double_gdnative(double_info.path)
+ else:
+ to_return = gut.get_doubler().double_gdnative(double_info.path)
+
+ elif(double_info.is_script()):
+ if(double_info.subpath == null):
+ if(double_info.make_partial):
+ to_return = gut.get_doubler().partial_double(double_info.path, override_strat)
+ else:
+ to_return = gut.get_doubler().double(double_info.path, override_strat)
+ else:
+ if(double_info.make_partial):
+ to_return = gut.get_doubler().partial_double_inner(double_info.path, double_info.subpath, override_strat)
+ else:
+ to_return = gut.get_doubler().double_inner(double_info.path, double_info.subpath, override_strat)
+ return to_return
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func double(thing, p2=null, p3=null):
+ var double_info = DoubleInfo.new(thing, p2, p3)
+ double_info.make_partial = false
+
+ return _smart_double(double_info)
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func partial_double(thing, p2=null, p3=null):
+ var double_info = DoubleInfo.new(thing, p2, p3)
+ double_info.make_partial = true
+
+ return _smart_double(double_info)
+
+
+# ------------------------------------------------------------------------------
+# Specifically double a scene
+# ------------------------------------------------------------------------------
+func double_scene(path, strategy=null):
+ var override_strat = _utils.nvl(strategy, gut.get_doubler().get_strategy())
+ return gut.get_doubler().double_scene(path, override_strat)
+
+# ------------------------------------------------------------------------------
+# Specifically double a script
+# ------------------------------------------------------------------------------
+func double_script(path, strategy=null):
+ var override_strat = _utils.nvl(strategy, gut.get_doubler().get_strategy())
+ return gut.get_doubler().double(path, override_strat)
+
+# ------------------------------------------------------------------------------
+# Specifically double an Inner class in a a script
+# ------------------------------------------------------------------------------
+func double_inner(path, subpath, strategy=null):
+ var override_strat = _utils.nvl(strategy, gut.get_doubler().get_strategy())
+ return gut.get_doubler().double_inner(path, subpath, override_strat)
+
+# ------------------------------------------------------------------------------
+# Add a method that the doubler will ignore. You can pass this the path to a
+# script or scene or a loaded script or scene. When running tests, these
+# ignores are cleared after every test.
+# ------------------------------------------------------------------------------
+func ignore_method_when_doubling(thing, method_name):
+ var double_info = DoubleInfo.new(thing)
+ var path = double_info.path
+
+ if(double_info.is_scene()):
+ var inst = thing.instance()
+ if(inst.get_script()):
+ path = inst.get_script().get_path()
+
+ gut.get_doubler().add_ignored_method(path, method_name)
+
+# ------------------------------------------------------------------------------
+# Stub something.
+#
+# Parameters
+# 1: the thing to stub, a file path or a instance or a class
+# 2: either an inner class subpath or the method name
+# 3: the method name if an inner class subpath was specified
+# NOTE: right now we cannot stub inner classes at the path level so this should
+# only be called with two parameters. I did the work though so I'm going
+# to leave it but not update the wiki.
+# ------------------------------------------------------------------------------
+func stub(thing, p2, p3=null):
+ var method_name = p2
+ var subpath = null
+ if(p3 != null):
+ subpath = p2
+ method_name = p3
+ var sp = _utils.StubParams.new(thing, method_name, subpath)
+ gut.get_stubber().add_stub(sp)
+ return sp
+
+# ------------------------------------------------------------------------------
+# convenience wrapper.
+# ------------------------------------------------------------------------------
+func simulate(obj, times, delta):
+ gut.simulate(obj, times, delta)
+
+# ------------------------------------------------------------------------------
+# Replace the node at base_node.get_node(path) with with_this. All references
+# to the node via $ and get_node(...) will now return with_this. with_this will
+# get all the groups that the node that was replaced had.
+#
+# The node that was replaced is queued to be freed.
+#
+# TODO see replace_by method, this could simplify the logic here.
+# ------------------------------------------------------------------------------
+func replace_node(base_node, path_or_node, with_this):
+ var path = path_or_node
+
+ if(typeof(path_or_node) != TYPE_STRING):
+ # This will cause an engine error if it fails. It always returns a
+ # NodePath, even if it fails. Checking the name count is the only way
+ # I found to check if it found something or not (after it worked I
+ # didn't look any farther).
+ path = base_node.get_path_to(path_or_node)
+ if(path.get_name_count() == 0):
+ _lgr.error('You passed an object that base_node does not have. Cannot replace node.')
+ return
+
+ if(!base_node.has_node(path)):
+ _lgr.error(str('Could not find node at path [', path, ']'))
+ return
+
+ var to_replace = base_node.get_node(path)
+ var parent = to_replace.get_parent()
+ var replace_name = to_replace.get_name()
+
+ parent.remove_child(to_replace)
+ parent.add_child(with_this)
+ with_this.set_name(replace_name)
+ with_this.set_owner(parent)
+
+ var groups = to_replace.get_groups()
+ for i in range(groups.size()):
+ with_this.add_to_group(groups[i])
+
+ to_replace.queue_free()
diff --git a/addons/gut/test_collector.gd b/addons/gut/test_collector.gd
new file mode 100644
index 0000000..8d8264a
--- /dev/null
+++ b/addons/gut/test_collector.gd
@@ -0,0 +1,241 @@
+# ------------------------------------------------------------------------------
+# Used to keep track of info about each test ran.
+# ------------------------------------------------------------------------------
+class Test:
+ # indicator if it passed or not. defaults to true since it takes only
+ # one failure to make it not pass. _fail in gut will set this.
+ var passed = true
+ # the name of the function
+ var name = ""
+ # flag to know if the name has been printed yet.
+ var has_printed_name = false
+ # the line number the test is on
+ var line_number = -1
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+class TestScript:
+ var inner_class_name = null
+ var tests = []
+ var path = null
+ var _utils = null
+ var _lgr = null
+
+ func _init(utils=null, logger=null):
+ _utils = utils
+ _lgr = logger
+
+ func to_s():
+ var to_return = path
+ if(inner_class_name != null):
+ to_return += str('.', inner_class_name)
+ to_return += "\n"
+ for i in range(tests.size()):
+ to_return += str(' ', tests[i].name, "\n")
+ return to_return
+
+ func get_new():
+ var TheScript = load(path)
+ var inst = null
+ if(inner_class_name != null):
+ inst = TheScript.get(inner_class_name).new()
+ else:
+ inst = TheScript.new()
+ return inst
+
+ func get_full_name():
+ var to_return = path
+ if(inner_class_name != null):
+ to_return += '.' + inner_class_name
+ return to_return
+
+ func get_filename():
+ return path.get_file()
+
+ func has_inner_class():
+ return inner_class_name != null
+
+ func export_to(config_file, section):
+ config_file.set_value(section, 'path', path)
+ config_file.set_value(section, 'inner_class', inner_class_name)
+ var names = []
+ for i in range(tests.size()):
+ names.append(tests[i].name)
+ config_file.set_value(section, 'tests', names)
+
+ func _remap_path(source_path):
+ var to_return = source_path
+ if(!_utils.file_exists(source_path)):
+ _lgr.debug('Checking for remap for: ' + source_path)
+ var remap_path = source_path.get_basename() + '.gd.remap'
+ if(_utils.file_exists(remap_path)):
+ var cf = ConfigFile.new()
+ cf.load(remap_path)
+ to_return = cf.get_value('remap', 'path')
+ else:
+ _lgr.warn('Could not find remap file ' + remap_path)
+ return to_return
+
+ func import_from(config_file, section):
+ path = config_file.get_value(section, 'path')
+ path = _remap_path(path)
+ var test_names = config_file.get_value(section, 'tests')
+ for i in range(test_names.size()):
+ var t = Test.new()
+ t.name = test_names[i]
+ tests.append(t)
+ # Null is an acceptable value, but you can't pass null as a default to
+ # get_value since it thinks you didn't send a default...then it spits
+ # out red text. This works around that.
+ var inner_name = config_file.get_value(section, 'inner_class', 'Placeholder')
+ if(inner_name != 'Placeholder'):
+ inner_class_name = inner_name
+ else: # just being explicit
+ inner_class_name = null
+
+
+# ------------------------------------------------------------------------------
+# start test_collector, I don't think I like the name.
+# ------------------------------------------------------------------------------
+var scripts = []
+var _test_prefix = 'test_'
+var _test_class_prefix = 'Test'
+
+var _utils = load('res://addons/gut/utils.gd').new()
+var _lgr = _utils.get_logger()
+
+func _parse_script(script):
+ var file = File.new()
+ var line = ""
+ var line_count = 0
+ var inner_classes = []
+ var scripts_found = []
+
+ file.open(script.path, 1)
+ while(!file.eof_reached()):
+ line_count += 1
+ line = file.get_line()
+ #Add a test
+ if(line.begins_with("func " + _test_prefix)):
+ var from = line.find(_test_prefix)
+ var line_len = line.find("(") - from
+ var new_test = Test.new()
+ new_test.name = line.substr(from, line_len)
+ new_test.line_number = line_count
+ script.tests.append(new_test)
+
+ if(line.begins_with('class ')):
+ var iclass_name = line.replace('class ', '')
+ iclass_name = iclass_name.replace(':', '')
+ if(iclass_name.begins_with(_test_class_prefix)):
+ inner_classes.append(iclass_name)
+
+ scripts_found.append(script.path)
+
+ for i in range(inner_classes.size()):
+ var ts = TestScript.new(_utils, _lgr)
+ ts.path = script.path
+ ts.inner_class_name = inner_classes[i]
+ if(_parse_inner_class_tests(ts)):
+ scripts.append(ts)
+ scripts_found.append(script.path + '[' + inner_classes[i] +']')
+
+ file.close()
+ return scripts_found
+
+func _parse_inner_class_tests(script):
+ var inst = script.get_new()
+
+ if(!inst is _utils.Test):
+ _lgr.warn('Ignoring ' + script.inner_class_name + ' because it starts with "' + _test_class_prefix + '" but does not extend addons/gut/test.gd')
+ return false
+
+ var methods = inst.get_method_list()
+ for i in range(methods.size()):
+ var name = methods[i]['name']
+ if(name.begins_with(_test_prefix) and methods[i]['flags'] == 65):
+ var t = Test.new()
+ t.name = name
+ script.tests.append(t)
+
+ return true
+# -----------------
+# Public
+# -----------------
+func add_script(path):
+ # SHORTCIRCUIT
+ if(has_script(path)):
+ return []
+
+ var f = File.new()
+ # SHORTCIRCUIT
+ if(!f.file_exists(path)):
+ _lgr.error('Could not find script: ' + path)
+ return
+
+ var ts = TestScript.new(_utils, _lgr)
+ ts.path = path
+ scripts.append(ts)
+ return _parse_script(ts)
+
+func to_s():
+ var to_return = ''
+ for i in range(scripts.size()):
+ to_return += scripts[i].to_s() + "\n"
+ return to_return
+func get_logger():
+ return _lgr
+
+func set_logger(logger):
+ _lgr = logger
+
+func get_test_prefix():
+ return _test_prefix
+
+func set_test_prefix(test_prefix):
+ _test_prefix = test_prefix
+
+func get_test_class_prefix():
+ return _test_class_prefix
+
+func set_test_class_prefix(test_class_prefix):
+ _test_class_prefix = test_class_prefix
+
+func clear():
+ scripts.clear()
+
+func has_script(path):
+ var found = false
+ var idx = 0
+ while(idx < scripts.size() and !found):
+ if(scripts[idx].path == path):
+ found = true
+ else:
+ idx += 1
+ return found
+
+func export_tests(path):
+ var success = true
+ var f = ConfigFile.new()
+ for i in range(scripts.size()):
+ scripts[i].export_to(f, str('TestScript-', i))
+ var result = f.save(path)
+ if(result != OK):
+ _lgr.error(str('Could not save exported tests to [', path, ']. Error code: ', result))
+ success = false
+ return success
+
+func import_tests(path):
+ var success = false
+ var f = ConfigFile.new()
+ var result = f.load(path)
+ if(result != OK):
+ _lgr.error(str('Could not load exported tests from [', path, ']. Error code: ', result))
+ else:
+ var sections = f.get_sections()
+ for key in sections:
+ var ts = TestScript.new(_utils, _lgr)
+ ts.import_from(f, key)
+ scripts.append(ts)
+ success = true
+ return success
diff --git a/addons/gut/thing_counter.gd b/addons/gut/thing_counter.gd
new file mode 100644
index 0000000..a9b0b48
--- /dev/null
+++ b/addons/gut/thing_counter.gd
@@ -0,0 +1,43 @@
+var things = {}
+
+func get_unique_count():
+ return things.size()
+
+func add(thing):
+ if(things.has(thing)):
+ things[thing] += 1
+ else:
+ things[thing] = 1
+
+func has(thing):
+ return things.has(thing)
+
+func get(thing):
+ var to_return = 0
+ if(things.has(thing)):
+ to_return = things[thing]
+ return to_return
+
+func sum():
+ var count = 0
+ for key in things:
+ count += things[key]
+ return count
+
+func to_s():
+ var to_return = ""
+ for key in things:
+ to_return += str(key, ": ", things[key], "\n")
+ to_return += str("sum: ", sum())
+ return to_return
+
+func get_max_count():
+ var max_val = null
+ for key in things:
+ if(max_val == null or things[key] > max_val):
+ max_val = things[key]
+ return max_val
+
+func add_array_items(array):
+ for i in range(array.size()):
+ add(array[i])
diff --git a/addons/gut/utils.gd b/addons/gut/utils.gd
new file mode 100644
index 0000000..7f44f26
--- /dev/null
+++ b/addons/gut/utils.gd
@@ -0,0 +1,160 @@
+var _Logger = load('res://addons/gut/logger.gd') # everything should use get_logger
+
+var Doubler = load('res://addons/gut/doubler.gd')
+var Gut = load('res://addons/gut/gut.gd')
+var HookScript = load('res://addons/gut/hook_script.gd')
+var MethodMaker = load('res://addons/gut/method_maker.gd')
+var Spy = load('res://addons/gut/spy.gd')
+var Stubber = load('res://addons/gut/stubber.gd')
+var StubParams = load('res://addons/gut/stub_params.gd')
+var Summary = load('res://addons/gut/summary.gd')
+var Test = load('res://addons/gut/test.gd')
+var TestCollector = load('res://addons/gut/test_collector.gd')
+var ThingCounter = load('res://addons/gut/thing_counter.gd')
+var OneToMany = load('res://addons/gut/one_to_many.gd')
+
+const GUT_METADATA = '__gut_metadata_'
+
+enum DOUBLE_STRATEGY{
+ FULL,
+ PARTIAL
+}
+
+var escape = PoolByteArray([0x1b]).get_string_from_ascii()
+var CMD_COLORS = {
+ RED = escape + '[31m',
+ YELLOW = escape + '[33m',
+ DEFAULT = escape + '[0m',
+ GREEN = escape + '[32m',
+ UNDERLINE = escape + '[4m',
+ BOLD = escape + '[1m'
+}
+
+func colorize_word(source, word, c):
+ var new_word = c + word + CMD_COLORS.DEFAULT
+ return source.replace(word, new_word)
+
+func colorize_text(text):
+ var t = colorize_word(text, 'FAILED', CMD_COLORS.RED)
+ t = colorize_word(t, 'PASSED', CMD_COLORS.GREEN)
+ t = colorize_word(t, 'PENDING', CMD_COLORS.YELLOW)
+ t = colorize_word(t, '[ERROR]', CMD_COLORS.RED)
+ t = colorize_word(t, '[WARNING]', CMD_COLORS.YELLOW)
+ t = colorize_word(t, '[DEBUG]', CMD_COLORS.BOLD)
+ t = colorize_word(t, '[DEPRECATED]', CMD_COLORS.BOLD)
+ t = colorize_word(t, '[INFO]', CMD_COLORS.BOLD)
+ return t
+
+
+var _file_checker = File.new()
+
+func is_version_30():
+ var info = Engine.get_version_info()
+ return info.major == 3 and info.minor == 0
+
+func is_version_31():
+ var info = Engine.get_version_info()
+ return info.major == 3 and info.minor == 1
+
+# ------------------------------------------------------------------------------
+# Everything should get a logger through this.
+#
+# Eventually I want to make this get a single instance of a logger but I'm not
+# sure how to do that without everything having to be in the tree which I
+# DO NOT want to to do. I'm thinking of writings some instance ids to a file
+# and loading them in the _init for this.
+# ------------------------------------------------------------------------------
+func get_logger():
+ return _Logger.new()
+
+# ------------------------------------------------------------------------------
+# Returns an array created by splitting the string by the delimiter
+# ------------------------------------------------------------------------------
+func split_string(to_split, delim):
+ var to_return = []
+
+ var loc = to_split.find(delim)
+ while(loc != -1):
+ to_return.append(to_split.substr(0, loc))
+ to_split = to_split.substr(loc + 1, to_split.length() - loc)
+ loc = to_split.find(delim)
+ to_return.append(to_split)
+ return to_return
+
+# ------------------------------------------------------------------------------
+# Returns a string containing all the elements in the array separated by delim
+# ------------------------------------------------------------------------------
+func join_array(a, delim):
+ var to_return = ''
+ for i in range(a.size()):
+ to_return += str(a[i])
+ if(i != a.size() -1):
+ to_return += str(delim)
+ return to_return
+
+# ------------------------------------------------------------------------------
+# return if_null if value is null otherwise return value
+# ------------------------------------------------------------------------------
+func nvl(value, if_null):
+ if(value == null):
+ return if_null
+ else:
+ return value
+
+# ------------------------------------------------------------------------------
+# returns true if the object has been freed, false if not
+#
+# From what i've read, the weakref approach should work. It seems to work most
+# of the time but sometimes it does not catch it. The str comparison seems to
+# fill in the gaps. I've not seen any errors after adding that check.
+# ------------------------------------------------------------------------------
+func is_freed(obj):
+ var wr = weakref(obj)
+ return !(wr.get_ref() and str(obj) != '[Deleted Object]')
+
+func is_not_freed(obj):
+ return !is_freed(obj)
+
+func is_double(obj):
+ return obj.get(GUT_METADATA) != null
+
+func extract_property_from_array(source, property):
+ var to_return = []
+ for i in (source.size()):
+ to_return.append(source[i].get(property))
+ return to_return
+
+func file_exists(path):
+ return _file_checker.file_exists(path)
+
+func write_file(path, content):
+ var f = File.new()
+ f.open(path, f.WRITE)
+ f.store_string(content)
+ f.close()
+
+func is_null_or_empty(text):
+ return text == null or text == ''
+
+func get_native_class_name(thing):
+ var to_return = null
+ if(is_native_class(thing)):
+ to_return = thing.new().get_class()
+ return to_return
+
+func is_native_class(thing):
+ var it_is = false
+ if(typeof(thing) == TYPE_OBJECT):
+ it_is = str(thing).begins_with("[GDScriptNativeClass:")
+ return it_is
+
+# ------------------------------------------------------------------------------
+# ------------------------------------------------------------------------------
+func get_file_as_text(path):
+ var to_return = ''
+ var f = File.new()
+ var result = f.open(path, f.READ)
+ if(result == OK):
+ to_return = f.get_as_text()
+ f.close()
+ return to_return
diff --git a/default_env.tres b/default_env.tres
new file mode 100644
index 0000000..20207a4
--- /dev/null
+++ b/default_env.tres
@@ -0,0 +1,7 @@
+[gd_resource type="Environment" load_steps=2 format=2]
+
+[sub_resource type="ProceduralSky" id=1]
+
+[resource]
+background_mode = 2
+background_sky = SubResource( 1 )
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..0a4909c
Binary files /dev/null and b/icon.png differ
diff --git a/icon.png.import b/icon.png.import
new file mode 100644
index 0000000..96cbf46
--- /dev/null
+++ b/icon.png.import
@@ -0,0 +1,34 @@
+[remap]
+
+importer="texture"
+type="StreamTexture"
+path="res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.png"
+dest_files=[ "res://.import/icon.png-487276ed1e3a0c39cad0279d744ee560.stex" ]
+
+[params]
+
+compress/mode=0
+compress/lossy_quality=0.7
+compress/hdr_mode=0
+compress/bptc_ldr=0
+compress/normal_map=0
+flags/repeat=0
+flags/filter=true
+flags/mipmaps=false
+flags/anisotropic=false
+flags/srgb=2
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/HDR_as_SRGB=false
+process/invert_color=false
+stream=false
+size_limit=0
+detect_3d=true
+svg/scale=1.0
diff --git a/project.godot b/project.godot
new file mode 100644
index 0000000..a2b366e
--- /dev/null
+++ b/project.godot
@@ -0,0 +1,99 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=4
+
+_global_script_classes=[ ]
+_global_script_class_icons={
+
+}
+
+[application]
+
+config/name="GodotXterm"
+config/description="Xterm.js in Godot."
+run/main_scene="res://scenes/demo.tscn"
+config/icon="res://icon.png"
+
+[editor_plugins]
+
+enabled=PoolStringArray( "godot_xterm", "gut" )
+
+[input]
+
+ui_accept={
+"deadzone": 0.5,
+"events": [ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":16777222,"unicode":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
+ ]
+}
+ui_cancel={
+"deadzone": 0.5,
+"events": [ Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
+ ]
+}
+ui_left={
+"deadzone": 0.5,
+"events": [ Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
+ ]
+}
+ui_right={
+"deadzone": 0.5,
+"events": [ Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":15,"pressure":0.0,"pressed":false,"script":null)
+ ]
+}
+ui_up={
+"deadzone": 0.5,
+"events": [ Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
+ ]
+}
+ui_down={
+"deadzone": 0.5,
+"events": [ Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
+ ]
+}
+jump={
+"deadzone": 0.5,
+"events": [ ]
+}
+sprint={
+"deadzone": 0.5,
+"events": [ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":16777238,"unicode":0,"echo":false,"script":null)
+ ]
+}
+crouch={
+"deadzone": 0.5,
+"events": [ ]
+}
+interact={
+"deadzone": 0.5,
+"events": [ ]
+}
+walk={
+"deadzone": 0.5,
+"events": [ Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"pressed":false,"scancode":16777240,"unicode":0,"echo":false,"script":null)
+ ]
+}
+pick_up={
+"deadzone": 0.5,
+"events": [ ]
+}
+throw={
+"deadzone": 0.5,
+"events": [ Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":0,"alt":false,"shift":false,"control":false,"meta":false,"command":false,"button_mask":0,"position":Vector2( 0, 0 ),"global_position":Vector2( 0, 0 ),"factor":1.0,"button_index":1,"pressed":false,"doubleclick":false,"script":null)
+ ]
+}
+reset={
+"deadzone": 0.5,
+"events": [ ]
+}
+
+[rendering]
+
+quality/filters/msaa=4
+environment/default_environment="res://default_env.tres"
diff --git a/scenes/demo.gd b/scenes/demo.gd
new file mode 100644
index 0000000..e725925
--- /dev/null
+++ b/scenes/demo.gd
@@ -0,0 +1,60 @@
+# Copyright (c) 2020 The GodotTerm authors. All rights reserved.
+# License MIT
+extends Control
+
+
+signal data_received(data)
+
+
+# The user must have these programs installed for this to work.
+const dependencies = PoolStringArray(['which', 'socat', 'bash'])
+const host = '127.0.0.1'
+const port = 17154
+
+
+var socat_pid = -1
+var stream_peer = StreamPeerTCP.new()
+
+
+func _ready():
+ # First check that dependencies are installed and in $PATH.
+ var exit_code = OS.execute("which", dependencies)
+ if exit_code != 0:
+ OS.alert("Make sure the following programs are installed and in your $PATH: " + \
+ dependencies.join(", ") + ".", "Misssing Dependencies!")
+
+ # Start socat.
+ socat_pid = OS.execute("socat",
+ ["-d", "-d", "tcp-l:%d,bind=%s,reuseaddr,fork" % [port, host],
+ "exec:bash,pty,setsid,stderr,login,ctty"], false)
+
+ # Create a StreamPeerTCP to connect to socat.
+ var err = stream_peer.connect_to_host(host, port)
+ if err != OK:
+ OS.alert("Couldn't connect to socat on %s:%d" % [host, port], "Connection Failed!")
+
+
+ var status = stream_peer.get_status()
+ var connected = stream_peer.is_connected_to_host()
+
+ # Connect the Terminal and StreamPeer.
+ $Terminal.connect('data_sent', self, 'send_data')
+ connect("data_received", $Terminal, "receive_data")
+
+
+func send_data(data: PoolByteArray):
+ stream_peer.put_data(data)
+
+
+func _process(delta):
+ if stream_peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
+ var res = stream_peer.get_data(stream_peer.get_available_bytes())
+ var error = res[0]
+ var data = res[1]
+ if error == OK and not data.empty():
+ emit_signal("data_received", data)
+
+
+func _exit_tree():
+ if socat_pid != -1:
+ OS.execute("kill", ["-9", socat_pid], false)
diff --git a/scenes/demo.tscn b/scenes/demo.tscn
new file mode 100644
index 0000000..2df8e4e
--- /dev/null
+++ b/scenes/demo.tscn
@@ -0,0 +1,24 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://scenes/demo.gd" type="Script" id=1]
+[ext_resource path="res://addons/godot_xterm/terminal.gd" type="Script" id=2]
+
+[node name="Demo" type="Control"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_left = 120.0
+margin_top = 80.0
+margin_right = 120.0
+margin_bottom = 80.0
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Terminal" type="Control" parent="."]
+margin_right = 631.0
+margin_bottom = 401.0
+script = ExtResource( 2 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
diff --git a/test/integration/test_terminal.gd b/test/integration/test_terminal.gd
new file mode 100644
index 0000000..5619251
--- /dev/null
+++ b/test/integration/test_terminal.gd
@@ -0,0 +1,108 @@
+# Copyright (c) 2020 The GodotXterm authors. All rights reserved.
+# License MIT
+extends "res://addons/gut/test.gd"
+
+const Parser = preload("res://addons/godot_xterm/parser/escape_sequence_parser.gd")
+const Terminal = preload("res://addons/godot_xterm/terminal.gd")
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+const Constants = preload("res://addons/godot_xterm/parser/constants.gd")
+
+const C0 = Constants.C0
+const C1 = Constants.C1
+
+class TestBuffer:
+ var calls = []
+ var printed = ''
+
+ func handle_print(data, start, end):
+ var string = Decoder.utf32_to_string(data.slice(start, end - 1))
+ calls.append(['print', string])
+ printed += string
+
+
+ func handle_exec():
+ calls.append(['exec'])
+
+
+ func handle_csi(params):
+ calls.append(['csi', params])
+
+
+ func clear():
+ printed = ''
+ calls.resize(0)
+
+
+var parser
+var buffer
+var decoder
+
+
+func parse(parser, string):
+ var container = []
+ container.resize(string.length())
+ var length = decoder.decode(string.to_utf8(), container)
+ parser.parse(container, length)
+
+
+func before_all():
+ buffer = TestBuffer.new()
+ decoder = Decoder.Utf8ToUtf32.new()
+
+
+func before_each():
+ parser = Parser.new()
+ parser.set_print_handler(buffer, 'handle_print')
+ buffer.clear()
+
+
+func test_prints_printables():
+ var string = 'bash-4.4# '
+ var data = string.to_utf8()
+ var length = decoder.decode(data, data)
+ parser.parse(data, length)
+ assert_eq(buffer.calls, [['print', 'bash-4.4# ']])
+ assert_eq(buffer.printed, 'bash-4.4# ')
+
+
+func skip_test_c0():
+ for code in C0.values():
+ parser.set_execute_handler(code, buffer, 'handle_exec')
+ parse(parser, Decoder.string_from_codepoint(code))
+ if code == 0x0 or code == 0x1b or code == 0x20 or code == 0x7f:
+ assert_eq(buffer.calls, [])
+ else:
+ assert_eq(buffer.calls, [['exec']], 'code: 0x%x' % code)
+ assert_eq(buffer.printed, '')
+ parser.reset()
+ buffer.clear()
+
+
+func skip_test_c1():
+ for code in C1.values():
+ parser.set_execute_handler(code, buffer, 'handle_exec')
+ parse(parser, Decoder.string_from_codepoint(code))
+ assert_eq(buffer.calls, [['exec']], 'code: 0x%x' % code)
+ assert_eq(buffer.printed, '')
+ parser.reset()
+ buffer.clear()
+
+
+func test_print_csi_print():
+ parser.set_csi_handler({'final': 'g'}, buffer, 'handle_csi')
+ parse(parser, 'a\u001b[gb')
+ assert_eq(buffer.calls, [['print', 'a'],['csi', [0]], ['print', 'b']])
+ assert_eq(buffer.printed, 'ab')
+
+
+func test_csi_position_cursor():
+ parser.set_csi_handler({'final': 'H'}, buffer, 'handle_csi')
+ parse(parser, '\u001b[1;5H')
+ assert_eq(buffer.calls, [['csi', [1, 5]]])
+ assert_eq(buffer.printed, '')
+
+
+
+
+
+
diff --git a/test/test.tscn b/test/test.tscn
new file mode 100644
index 0000000..1214701
--- /dev/null
+++ b/test/test.tscn
@@ -0,0 +1,17 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://addons/gut/gut.gd" type="Script" id=1]
+
+[node name="Gut" type="Control"]
+self_modulate = Color( 1, 1, 1, 0 )
+margin_right = 1031.0
+margin_bottom = 597.0
+rect_min_size = Vector2( 740, 250 )
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": false
+}
+_yield_between_tests = false
+_directory1 = "res://test/unit"
+_directory2 = "res://test/integration"
+_double_strategy = 1
diff --git a/test/unit/test_dcs_parser.gd b/test/unit/test_dcs_parser.gd
new file mode 100644
index 0000000..cbe6459
--- /dev/null
+++ b/test/unit/test_dcs_parser.gd
@@ -0,0 +1,100 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends "res://addons/gut/test.gd"
+
+const DcsParser = preload("res://addons/godot_xterm/parser/dcs_parser.gd")
+const Parser = preload("res://addons/godot_xterm/parser/escape_sequence_parser.gd")
+const Params = preload("res://addons/godot_xterm/parser/params.gd")
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+
+class Handler:
+ extends Reference
+
+
+ var _output
+ var _msg
+ var _return_false
+
+
+ func _init(output: Array, msg: String, return_false: bool = false):
+ _output = output
+ _msg = msg
+ _return_false = return_false
+
+
+ func hook(params):
+ _output.append([_msg, 'HOOK', params.to_array()])
+
+
+ func put(data: Array, start: int, end: int):
+ _output.append([_msg, 'PUT', Decoder.utf32_to_string(data, start, end)])
+
+
+ func unhook(success: bool):
+ _output.append([_msg, 'UNHOOK', success])
+ if _return_false:
+ return false
+
+
+var parser: DcsParser
+var reports: Array
+
+
+func to_utf32(s: String):
+ var utf32 = []
+ utf32.resize(s.length())
+ var decoder = Decoder.Utf8ToUtf32.new()
+ var length = decoder.decode(s.to_utf8(), utf32)
+ assert_eq(length, s.length())
+ return utf32.slice(0, length - 1)
+
+
+func handler_fallback(id, action, data):
+ if action == 'HOOK':
+ data = data.to_array()
+ reports.append([id, action, data])
+
+
+func before_each():
+ parser = DcsParser.new()
+ parser.set_handler_fallback(self, 'handler_fallback')
+ reports = []
+
+
+func test_set_dcs_handler():
+ parser.set_handler(Parser.identifier({'intermediates': '+', 'final': 'p'}),
+ Handler.new(reports, 'th'))
+ parser.hook(Parser.identifier({'intermediates': '+', 'final': 'p'}),
+ Params.from_array([1, 2, 3]))
+ var data = to_utf32('Here comes')
+ parser.put(data, 0, data.size())
+ data = to_utf32('the mouse!')
+ parser.put(data, 0, data.size())
+ parser.unhook(true)
+ assert_eq(reports, [
+ # messages from Handler
+ ['th', 'HOOK', [1, 2, 3]],
+ ['th', 'PUT', 'Here comes'],
+ ['th', 'PUT', 'the mouse!'],
+ ['th', 'UNHOOK', true],
+ ])
+
+
+func test_clear_dcs_handler():
+ var ident = Parser.identifier({'intermediates': '+', 'final': 'p'})
+ parser.set_handler(ident, Handler.new(reports, 'th'))
+ parser.clear_handler(ident)
+ parser.hook(ident, Params.from_array([1, 2, 3]))
+ var data = to_utf32('Here comes')
+ parser.put(data, 0, data.size())
+ data = to_utf32('the mouse!')
+ parser.put(data, 0, data.size())
+ parser.unhook(true)
+ assert_eq(reports, [
+ # messages from fallback handler
+ [ident, 'HOOK', [1, 2, 3]],
+ [ident, 'PUT', 'Here comes'],
+ [ident, 'PUT', 'the mouse!'],
+ [ident, 'UNHOOK', true],
+ ])
diff --git a/test/unit/test_escape_sequence_parser.gd b/test/unit/test_escape_sequence_parser.gd
new file mode 100644
index 0000000..9e55a64
--- /dev/null
+++ b/test/unit/test_escape_sequence_parser.gd
@@ -0,0 +1,411 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2018 The xterm.js authors. All rights reserved.
+# License MIT
+extends 'res://addons/gut/test.gd'
+
+const Parser = preload("res://addons/godot_xterm/parser/escape_sequence_parser.gd")
+const Params = preload("res://addons/godot_xterm/parser/params.gd")
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+const Constants = preload("res://addons/godot_xterm/parser/constants.gd")
+const ParserState = Constants.ParserState
+
+class TestTerminal:
+ var calls = []
+
+ func clear():
+ calls = []
+
+
+ func handle_print(data, start, end):
+ var string = Decoder.utf32_to_string(data, start, end)
+ calls.append(['print', string])
+
+
+ func handle_csi(ident, params):
+ var id = Parser.ident_to_string(ident)
+ var collect = id.substr(0, id.length() - 1)
+ var flag = id.substr(id.length() - 1, 1)
+ calls.append(['csi', collect, params, flag])
+
+
+ func handle_esc(ident: int):
+ var id = Parser.ident_to_string(ident)
+ var collect = id.substr(0, id.length() - 1)
+ var flag = id.substr(id.length() - 1, 1)
+ calls.append(['esc', collect, flag])
+
+
+ func handle_execute(code: int):
+ var flag = Decoder.string_from_codepoint(code)
+ calls.append(['exe', flag])
+
+
+ func handle_dcs(collect_and_flag, action, payload):
+ match action:
+ 'HOOK':
+ calls.append(['dcs hook', payload.to_array()])
+ 'PUT':
+ calls.append(['dcs put', payload])
+ 'UNHOOK':
+ calls.append(['dcs unhook', payload])
+
+
+# derived parser with access to internal states
+class TestParser:
+ extends Parser
+
+ var params setget _set_params,_get_params
+ var collect setget _set_collect,_get_collect
+
+
+ func _init():
+ pass
+
+
+ func _set_params(value):
+ _params = Params.from_array(value)
+
+
+ func _get_params():
+ return _params.to_array()
+
+
+ func _set_collect(value: String):
+ _collect = 0
+ for c in value.to_ascii():
+ _collect <<= 8
+ _collect |= c
+
+
+ func _get_collect() -> String:
+ return ident_to_string(_collect)
+
+
+ func real_params():
+ return _params
+
+# translate string based parse calls into typed array based
+func parse(parser: TestParser, data):
+ if data == '': # handle the 0x00 codepoint
+ data = PoolByteArray([0])
+ else:
+ data = data.to_utf8()
+ var container = []
+ var decoder = Decoder.Utf8ToUtf32.new()
+ decoder.clear()
+ var length = decoder.decode(data, container)
+ parser.parse(container, length)
+
+
+var parser
+var test_terminal
+
+
+func before_all():
+ parser = TestParser.new()
+ test_terminal = TestTerminal.new()
+
+ parser.set_print_handler(test_terminal, 'handle_print')
+ parser.set_csi_handler_fallback(test_terminal, 'handle_csi')
+ parser.set_esc_handler_fallback(test_terminal, 'handle_esc')
+ parser.set_execute_handler_fallback(test_terminal, "handle_execute")
+ parser.set_dcs_handler_fallback(test_terminal, "handle_dcs")
+
+
+func before_each():
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_initial_states():
+ assert_eq(parser.initial_state, ParserState.GROUND)
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(parser._params.to_array(), [0])
+
+func test_reset_states():
+ var params = Params.new()
+ params.add_param(123)
+ parser.current_state = 124
+ parser._params = params
+ parser.reset()
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(parser._params.to_array(), [0])
+
+# state transitions and actions
+
+func test_state_GROUND_execute_action():
+ var exes = range(0x00, 0x18) + [0x19] + range(0x1c, 0x20)
+ for exe in exes:
+ parser.current_state = ParserState.GROUND
+ parse(parser, Decoder.string_from_codepoint(exe))
+ assert_eq(parser.current_state, ParserState.GROUND)
+ parser.reset()
+
+func test_state_GROUND_print_action():
+ var printables = range(0x20, 0x7f) # NOTE: DEL excluded
+ for printable in printables:
+ var string = Decoder.string_from_codepoint(printable)
+ parser.current_state = ParserState.GROUND
+ parse(parser, string)
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(test_terminal.calls, [['print', string]])
+ parser.reset()
+ test_terminal.clear()
+
+func test_trans_ANYWHERE_to_GROUND_with_actions():
+ var exes = [
+ '\u0018', '\u001a',
+ '\u0080', '\u0081', '\u0082', '\u0083', '\u0084', '\u0085', '\u0086', '\u0087', '\u0088',
+ '\u0089', '\u008a', '\u008b', '\u008c', '\u008d', '\u008e', '\u008f',
+ '\u0091', '\u0092', '\u0093', '\u0094', '\u0095', '\u0096', '\u0097', '\u0099', '\u009a'
+ ]
+ var exceptions = {
+ 8: { '\u0018': [], '\u001a': [] }, # abort OSC_STRING
+ 13: { '\u0018': [['dcs unhook', false]], '\u001a': [['dcs unhook', false]] } # abort DCS_PASSTHROUGH
+ }
+ for state in ParserState.values():
+ for exe in exes:
+ if exe != '\u0018' and exe != '\u001a':
+ continue
+ parser.current_state = state
+ parse(parser, exe)
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(
+ test_terminal.calls,
+ exceptions[state][exe] if exceptions.has(state) and exceptions[state].has(exe) else [['exe', exe]],
+ 'state: %s exe: %x' % [ParserState.keys()[state], exe.to_utf8()[0]]
+ )
+ parser.reset()
+ test_terminal.clear()
+ parse(parser, '\u009c')
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(test_terminal.calls, [])
+ parser.reset()
+ test_terminal.clear()
+
+
+func skip_test_trans_ANYWHERE_to_ESCAPE_with_clear():
+ for state in ParserState.values():
+ var state_name = ParserState.keys()[state]
+ parser.current_state = state
+ parser.params = [23]
+ parser.collect = '#'
+ parse(parser, '\u001b')
+ assert_eq(parser.current_state, ParserState.ESCAPE,
+ 'wrong current_state. start state: %s' % state_name)
+ assert_eq(parser.params, [0],
+ 'wrong params. start state: %s' % state_name)
+ assert_eq(parser.collect, '',
+ 'wrong collect. start state: %s' % state_name)
+ parser.reset()
+
+
+func test_state_ESCAPE_execute_rules():
+ var exes = range(0x00, 0x18) + [0x19] + range(0x1c, 0x20)
+ for exe in exes:
+ parser.current_state = ParserState.ESCAPE
+ var data = Decoder.string_from_codepoint(exe)
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.ESCAPE, 'exe: %x' % exe)
+ assert_eq(test_terminal.calls, [['exe', data]], 'exe: %x' % exe)
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_state_ESCAPE_ignore():
+ parser.current_state = ParserState.ESCAPE
+ parse(parser, '\u007f')
+ assert_eq(parser.current_state, ParserState.ESCAPE)
+ assert_eq(test_terminal.calls, [])
+
+func test_trans_ESCAPE_to_GROUND_with_esc_dispatch_action():
+ var dispatches = range(0x30, 0x50) + range(0x51, 0x58) + [0x59, 0x5a] + range(0x60, 0x7f)
+ for dispatch in dispatches:
+ parser.current_state = ParserState.ESCAPE
+ var data = Decoder.string_from_codepoint(dispatch)
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.GROUND,
+ 'wrong state: %s, dispatch: %x' % [ParserState.keys()[parser.current_state], dispatch])
+ assert_eq(test_terminal.calls, [['esc', '', data]],
+ 'wrong call. dispatch: %x' % dispatch)
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_trans_ESCAPE_to_ESCAPE_INTERMEDIATE_with_collect_action():
+ var collect = range(0x20, 0x30)
+ for c in collect:
+ parser.current_state = ParserState.ESCAPE
+ var data = Decoder.string_from_codepoint(c)
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.ESCAPE_INTERMEDIATE)
+ assert_eq(parser.collect, data)
+ parser.reset()
+
+
+func test_state_ESCAPE_INTERMEDIATE_execute_rules():
+ var exes = range(0x00, 0x18) + [0x19] + range(0x1c, 0x20)
+ for exe in exes:
+ var data = Decoder.string_from_codepoint(exe)
+ parser.current_state = ParserState.ESCAPE_INTERMEDIATE
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.ESCAPE_INTERMEDIATE)
+ assert_eq(test_terminal.calls, [['exe', data]])
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_state_ESCAPE_INTERMEDIATE_ignore():
+ parser.current_state = ParserState.ESCAPE_INTERMEDIATE
+ parse(parser, '\u007f')
+ assert_eq(parser.current_state, ParserState.ESCAPE_INTERMEDIATE)
+ assert_eq(test_terminal.calls, [])
+
+
+func test_state_ESCAPE_INTERMEDIATE_collect_action():
+ var collect = range(0x20, 0x30)
+ for c in collect:
+ var data = Decoder.string_from_codepoint(c)
+ parser.current_state = ParserState.ESCAPE_INTERMEDIATE
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.ESCAPE_INTERMEDIATE)
+ assert_eq(parser.collect, data)
+ parser.reset()
+
+
+func test_trans_ESCAPE_INTERMEDIATE_to_GROUND_with_esc_dispatch_action():
+ var collect = range(0x30, 0x7f)
+ for c in collect:
+ var data = Decoder.string_from_codepoint(c)
+ parser.current_state = ParserState.ESCAPE_INTERMEDIATE
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.GROUND)
+ # '\u005c' --> ESC + \ (7bit ST) parser does not expose this as it already got handled
+ assert_eq(test_terminal.calls, [] if c == 0x5c else [['esc', '', data]], 'c: 0x%x' % c)
+ parser.reset()
+ test_terminal.clear()
+
+func test_ANYWHERE_or_ESCAPE_to_CSI_ENTRY_with_clear():
+ # C0
+ parser.current_state = ParserState.ESCAPE
+ parser.params = [123]
+ parser.collect = '#'
+ parse(parser, '[')
+ assert_eq(parser.current_state, ParserState.CSI_ENTRY)
+ assert_eq(parser.params, [0])
+ assert_eq(parser.collect, '')
+ parser.reset()
+ # C1
+ for state in ParserState.values():
+ parser.current_state = state
+ parser.params = [123]
+ parser.collect = '#'
+ parse(parser, '\u009b')
+ assert_eq(parser.current_state, ParserState.CSI_ENTRY)
+ assert_eq(parser.collect, '')
+ parser.reset()
+
+
+func test_CSI_ENTRY_execute_rules():
+ var exes = range(0x00, 0x18) + [0x19] + range(0x1c, 0x20)
+ for exe in exes:
+ var data = Decoder.string_from_codepoint(exe)
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.CSI_ENTRY)
+ assert_eq(test_terminal.calls, [['exe', data]])
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_state_CSI_ENTRY_ignore():
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, '\u007f')
+ assert_eq(parser.current_state, ParserState.CSI_ENTRY)
+ assert_eq(test_terminal.calls, [])
+
+
+func test_trans_CSI_ENTRY_to_GROUND_with_csi_dispatch_action():
+ var dispatches = range(0x40, 0x7f)
+ for dispatch in dispatches:
+ var data = Decoder.string_from_codepoint(dispatch)
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(test_terminal.calls, [['csi', '', [0], data]])
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_trans_CSI_ENTRY_to_CSI_PARAMS_with_param_or_collect_action():
+ var params = range(0x30, 0x3a)
+ var collect = ['\u003c', '\u003d', '\u003e', '\u003f']
+ for param in params:
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, Decoder.string_from_codepoint(param))
+ assert_eq(parser.current_state, ParserState.CSI_PARAM)
+ assert_eq(parser.params, [param - 48], 'param: 0x%x' % param)
+ parser.reset()
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, '\u003b')
+ assert_eq(parser.current_state, ParserState.CSI_PARAM)
+ assert_eq(parser.params, [0, 0])
+ parser.reset()
+ for c in collect:
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, c)
+ assert_eq(parser.current_state, ParserState.CSI_PARAM)
+ assert_eq(parser.collect, c)
+ parser.reset()
+
+
+func test_state_CSI_PARAM_execute_rules():
+ var exes = range(0x00, 0x018) + [0x19] + range(0x1c, 0x20)
+ for exe in exes:
+ var data = Decoder.string_from_codepoint(exe)
+ parser.current_state = ParserState.CSI_PARAM
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.CSI_PARAM)
+ assert_eq(test_terminal.calls, [['exe', data]])
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_state_CSI_PARAM_param_action():
+ var params = range(0x30, 0x3a)
+ for param in params:
+ parser.current_state = ParserState.CSI_PARAM
+ parse(parser, Decoder.string_from_codepoint(param))
+ assert_eq(parser.current_state, ParserState.CSI_PARAM)
+ assert_eq(parser.params, [param - 48], 'param: 0x%x' % param)
+ parser.reset()
+
+
+func test_state_CSI_PARAM_ignore():
+ parser.current_state = ParserState.CSI_PARAM
+ parse(parser, '\u007f')
+ assert_eq(parser.current_state, ParserState.CSI_PARAM)
+ assert_eq(test_terminal.calls, [])
+
+
+func test_trans_CSI_PARAM_to_GROUND_with_csi_dispatch_action():
+ var dispatches = range(0x40, 0x7f)
+ for dispatch in dispatches:
+ var data = Decoder.string_from_codepoint(dispatch)
+ parser.current_state = ParserState.CSI_PARAM
+ parser.params = [0, 1]
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.GROUND)
+ assert_eq(test_terminal.calls, [['csi', '', [0, 1], data]])
+ parser.reset()
+ test_terminal.clear()
+
+
+func test_trans_CSI_ENTRY_to_CSI_INTERMEDIATE_with_collect_action():
+ for collect in range(0x20, 0x30):
+ var data = Decoder.string_from_codepoint(collect)
+ parser.current_state = ParserState.CSI_ENTRY
+ parse(parser, data)
+ assert_eq(parser.current_state, ParserState.CSI_INTERMEDIATE)
+ assert_eq(parser.collect, data)
+ parser.reset()
diff --git a/test/unit/test_parser.gd b/test/unit/test_parser.gd
new file mode 100644
index 0000000..f001f75
--- /dev/null
+++ b/test/unit/test_parser.gd
@@ -0,0 +1,199 @@
+extends 'res://addons/gut/test.gd'
+
+const Params = preload("res://addons/godot_xterm/parser/params.gd")
+
+class TestParams:
+ extends 'res://addons/gut/test.gd'
+
+ var params
+
+ func before_each():
+ params = Params.new()
+
+ func test_respects_ctor_args():
+ params = Params.new(12, 23)
+ assert_eq(params.params.size(), 12)
+ assert_eq(params.sub_params.size(), 23)
+ assert_eq(params.to_array(), [])
+
+ func test_add_param():
+ params.add_param(1)
+ assert_eq(params.length, 1)
+ assert_eq(params.params.slice(0, params.length - 1), [1])
+ assert_eq(params.to_array(), [1])
+ params.add_param(23)
+ assert_eq(params.length, 2)
+ assert_eq(params.params.slice(0, params.length - 1), [1, 23])
+ assert_eq(params.to_array(), [1, 23])
+ assert_eq(params.sub_params_length, 0)
+
+ func test_add_sub_param():
+ params.add_param(1)
+ params.add_sub_param(2)
+ params.add_sub_param(3)
+ assert_eq(params.length, 1)
+ assert_eq(params.sub_params_length, 2)
+ assert_eq(params.to_array(), [1, [2, 3]])
+ params.add_param(12345)
+ params.add_sub_param(-1)
+ assert_eq(params.length, 2)
+ assert_eq(params.sub_params_length, 3)
+ assert_eq(params.to_array(), [1, [2,3], 12345, [-1]])
+
+ func test_should_not_add_sub_params_without_previous_param():
+ params.add_sub_param(2)
+ params.add_sub_param(3)
+ assert_eq(params.length, 0)
+ assert_eq(params.sub_params_length, 0)
+ assert_eq(params.to_array(), [])
+ params.add_param(1)
+ params.add_sub_param(2)
+ params.add_sub_param(3)
+ assert_eq(params.length, 1)
+ assert_eq(params.sub_params_length, 2)
+ assert_eq(params.to_array(), [1, [2, 3]])
+
+ func test_reset():
+ params.add_param(1)
+ params.add_sub_param(2)
+ params.add_sub_param(3)
+ params.add_param(12345)
+ params.reset()
+ assert_eq(params.length, 0)
+ assert_eq(params.sub_params_length, 0)
+ assert_eq(params.to_array(), [])
+ params.add_param(1)
+ params.add_sub_param(2)
+ params.add_sub_param(3)
+ params.add_param(12345)
+ params.add_sub_param(-1)
+ assert_eq(params.length, 2)
+ assert_eq(params.sub_params_length, 3)
+ assert_eq(params.to_array(), [1, [2, 3], 12345, [-1]])
+
+
+ func test_from_array_to_array():
+ var data = []
+ assert_eq(params.from_array(data).to_array(), data)
+ data = [1, [2, 3], 12345, [-1]]
+ assert_eq(params.from_array(data).to_array(), data)
+ data = [38, 2, 50, 100, 150]
+ assert_eq(params.from_array(data).to_array(), data)
+ data = [38, 2, 50, 100, [150]]
+ assert_eq(params.from_array(data).to_array(), data)
+ data = [38, [2, 50, 100, 150]]
+ assert_eq(params.from_array(data).to_array(), data)
+ # strip empty sub params
+ data = [38, [2, 50, 100, 150], 5, [], 6]
+ assert_eq(Params.from_array(data).to_array(), [38, [2, 50, 100, 150], 5, 6])
+ # ignore leading sub params
+ data = [[1,2], 12345, [-1]]
+ assert_eq(Params.from_array(data).to_array(), [12345, [-1]])
+
+
+class TestParse:
+ extends 'res://addons/gut/test.gd'
+
+ var params
+
+ func parse(params, s):
+ params.reset()
+ params.add_param(0)
+ if typeof(s) == TYPE_STRING:
+ s = [s]
+ for chunk in s:
+ var i = 0
+ while i < chunk.length():
+ # Start for
+ var code = chunk.to_ascii()[i]
+ var do = true
+ while do:
+ match code:
+ 0x3b:
+ params.add_param(0)
+ 0x3a:
+ params.add_sub_param(-1)
+ _:
+ params.add_digit(code - 48)
+ code = chunk.to_ascii()[i] if i < chunk.length() else 0
+ i+=1
+ do = i < s.size() and code > 0x2f and code < 0x3c
+ i-=1
+ # End for
+ i+=1
+
+ func before_each():
+ params = Params.new()
+
+ func test_param_defaults_to_0(): # ZDM (Zero Default Mode)
+ parse(params, '')
+ assert_eq(params.to_array(), [0])
+
+ func test_sub_param_defaults_to_neg_1():
+ parse(params, ':')
+ assert_eq(params.to_array(), [0, [-1]])
+
+ func test_reset_on_new_sequence():
+ parse(params, '1;2;3')
+ assert_eq(params.to_array(), [1, 2, 3])
+ parse(params, '4')
+ assert_eq(params.to_array(), [4])
+ parse(params, '4::123:5;6;7')
+ assert_eq(params.to_array(), [4, [-1, 123, 5], 6, 7])
+ parse(params, '')
+ assert_eq(params.to_array(), [0])
+
+ func test_should_handle_length_restrictions_correctly():
+ params = Params.new(3, 3)
+ parse(params, '1;2;3')
+ assert_eq(params.to_array(), [1, 2, 3])
+ parse(params, '4')
+ assert_eq(params.to_array(), [4])
+ parse(params, '4::123:5;6;7')
+ assert_eq(params.to_array(), [4, [-1, 123, 5], 6, 7])
+ parse(params, '')
+ assert_eq(params.to_array(), [0])
+ # overlong params
+ parse(params, '4;38:2::50:100:150;48:5:22')
+ assert_eq(params.to_array(), [4, 38, [2, -1, 50], 48])
+ # overlong sub params
+ parse(params, '4;38:2::50:100:150;48:5:22')
+ assert_eq(params.to_array(), [4, 38, [2, -1, 50], 48])
+
+ func test_typical_sequences():
+ # SGR with semicolon syntax
+ parse(params, '0;4;38;2;50;100;150;48;5;22')
+ assert_eq(params.to_array(), [0, 4, 38, 2, 50, 100, 150, 48, 5, 22])
+ # SGR mixed style (partly wrong)
+ parse(params, '0;4;38;2;50:100:150;48;5:22')
+ assert_eq(params.to_array(), [0, 4, 38, 2, 50, [100, 150], 48, 5, [22]])
+ # SGR colon style
+ parse(params, '0;4;38:2::50:100:150;48:5:22')
+ assert_eq(params.to_array(), [0, 4, 38, [2, -1, 50, 100, 150], 48, [5, 22]])
+
+ func test_clamp_parsed_params():
+ parse(params, '2147483648')
+ assert_eq(params.to_array(), [0x7FFFFFFF])
+
+ func test_clamp_parsed_sub_params():
+ parse(params, ':2147483648')
+ assert_eq(params.to_array(), [0, [0x7FFFFFFF]])
+
+ func test_should_cancel_subdigits_if_beyond_params_limit():
+ parse(params, ';;;;;;;;;10;;;;;;;;;;20;;;;;;;;;;30;31;32;33;34;35::::::::')
+ assert_eq(params.to_array(), [
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 10,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 20,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 31, 32
+ ])
+
+# func test_should_carry_forward_is_sub_state():
+# parse(params, ['1:22:33', '44'])
+# assert_eq(params.to_array(), [1, [22, 3344]])
+
+
+
+
+
+
+
diff --git a/test/unit/test_text_decoder.gd b/test/unit/test_text_decoder.gd
new file mode 100644
index 0000000..b84c1a5
--- /dev/null
+++ b/test/unit/test_text_decoder.gd
@@ -0,0 +1,97 @@
+# Copyright (c) 2020 The GodotXterm authors.
+# Copyright (c) 2019 The xterm.js authors. All rights reserved.
+# License MIT
+extends 'res://addons/gut/test.gd'
+
+const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd")
+
+# Note: There might be some invisible characters (such as emoji) depending
+# on your editor and font settings.
+const TEST_STRINGS = [
+ "Лорем ипсум долор сит амет, ех сеа аццусам диссентиет. Ан еос стет еирмод витуперата. Иус дицерет урбанитас ет. Ан при алтера долорес сплендиде, цу яуо интегре денияуе, игнота волуптариа инструцтиор цу вим.",
+ "ლორემ იფსუმ დოლორ სით ამეთ, ფაცერ მუციუს ცონსეთეთურ ყუო იდ, ფერ ვივენდუმ ყუაერენდუმ ეა, ესთ ამეთ მოვეთ სუავითათე ცუ. ვითაე სენსიბუს ან ვიხ. ეხერცი დეთერრუისსეთ უთ ყუი. ვოცენთ დებითის ადიფისცი ეთ ფერ. ნეც ან ფეუგაით ფორენსიბუს ინთერესსეთ. იდ დიცო რიდენს იუს. დისსენთიეთ ცონსეყუუნთურ სედ ნე, ნოვუმ მუნერე ეუმ ათ, ნე ეუმ ნიჰილ ირაცუნდია ურბანითას.",
+ "अधिकांश अमितकुमार प्रोत्साहित मुख्य जाने प्रसारन विश्लेषण विश्व दारी अनुवादक अधिकांश नवंबर विषय गटकउसि गोपनीयता विकास जनित परस्पर गटकउसि अन्तरराष्ट्रीयकरन होसके मानव पुर्णता कम्प्युटर यन्त्रालय प्रति साधन",
+ "覧六子当聞社計文護行情投身斗来。増落世的況上席備界先関権能万。本物挙歯乳全事携供板栃果以。頭月患端撤競見界記引去法条公泊候。決海備駆取品目芸方用朝示上用報。講申務紙約週堂出応理田流団幸稿。起保帯吉対阜庭支肯豪彰属本躍。量抑熊事府募動極都掲仮読岸。自続工就断庫指北速配鳴約事新住米信中験。婚浜袋著金市生交保他取情距。",
+ "八メル務問へふらく博辞説いわょ読全タヨムケ東校どっ知壁テケ禁去フミ人過を装5階がねぜ法逆はじ端40落ミ予竹マヘナセ任1悪た。省ぜりせ製暇ょへそけ風井イ劣手はぼまず郵富法く作断タオイ取座ゅょが出作ホシ月給26島ツチ皇面ユトクイ暮犯リワナヤ断連こうでつ蔭柔薄とレにの。演めけふぱ損田転10得観びトげぎ王物鉄夜がまけ理惜くち牡提づ車惑参ヘカユモ長臓超漫ぼドかわ。",
+ "모든 국민은 행위시의 법률에 의하여 범죄를 구성하지 아니하는 행위로 소추되지 아니하며. 전직대통령의 신분과 예우에 관하여는 법률로 정한다, 국회는 헌법 또는 법률에 특별한 규정이 없는 한 재적의원 과반수의 출석과 출석의원 과반수의 찬성으로 의결한다. 군인·군무원·경찰공무원 기타 법률이 정하는 자가 전투·훈련등 직무집행과 관련하여 받은 손해에 대하여는 법률이 정하는 보상외에 국가 또는 공공단체에 공무원의 직무상 불법행위로 인한 배상은 청구할 수 없다.",
+ "كان فشكّل الشرقي مع, واحدة للمجهود تزامناً بعض بل. وتم جنوب للصين غينيا لم, ان وبدون وكسبت الأمور ذلك, أسر الخاسر الانجليزية هو. نفس لغزو مواقعها هو. الجو علاقة الصعداء انه أي, كما مع بمباركة للإتحاد الوزراء. ترتيب الأولى أن حدى, الشتوية باستحداث مدن بل, كان قد أوسع عملية. الأوضاع بالمطالبة كل قام, دون إذ شمال الربيع،. هُزم الخاصّة ٣٠ أما, مايو الصينية مع قبل.",
+ "או סדר החול מיזמי קרימינולוגיה. קהילה בגרסה לויקיפדים אל היא, של צעד ציור ואלקטרוניקה. מדע מה ברית המזנון ארכיאולוגיה, אל טבלאות מבוקשים כלל. מאמרשיחהצפה העריכהגירסאות שכל אל, כתב עיצוב מושגי של. קבלו קלאסיים ב מתן. נבחרים אווירונאוטיקה אם מלא, לוח למנוע ארכיאולוגיה מה. ארץ לערוך בקרבת מונחונים או, עזרה רקטות לויקיפדים אחר גם.",
+ "Лорем ლორემ अधिकांश 覧六子 八メル 모든 בקרבת 💮 😂 äggg 123€ 𝄞.",
+]
+
+func test_utf32_to_utf8():
+ # 1 byte utf8 character
+ assert_eq(
+ Decoder.utf32_to_utf8(0x00000061),
+ PoolByteArray([0x61])
+ )
+ # 2 byte utf8 character
+ assert_eq(
+ Decoder.utf32_to_utf8(0x00000761),
+ PoolByteArray([0xdd, 0xa1])
+ )
+ # 3 byte utf8 character
+ assert_eq(
+ Decoder.utf32_to_utf8(0x00002621),
+ PoolByteArray([0xe2, 0x98, 0xa1])
+ )
+ # 4 byte utf8 character
+ assert_eq(
+ Decoder.utf32_to_utf8(0x00010144),
+ PoolByteArray([0xf0, 0x90, 0x85, 0x84])
+ )
+ assert_eq(
+ Decoder.utf32_to_utf8(0x0001f427) as Array,
+ PoolByteArray([0xf0, 0x9f, 0x90, 0xa7]) as Array
+ )
+
+func test_string_from_codepoint():
+ assert_eq(Decoder.string_from_codepoint(49), '1')
+ assert_eq(Decoder.string_from_codepoint(0x1f427), '🐧')
+ assert_eq(Decoder.string_from_codepoint(0x1d11e), '𝄞')
+
+func test_utf32_to_string():
+ assert_eq(
+ Decoder.utf32_to_string([49, 50, 51, 0x1d11e, 49, 50, 51]),
+ '123𝄞123'
+ )
+
+class TestUtf8ToUtf32Decoder:
+ extends 'res://addons/gut/test.gd'
+
+ var decoder = Decoder.Utf8ToUtf32.new()
+ var target = []
+
+ func before_each():
+ decoder.clear()
+ target.clear()
+ target.resize(5)
+
+ func skip_test_full_code_point_0_to_65535(): # 1/2/3 byte sequences
+ for i in range(65536):
+ # skip surrogate pairs
+ if i >= 0xD800 and i <= 0xDFFF:
+ continue
+ var utf8_data = Decoder.utf32_to_utf8(i)
+ var length = decoder.decode(utf8_data, target)
+ assert_eq(length, 1)
+ assert_eq(
+ Decoder.string_from_codepoint(target[0]),
+ utf8_data.get_string_from_utf8()
+ )
+ decoder.clear()
+
+ func skip_test_full_codepoint_65536_to_0x10FFFF(): # 4 byte sequences
+ for i in range(65536, 0x10FFFF):
+ var utf8_data = Decoder.utf32_to_utf8(i)
+ var length = decoder.decode(utf8_data, target)
+ assert_eq(length, 1)
+ assert_eq(target[0], i)
+
+ func test_test_strings():
+ target.resize(500)
+ for string in TEST_STRINGS:
+ var utf8_data = string.to_utf8()
+ var length = decoder.decode(utf8_data, target)
+ assert_eq(Decoder.utf32_to_string(target, 0, length), string)
+ decoder.clear()