diff --git a/addons/gdhexgrid/.gitignore b/addons/gdhexgrid/.gitignore new file mode 100644 index 0000000..37f05f2 --- /dev/null +++ b/addons/gdhexgrid/.gitignore @@ -0,0 +1 @@ +.import diff --git a/addons/gdhexgrid/HexCell.gd b/addons/gdhexgrid/HexCell.gd new file mode 100644 index 0000000..6586462 --- /dev/null +++ b/addons/gdhexgrid/HexCell.gd @@ -0,0 +1,238 @@ +""" + A single cell of a hexagonal grid. + + There are many ways to orient a hex grid, this library was written + with the following assumptions: + + * The hexes use a flat-topped orientation; + * Axial coordinates use +x => NE; +y => N; + * Offset coords have odd rows shifted up half a step. + + Using x,y instead of the reference's preferred x,z for axial coords makes + following along with the reference a little more tricky, but is less confusing + when using Godot's Vector2(x, y) objects. + + + ## Usage: + + #### var cube_coords; var axial_coords; var offset_coords + + Cube coordinates are used internally as the canonical representation, but + both axial and offset coordinates can be read and modified through these + properties. + + #### func get_adjacent(direction) + + Returns the neighbouring HexCell in the given direction. + + The direction should be one of the DIR_N, DIR_NE, DIR_SE, DIR_S, DIR_SW, or + DIR_NW constants provided by the HexCell class. + + #### func get_all_adjacent() + + Returns an array of the six HexCell instances neighbouring this one. + + #### func get_all_within(distance) + + Returns an array of all the HexCells within the given number of steps, + including the current hex. + + #### func get_ring(distance) + + Returns an array of all the HexCells at the given distance from the current. + + #### func distance_to(target) + + Returns the number of hops needed to get from this hex to the given target. + + The target can be supplied as either a HexCell instance, cube or axial + coordinates. + + #### func line_to(target) + + Returns an array of all the hexes crossed when drawing a straight line + between this hex and another. + + The target can be supplied as either a HexCell instance, cube or axial + coordinates. + + The items in the array will be in the order of traversal, and include both + the start (current) hex, as well as the final target. + +""" +extends Resource +#warning-ignore-all:unused_class_variable + +# We use unit-size flat-topped hexes +const size = Vector2(1, sqrt(3)/2) +# Directions of neighbouring cells +const DIR_N = Vector3(0, 1, -1) +const DIR_NE = Vector3(1, 0, -1) +const DIR_SE = Vector3(1, -1, 0) +const DIR_S = Vector3(0, -1, 1) +const DIR_SW = Vector3(-1, 0, 1) +const DIR_NW = Vector3(-1, 1, 0) +const DIR_ALL = [DIR_N, DIR_NE, DIR_SE, DIR_S, DIR_SW, DIR_NW] + + +# Cube coords are canonical +var cube_coords = Vector3(0, 0, 0) setget set_cube_coords, get_cube_coords +# but other coord systems can be used +var axial_coords setget set_axial_coords, get_axial_coords +var offset_coords setget set_offset_coords, get_offset_coords + + +func _init(coords=null): + # HexCells can be created with coordinates + if coords: + self.cube_coords = obj_to_coords(coords) + +func new_hex(coords): + # Returns a new HexCell instance + return get_script().new(coords) + +""" + Handle coordinate access and conversion +""" +func obj_to_coords(val): + # Returns suitable cube coordinates for the given object + # The given object can an be one of: + # * Vector3 of standard cube coords; + # * Vector2 of axial coords; + # * HexCell instance + # Any other type of value will return null + # + # NB that offset coords are NOT supported, as they are + # indistinguishable from axial coords. + + if typeof(val) == TYPE_VECTOR3: + return val + elif typeof(val) == TYPE_VECTOR2: + return axial_to_cube_coords(val) + elif typeof(val) == TYPE_OBJECT and val.has_method("get_cube_coords"): + return val.get_cube_coords() + # Fall through to nothing + return + +func axial_to_cube_coords(val): + # Returns the Vector3 cube coordinates for an axial Vector2 + var x = val.x + var y = val.y + return Vector3(x, y, -x - y) + +func round_coords(val): + # Rounds floaty coordinate to the nearest whole number cube coords + if typeof(val) == TYPE_VECTOR2: + val = axial_to_cube_coords(val) + + # Straight round them + var rounded = Vector3(round(val.x), round(val.y), round(val.z)) + + # But recalculate the one with the largest diff so that x+y+z=0 + var diffs = (rounded - val).abs() + if diffs.x > diffs.y and diffs.x > diffs.z: + rounded.x = -rounded.y - rounded.z + elif diffs.y > diffs.z: + rounded.y = -rounded.x - rounded.z + else: + rounded.z = -rounded.x - rounded.y + + return rounded + + +func get_cube_coords(): + # Returns a Vector3 of the cube coordinates + return cube_coords + +func set_cube_coords(val): + # Sets the position from a Vector3 of cube coordinates + if abs(val.x + val.y + val.z) > 0.0001: + print("WARNING: Invalid cube coordinates for hex (x+y+z!=0): ", val) + return + cube_coords = round_coords(val) + +func get_axial_coords(): + # Returns a Vector2 of the axial coordinates + return Vector2(cube_coords.x, cube_coords.y) + +func set_axial_coords(val): + # Sets position from a Vector2 of axial coordinates + set_cube_coords(axial_to_cube_coords(val)) + +func get_offset_coords(): + # Returns a Vector2 of the offset coordinates + var x = int(cube_coords.x) + var y = int(cube_coords.y) + var off_y = y + (x - (x & 1)) / 2 + return Vector2(x, off_y) + +func set_offset_coords(val): + # Sets position from a Vector2 of offset coordinates + var x = int(val.x) + var y = int(val.y) + var cube_y = y - (x - (x & 1)) / 2 + self.set_axial_coords(Vector2(x, cube_y)) + + +""" + Finding our neighbours +""" +func get_adjacent(dir): + # Returns a HexCell instance for the given direction from this. + # Intended for one of the DIR_* consts, but really any Vector2 or x+y+z==0 Vector3 will do. + if typeof(dir) == TYPE_VECTOR2: + dir = axial_to_cube_coords(dir) + return new_hex(self.cube_coords + dir) + +func get_all_adjacent(): + # Returns an array of HexCell instances representing adjacent locations + var cells = Array() + for coord in DIR_ALL: + cells.append(new_hex(self.cube_coords + coord)) + return cells + +func get_all_within(distance): + # Returns an array of all HexCell instances within the given distance + var cells = Array() + for dx in range(-distance, distance+1): + for dy in range(max(-distance, -distance - dx), min(distance, distance - dx) + 1): + cells.append(new_hex(self.axial_coords + Vector2(dx, dy))) + return cells + +func get_ring(distance): + # Returns an array of all HexCell instances at the given distance + if distance < 1: + return [new_hex(self.cube_coords)] + # Start at the top (+y) and walk in a clockwise circle + var cells = Array() + var current = new_hex(self.cube_coords + (DIR_N * distance)) + for dir in [DIR_SE, DIR_S, DIR_SW, DIR_NW, DIR_N, DIR_NE]: + for _step in range(distance): + cells.append(current) + current = current.get_adjacent(dir) + return cells + +func distance_to(target): + # Returns the number of hops from this hex to another + # Can be passed cube or axial coords, or another HexCell instance + target = obj_to_coords(target) + return int(( + abs(cube_coords.x - target.x) + + abs(cube_coords.y - target.y) + + abs(cube_coords.z - target.z) + ) / 2) + +func line_to(target): + # Returns an array of HexCell instances representing + # a straight path from here to the target, including both ends + target = obj_to_coords(target) + # End of our lerp is nudged so it never lands exactly on an edge + var nudged_target = target + Vector3(1e-6, 2e-6, -3e-6) + var steps = distance_to(target) + var path = [] + for dist in range(steps): + var lerped = cube_coords.linear_interpolate(nudged_target, float(dist) / steps) + path.append(new_hex(round_coords(lerped))) + path.append(new_hex(target)) + return path + diff --git a/addons/gdhexgrid/HexGrid.gd b/addons/gdhexgrid/HexGrid.gd new file mode 100644 index 0000000..700aaf1 --- /dev/null +++ b/addons/gdhexgrid/HexGrid.gd @@ -0,0 +1,403 @@ +""" + A converter between hex and Godot-space coordinate systems. + + The hex grid uses +x => NE and +y => N, whereas + the projection to Godot-space uses +x => E, +y => S. + + We map hex coordinates to Godot-space with +y flipped to be the down vector + so that it maps neatly to both Godot's 2D coordinate system, and also to + x,z planes in 3D space. + + + ## Usage: + + #### var hex_scale = Vector2(...) + + If you want your hexes to display larger than the default 1 x 0.866 units, + then you can customise the scale of the hexes using this property. + + #### func get_hex_center(hex) + + Returns the Godot-space Vector2 of the center of the given hex. + + The coordinates can be given as either a HexCell instance; a Vector3 cube + coordinate, or a Vector2 axial coordinate. + + #### func get_hex_center3(hex [, y]) + + Returns the Godot-space Vector3 of the center of the given hex. + + The coordinates can be given as either a HexCell instance; a Vector3 cube + coordinate, or a Vector2 axial coordinate. + + If a second parameter is given, it will be used for the y value in the + returned Vector3. Otherwise, the y value will be 0. + + #### func get_hex_at(coords) + + Returns HexCell whose grid position contains the given Godot-space coordinates. + + The given value can either be a Vector2 on the grid's plane + or a Vector3, in which case its (x, z) coordinates will be used. + + + ### Path-finding + + HexGrid also includes an implementation of the A* pathfinding algorithm. + The class can be used to populate an internal representation of a game grid + with obstacles to traverse. + + #### func set_bounds(min_coords, max_coords) + + Sets the hard outer limits of the path-finding grid. + + The coordinates given are the min and max corners *inside* a bounding + square (diamond in hex visualisation) region. Any hex outside that area + is considered an impassable obstacle. + + The default bounds consider only the origin to be inside, so you're probably + going to want to do something about that. + + #### func get_obstacles() + + Returns a dict of all obstacles and their costs + + The keys are Vector2s of the axial coordinates, the values will be the + cost value. Zero cost means an impassable obstacle. + + #### func add_obstacles(coords, cost=0) + + Adds one or more obstacles to the path-finding grid + + The given coordinates (axial or cube), HexCell instance, or array thereof, + will be added as path-finding obstacles with the given cost. A zero cost + indicates an impassable obstacle. + + #### func remove_obstacles(coords) + + Removes one or more obstacles from the path-finding grid + + The given coordinates (axial or cube), HexCell instance, or array thereof, + will be removed as obstacles from the path-finding grid. + + #### func get_barriers() + + Returns a dict of all barriers in the grid. + + A barrier is an edge of a hex which is either impassable, or has a + non-zero cost to traverse. If two adjacent hexes both have barriers on + their shared edge, their costs are summed. + Barrier costs are in addition to the obstacle (or default) cost of + moving to a hex. + + The outer dict is a mapping of axial coords to an inner barrier dict. + The inner dict maps between HexCell.DIR_* directions and the cost of + travel in that direction. A cost of zero indicates an impassable barrier. + + #### func add_barriers(coords, dirs, cost=0) + + Adds one or more barriers to locations on the grid. + + The given coordinates (axial or cube), HexCell instance, or array thereof, + will have path-finding barriers added in the given HexCell.DIR_* directions + with the given cost. A zero cost indicates an impassable obstacle. + + Existing barriers at given coordinates will not be removed, but will be + overridden if the direction is specified. + + #### func remove_barriers(coords, dirs=null) + + Remove one or more barriers from the path-finding grid. + + The given coordinates (axial or cube), HexCell instance, or array thereof, + will have the path-finding barriers in the supplied HexCell.DIR_* directions + removed. If no direction is specified, all barriers for the given + coordinates will be removed. + + #### func get_hex_cost(coords) + + Returns the cost of moving into the specified grid position. + + Will return 0 if the given grid position is inaccessible. + + #### func get_move_cost(coords, direction) + + Returns the cost of moving from one hex to an adjacent one. + + This method takes into account any barriers defined between the two + hexes, as well as the cost of the target hex. + Will return 0 if the target hex is inaccessible, or if there is an + impassable barrier between the hexes. + + The direction should be provided as one of the HexCell.DIR_* values. + + #### func find_path(start, goal, exceptions=[]) + + Calculates an A* path from the start to the goal. + + Returns a list of HexCell instances charting the path from the given start + coordinates to the goal, including both ends of the journey. + + Exceptions can be specified as the third parameter, and will act as + impassable obstacles for the purposes of this call of the function. + This can be used for pathing around obstacles which may change position + (eg. enemy playing pieces), without having to update the grid's list of + obstacles every time something moves. + + If the goal is an impassable location, the path will terminate at the nearest + adjacent coordinate. In this instance, the goal hex will not be included in + the returned array. + + If there is no path possible to the goal, or any hex adjacent to it, an + empty array is returned. But the algorithm will only know that once it's + visited every tile it can reach, so try not to path to the impossible. + +""" +extends Reference + +var HexCell = preload("./HexCell.gd") +# Duplicate these from HexCell for ease of access +const DIR_N = Vector3(0, 1, -1) +const DIR_NE = Vector3(1, 0, -1) +const DIR_SE = Vector3(1, -1, 0) +const DIR_S = Vector3(0, -1, 1) +const DIR_SW = Vector3(-1, 0, 1) +const DIR_NW = Vector3(-1, 1, 0) +const DIR_ALL = [DIR_N, DIR_NE, DIR_SE, DIR_S, DIR_SW, DIR_NW] + +# Allow the user to scale the hex for fake perspective or somesuch +export(Vector2) var hex_scale = Vector2(1, 1) setget set_hex_scale + +var base_hex_size = Vector2(1, sqrt(3)/2) +var hex_size +var hex_transform +var hex_transform_inv +# Pathfinding obstacles {Vector2: cost} +# A zero cost means impassable +var path_obstacles = {} +# Barriers restrict traversing between edges (in either direction) +# costs for barriers and obstacles are cumulative, but impassable is impassable +# {Vector2: {DIR_VECTOR2: cost, ...}} +var path_barriers = {} +var path_bounds = Rect2() +var path_cost_default = 1.0 + + +func _init(): + set_hex_scale(hex_scale) + + +func set_hex_scale(scale): + # We need to recalculate some stuff when projection scale changes + hex_scale = scale + hex_size = base_hex_size * hex_scale + hex_transform = Transform2D( + Vector2(hex_size.x * 3/4, -hex_size.y / 2), + Vector2(0, -hex_size.y), + Vector2(0, 0) + ) + hex_transform_inv = hex_transform.affine_inverse() + + +""" + Converting between hex-grid and 2D spatial coordinates +""" +func get_hex_center(hex): + # Returns hex's centre position on the projection plane + hex = HexCell.new(hex) + return hex_transform * hex.axial_coords + +func get_hex_center_from_offset(offset): + # Returns hex's centre position at the given offset coordinates + var hex = HexCell.new() + hex.offset_coords = offset + return hex_transform * hex.axial_coords + +func get_hex_at(coords): + # Returns a HexCell at the given Vector2/3 on the projection plane + # If the given value is a Vector3, its x,z coords will be used + if typeof(coords) == TYPE_VECTOR3: + coords = Vector2(coords.x, coords.z) + return HexCell.new(hex_transform_inv * coords) + +func get_hex_center3(hex, y=0): + # Returns hex's centre position as a Vector3 + var coords = get_hex_center(hex) + return Vector3(coords.x, y, coords.y) + + +""" + Pathfinding + + Ref: https://www.redblobgames.com/pathfinding/a-star/introduction.html + + We use axial coords for everything internally (to use Rect2.has_point), + but the methods accept cube or axial coords, or HexCell instances. +""" +func set_bounds(min_coords, max_coords): + # Set the absolute bounds of the pathfinding area in grid coords + # The given coords will be inside the boundary (hence the extra (1, 1)) + min_coords = HexCell.new(min_coords).axial_coords + max_coords = HexCell.new(max_coords).axial_coords + path_bounds = Rect2(min_coords, (max_coords - min_coords) + Vector2(1, 1)) + +func get_obstacles(): + return path_obstacles + +func add_obstacles(vals, cost=0): + # Store the given coordinate/s as obstacles + if not typeof(vals) == TYPE_ARRAY: + vals = [vals] + for coords in vals: + coords = HexCell.new(coords).axial_coords + path_obstacles[coords] = cost + +func remove_obstacles(vals): + # Remove the given obstacle/s from the grid + if not typeof(vals) == TYPE_ARRAY: + vals = [vals] + for coords in vals: + coords = HexCell.new(coords).axial_coords + path_obstacles.erase(coords) + +func get_barriers(): + return path_barriers + +func add_barriers(vals, dirs, cost=0): + # Store the given directions of the given locations as barriers + if not typeof(vals) == TYPE_ARRAY: + vals = [vals] + if not typeof(dirs) == TYPE_ARRAY: + dirs = [dirs] + for coords in vals: + coords = HexCell.new(coords).axial_coords + var barriers = {} + if coords in path_barriers: + # Already something there + barriers = path_barriers[coords] + else: + path_barriers[coords] = barriers + # Set or override the given dirs + for dir in dirs: + barriers[dir] = cost + path_barriers[coords] = barriers + +func remove_barriers(vals, dirs=null): + if not typeof(vals) == TYPE_ARRAY: + vals = [vals] + if dirs != null and not typeof(dirs) == TYPE_ARRAY: + dirs = [dirs] + for coords in vals: + coords = HexCell.new(coords).axial_coords + if dirs == null: + path_barriers.erase(coords) + else: + for dir in dirs: + path_barriers[coords].erase(dir) + + +func get_hex_cost(coords): + # Returns the cost of moving to the given hex + coords = HexCell.new(coords).axial_coords + if coords in path_obstacles: + return path_obstacles[coords] + if not path_bounds.has_point(coords): + # Out of bounds + return 0 + return path_cost_default + +func get_move_cost(coords, direction): + # Returns the cost of moving from one hex to a neighbour + direction = HexCell.new(direction).cube_coords + var start_hex = HexCell.new(coords) + var target_hex = HexCell.new(start_hex.cube_coords + direction) + coords = start_hex.axial_coords + # First check if either end is completely impassable + var cost = get_hex_cost(start_hex) + if cost == 0: + return 0 + cost = get_hex_cost(target_hex) + if cost == 0: + return 0 + # Check for barriers + var barrier_cost + if coords in path_barriers and direction in path_barriers[coords]: + barrier_cost = path_barriers[coords][direction] + if barrier_cost == 0: + return 0 + cost += barrier_cost + var target_coords = target_hex.axial_coords + if target_coords in path_barriers and -direction in path_barriers[target_coords]: + barrier_cost = path_barriers[target_coords][-direction] + if barrier_cost == 0: + return 0 + cost += barrier_cost + return cost + + +func get_path(start, goal, exceptions=[]): + # DEPRECATED! + # The function `get_path` is used by Godot for something completely different, + # so we renamed it here to `find_path`. + push_warning("HexGrid.get_path has been deprecated, use find_path instead.") + return find_path(start, goal, exceptions) + +func find_path(start, goal, exceptions=[]): + # Light a starry path from the start to the goal, inclusive + start = HexCell.new(start).axial_coords + goal = HexCell.new(goal).axial_coords + # Make sure all the exceptions are axial coords + var exc = [] + for ex in exceptions: + exc.append(HexCell.new(ex).axial_coords) + exceptions = exc + # Now we begin the A* search + var frontier = [make_priority_item(start, 0)] + var came_from = {start: null} + var cost_so_far = {start: 0} + while not frontier.empty(): + var current = frontier.pop_front().v + if current == goal: + break + for next_hex in HexCell.new(current).get_all_adjacent(): + var next = next_hex.axial_coords + var next_cost = get_move_cost(current, next - current) + if next == goal and (next in exceptions or get_hex_cost(next) == 0): + # Our goal is an obstacle, but we're next to it + # so our work here is done + came_from[next] = current + frontier.clear() + break + if not next_cost or next in exceptions: + # We shall not pass + continue + next_cost += cost_so_far[current] + if not next in cost_so_far or next_cost < cost_so_far[next]: + # New shortest path to that node + cost_so_far[next] = next_cost + var priority = next_cost + next_hex.distance_to(goal) + # Insert into the frontier + var item = make_priority_item(next, priority) + var idx = frontier.bsearch_custom(item, self, "comp_priority_item") + frontier.insert(idx, item) + came_from[next] = current + + if not goal in came_from: + # Not found + return [] + # Follow the path back where we came_from + var path = [] + if not (get_hex_cost(goal) == 0 or goal in exceptions): + # We only include the goal if it's traversable + path.append(HexCell.new(goal)) + var current = goal + while current != start: + current = came_from[current] + path.push_front(HexCell.new(current)) + return path + +# Used to make a priority queue out of an array +func make_priority_item(val, priority): + return {"v": val, "p": priority} +func comp_priority_item(a, b): + return a.p < b.p diff --git a/addons/gdhexgrid/HexShape.gd b/addons/gdhexgrid/HexShape.gd new file mode 100644 index 0000000..64265f8 --- /dev/null +++ b/addons/gdhexgrid/HexShape.gd @@ -0,0 +1,19 @@ +tool +extends Polygon2D + +export(Color) var OutLine = Color(0,0,0) setget set_color +export(float) var Width = 2.0 setget set_width + +func _draw(): + var poly = get_polygon() + for i in range(1 , poly.size()): + draw_line(poly[i-1] , poly[i], OutLine , Width) + draw_line(poly[poly.size() - 1] , poly[0], OutLine , Width) + +func set_color(color): + OutLine = color + update() + +func set_width(new_width): + Width = new_width + update() diff --git a/addons/gdhexgrid/HexShape.tscn b/addons/gdhexgrid/HexShape.tscn new file mode 100644 index 0000000..9b8d474 --- /dev/null +++ b/addons/gdhexgrid/HexShape.tscn @@ -0,0 +1,51 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://HexShape.gd" type="Script" id=1] + +[node name="Highlight" type="Polygon2D"] +color = Color( 1, 1, 1, 0 ) +polygon = PoolVector2Array( -12.5, 21.6506, 12.5, 21.6506, 25, 0, 12.5, -21.6506, -12.5, -21.6506, -25, 0 ) +script = ExtResource( 1 ) +OutLine = Color( 1, 1, 1, 0.133333 ) + +[node name="Label" type="Label" parent="."] +visible = false +margin_left = 5.0 +margin_top = -39.0 +margin_right = 52.0 +margin_bottom = -25.0 +text = "SCREEN" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="AreaCoords" type="Label" parent="."] +visible = false +margin_left = 55.0 +margin_top = -39.0 +margin_right = 105.0 +margin_bottom = -25.0 +text = "SCREEN" + +[node name="Label2" type="Label" parent="."] +visible = false +margin_left = 25.0 +margin_top = -19.0 +margin_right = 56.0 +margin_bottom = -5.0 +text = "HEX" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="HexCoords" type="Label" parent="."] +margin_left = -26.0 +margin_top = -8.0 +margin_right = 24.0 +margin_bottom = 6.0 +text = "HEX" +align = 1 +valign = 1 +__meta__ = { +"_edit_use_anchors_": false +} diff --git a/addons/gdhexgrid/LICENSE.txt b/addons/gdhexgrid/LICENSE.txt new file mode 100644 index 0000000..f95976f --- /dev/null +++ b/addons/gdhexgrid/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2018 Mel Collins + +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/gdhexgrid/README.md b/addons/gdhexgrid/README.md new file mode 100644 index 0000000..0f36b95 --- /dev/null +++ b/addons/gdhexgrid/README.md @@ -0,0 +1,229 @@ +# GDHexGrid + +Tools for using hexagonal grids in GDScript. + +The reference used for creating this was the amazing guide: +https://www.redblobgames.com/grids/hexagons/ + +Copyright 2018 Mel Collins. +Distributed under the MIT license (see LICENSE.txt). + +## Orientation + +There are many ways to orient a hex grid, this library was written +using the following assumptions: + +* The hexes use a flat-topped orientation; +* Axial coordinates use +x => NE; +y => N; +* Offset coords have odd rows shifted up half a step; +* Projections of the hex grid into Godot-space use +x => E, +y => S. + +Using x,y instead of the reference's preferred x,z for axial coords makes +following along with the reference a little more tricky, but is less confusing +when using Godot's Vector2(x, y) objects. + +We map hex coordinates to Godot-space with +y flipped to be the down vector +so that it maps neatly to both Godot's 2D coordinate system, and also to +x,z planes in 3D space. + + +## Usage + +### HexGrid + +HexGrid is used when you want to position hexes in a 2D or 3D scene. +It translates coordinates between the hex grid and conventional spaces. + +#### var hex_scale = Vector2(...) + +If you want your hexes to display larger than the default 1 x 0.866 units, +then you can customise the scale of the hexes using this property. + +#### func get_hex_center(hex) + +Returns the Godot-space coordinate of the center of the given hex coordinates. + +The coordinates can be given as either a HexCell instance; a Vector3 cube +coordinate, or a Vector2 axial coordinate. + +#### func get_hex_center3(hex [, y]) + +Returns the Godot-space Vector3 of the center of the given hex. + +The coordinates can be given as either a HexCell instance; a Vector3 cube +coordinate, or a Vector2 axial coordinate. + +If a second parameter is given, it will be used for the y value in the +returned Vector3. Otherwise, the y value will be 0. + +#### func get_hex_at(coords) + +Returns HexCell whose grid position contains the given Godot-space coordinates. + +The given value can either be a Vector2 on the grid's plane +or a Vector3, in which case its (x, z) coordinates will be used. + + +### HexGrid pathfinding + +HexGrid also includes an implementation of the A* pathfinding algorithm. +The class can be used to populate an internal representation of a game grid +with obstacles to traverse. + +This was written with the aid of another amazing guide: +https://www.redblobgames.com/pathfinding/a-star/introduction.html + +#### func set_bounds(min_coords, max_coords) + +Sets the hard outer limits of the path-finding grid. + +The coordinates given are the min and max corners *inside* a bounding +square (diamond in hex visualisation) region. Any hex outside that area +is considered an impassable obstacle. + +The default bounds consider only the origin to be inside, so you're probably +going to want to do something about that. + +#### func get_obstacles() + +Returns a dict of all obstacles and their costs + +The keys are Vector2s of the axial coordinates, the values will be the +cost value. Zero cost means an impassable obstacle. + +#### func add_obstacles(coords, cost=0) + +Adds one or more obstacles to the path-finding grid + +The given coordinates (axial or cube), HexCell instance, or array thereof, +will be added as path-finding obstacles with the given cost. A zero cost +indicates an impassable obstacle. + +#### func remove_obstacles(coords) + +Removes one or more obstacles from the path-finding grid + +The given coordinates (axial or cube), HexCell instance, or array thereof, +will be removed as obstacles from the path-finding grid. + +#### func get_barriers() + +Returns a dict of all barriers in the grid. + +A barrier is an edge of a hex which is either impassable, or has a +non-zero cost to traverse. If two adjacent hexes both have barriers on +their shared edge, their costs are summed. +Barrier costs are in addition to the obstacle (or default) cost of +moving to a hex. + +The outer dict is a mapping of axial coords to an inner barrier dict. +The inner dict maps between HexCell.DIR_* directions and the cost of +travel in that direction. A cost of zero indicates an impassable barrier. + +#### func add_barriers(coords, dirs, cost=0) + +Adds one or more barriers to locations on the grid. + +The given coordinates (axial or cube), HexCell instance, or array thereof, +will have path-finding barriers added in the given HexCell.DIR_* directions +with the given cost. A zero cost indicates an impassable obstacle. + +Existing barriers at given coordinates will not be removed, but will be +overridden if the direction is specified. + +#### func remove_barriers(coords, dirs=null) + +Remove one or more barriers from the path-finding grid. + +The given coordinates (axial or cube), HexCell instance, or array thereof, +will have the path-finding barriers in the supplied HexCell.DIR_* directions +removed. If no direction is specified, all barriers for the given +coordinates will be removed. + +#### func get_hex_cost(coords) + +Returns the cost of moving into the specified grid position. + +Will return 0 if the given grid position is inaccessible. + +#### func get_move_cost(coords, direction) + +Returns the cost of moving from one hex to an adjacent one. + +This method takes into account any barriers defined between the two +hexes, as well as the cost of the target hex. +Will return 0 if the target hex is inaccessible, or if there is an +impassable barrier between the hexes. + +The direction should be provided as one of the HexCell.DIR_* values. + +#### func find_path(start, goal, exceptions=[]) + +Calculates an A* path from the start to the goal. + +Returns a list of HexCell instances charting the path from the given start +coordinates to the goal, including both ends of the journey. + +Exceptions can be specified as the third parameter, and will act as +impassable obstacles for the purposes of this call of the function. +This can be used for pathing around obstacles which may change position +(eg. enemy playing pieces), without having to update the grid's list of +obstacles every time something moves. + +If the goal is an impassable location, the path will terminate at the nearest +adjacent coordinate. In this instance, the goal hex will not be included in +the returned array. + +If there is no path possible to the goal, or any hex adjacent to it, an +empty array is returned. But the algorithm will only know that once it's +visited every tile it can reach, so try not to path to the impossible. + + +### HexCell + +A HexCell represents a single hex in the grid, and is the meat of the library. + +#### var cube_coords; var axial_coords; var offset_coords + +Cube coordinates are used internally as the canonical representation, but +both axial and offset coordinates can be read and modified through these +properties. + +#### func get_adjacent(direction) + +Returns the neighbouring HexCell in the given direction. + +The direction should be one of the DIR_N, DIR_NE, DIR_SE, DIR_S, DIR_SW, or +DIR_NW constants provided by the HexCell class. + +#### func get_all_adjacent() + +Returns an array of the six HexCell instances neighbouring this one. + +#### func get_all_within(distance) + +Returns an array of all the HexCells within the given number of steps, +including the current hex. + +#### func get_ring(distance) + +Returns an array of all the HexCells at the given distance from the current. + +#### func distance_to(target) + +Returns the number of hops needed to get from this hex to the given target. + +The target can be supplied as either a HexCell instance, cube or axial +coordinates. + +#### func line_to(target) + +Returns an array of all the hexes crossed when drawing a straight line +between this hex and another. + +The target can be supplied as either a HexCell instance, cube or axial +coordinates. + +The items in the array will be in the order of traversal, and include both +the start (current) hex, as well as the final target. + diff --git a/addons/gdhexgrid/_project.godot b/addons/gdhexgrid/_project.godot new file mode 100644 index 0000000..e86a7f9 --- /dev/null +++ b/addons/gdhexgrid/_project.godot @@ -0,0 +1,28 @@ +; 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="HexGrid" +run/main_scene="res://demo_2d.tscn" +config/icon="res://icon.png" + +[editor_plugins] + +enabled=PoolStringArray( "gut" ) + +[rendering] + +environment/default_environment="res://default_env.tres" diff --git a/addons/gdhexgrid/addons/gut/GutScene.gd b/addons/gdhexgrid/addons/gut/GutScene.gd new file mode 100644 index 0000000..c0ba70d --- /dev/null +++ b/addons/gdhexgrid/addons/gut/GutScene.gd @@ -0,0 +1,343 @@ +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 _time = 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 +signal script_selected + +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 += delta + 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): + _time = 0 + _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(value) + +func set_progress_script_value(value): + _progress.script.set_value(value) + +func set_progress_test_max(value): + _progress.test.set_max(value) + +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() diff --git a/addons/gdhexgrid/addons/gut/GutScene.tscn b/addons/gdhexgrid/addons/gut/GutScene.tscn new file mode 100644 index 0000000..2642345 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/GutScene.tscn @@ -0,0 +1,842 @@ +[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] + +content_margin_left = -1.0 +content_margin_right = -1.0 +content_margin_top = -1.0 +content_margin_bottom = -1.0 +bg_color = Color( 0.193863, 0.205501, 0.214844, 1 ) +draw_center = true +border_width_left = 0 +border_width_top = 0 +border_width_right = 0 +border_width_bottom = 0 +border_color = Color( 0.8, 0.8, 0.8, 1 ) +border_blend = false +corner_radius_top_left = 20 +corner_radius_top_right = 20 +corner_radius_bottom_right = 0 +corner_radius_bottom_left = 0 +corner_detail = 8 +expand_margin_left = 0.0 +expand_margin_right = 0.0 +expand_margin_top = 0.0 +expand_margin_bottom = 0.0 +shadow_color = Color( 0, 0, 0, 0.6 ) +shadow_size = 0 +anti_aliasing = true +anti_aliasing_size = 1 +_sections_unfolded = [ "Corner Radius" ] + +[sub_resource type="StyleBoxFlat" id=2] + +content_margin_left = -1.0 +content_margin_right = -1.0 +content_margin_top = -1.0 +content_margin_bottom = -1.0 +bg_color = Color( 1, 1, 1, 1 ) +draw_center = true +border_width_left = 0 +border_width_top = 0 +border_width_right = 0 +border_width_bottom = 0 +border_color = Color( 0, 0, 0, 1 ) +border_blend = false +corner_radius_top_left = 5 +corner_radius_top_right = 5 +corner_radius_bottom_right = 0 +corner_radius_bottom_left = 0 +corner_detail = 8 +expand_margin_left = 0.0 +expand_margin_right = 0.0 +expand_margin_top = 0.0 +expand_margin_bottom = 0.0 +shadow_color = Color( 0, 0, 0, 0.6 ) +shadow_size = 0 +anti_aliasing = true +anti_aliasing_size = 1 +_sections_unfolded = [ "Border", "Corner Radius" ] + +[sub_resource type="Theme" id=3] + +resource_local_to_scene = true +Panel/styles/panel = SubResource( 2 ) +Panel/styles/panelf = null +Panel/styles/panelnc = null +_sections_unfolded = [ "Panel", "Panel/colors", "Panel/styles", "Resource" ] + +[node name="Gut" type="Panel" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_right = 740.0 +margin_bottom = 320.0 +rect_min_size = Vector2( 740, 250 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_styles/panel = SubResource( 1 ) +script = ExtResource( 1 ) +_sections_unfolded = [ "Theme", "Transform", "Z Index", "custom_styles" ] + +[node name="TitleBar" type="Panel" parent="." index="0"] + +editor/display_folded = true +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +theme = SubResource( 3 ) +_sections_unfolded = [ "Rect", "Theme" ] + +[node name="Title" type="Label" parent="TitleBar" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "Gut" +align = 1 +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 +_sections_unfolded = [ "Anchor", "custom_colors", "custom_fonts" ] + +[node name="Time" type="Label" parent="TitleBar" index="1"] + +anchor_left = 1.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.0 +margin_left = -114.0 +margin_right = -53.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "9999.99" +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 +_sections_unfolded = [ "Anchor", "custom_colors" ] + +[node name="Maximize" type="Button" parent="TitleBar" index="2"] + +anchor_left = 1.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 0.0 +margin_left = -30.0 +margin_top = 10.0 +margin_right = -6.0 +margin_bottom = 30.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "M" +flat = true +align = 1 +_sections_unfolded = [ "Anchor", "custom_colors" ] + +[node name="ScriptProgress" type="ProgressBar" parent="." index="1"] + +editor/display_folded = true +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 0.0 +anchor_bottom = 1.0 +margin_left = 70.0 +margin_top = -100.0 +margin_right = 180.0 +margin_bottom = -70.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 0 +min_value = 0.0 +max_value = 100.0 +step = 1.0 +page = 0.0 +value = 0.0 +exp_edit = false +rounded = false +percent_visible = true + +[node name="Label" type="Label" parent="ScriptProgress" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = -70.0 +margin_right = -10.0 +margin_bottom = 24.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Script" +align = 1 +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="TestProgress" type="ProgressBar" parent="." index="2"] + +editor/display_folded = true +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 0.0 +anchor_bottom = 1.0 +margin_left = 70.0 +margin_top = -70.0 +margin_right = 180.0 +margin_bottom = -40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 0 +min_value = 0.0 +max_value = 100.0 +step = 1.0 +page = 0.0 +value = 0.0 +exp_edit = false +rounded = false +percent_visible = true + +[node name="Label" type="Label" parent="TestProgress" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = -70.0 +margin_right = -10.0 +margin_bottom = 24.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Tests" +align = 1 +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="TextDisplay" type="Panel" parent="." index="3"] + +editor/display_folded = true +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_top = 40.0 +margin_bottom = -107.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Anchor", "Grow Direction", "Visibility" ] +__meta__ = { +"_edit_group_": true +} + +[node name="RichTextLabel" type="TextEdit" parent="TextDisplay" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +text = "" +readonly = true +highlight_current_line = false +syntax_highlighting = true +show_line_numbers = false +highlight_all_occurrences = false +override_selected_font_color = false +context_menu_enabled = true +smooth_scrolling = true +v_scroll_speed = 80.0 +hiding_enabled = 0 +wrap_lines = false +caret_block_mode = false +caret_blink = false +caret_blink_speed = 0.65 +caret_moving_by_right_click = true +_sections_unfolded = [ "Anchor", "Caret", "Grow Direction", "Margin", "Visibility", "custom_colors" ] + +[node name="FocusBlocker" type="Panel" parent="TextDisplay" index="1"] + +self_modulate = Color( 1, 1, 1, 0 ) +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_right = -10.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Anchor", "Visibility" ] + +[node name="Navigation" type="Panel" parent="." index="4"] + +editor/display_folded = true +self_modulate = Color( 1, 1, 1, 0 ) +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 0.0 +anchor_bottom = 1.0 +margin_left = 220.0 +margin_top = -100.0 +margin_right = 580.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Visibility" ] + +[node name="Previous" type="Button" parent="Navigation" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = -30.0 +margin_right = 50.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "<" +flat = false +align = 1 + +[node name="Next" type="Button" parent="Navigation" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 230.0 +margin_right = 310.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = ">" +flat = false +align = 1 + +[node name="Run" type="Button" parent="Navigation" index="2"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 60.0 +margin_right = 220.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Run" +flat = false +align = 1 + +[node name="CurrentScript" type="Button" parent="Navigation" index="3"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = -30.0 +margin_top = 50.0 +margin_right = 310.0 +margin_bottom = 90.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "res://test/unit/test_gut.gd" +flat = false +clip_text = true +align = 1 + +[node name="ShowScripts" type="Button" parent="Navigation" index="4"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 320.0 +margin_top = 50.0 +margin_right = 360.0 +margin_bottom = 90.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "..." +flat = false +align = 1 + +[node name="LogLevelSlider" type="HSlider" parent="." index="5"] + +editor/display_folded = true +anchor_left = 0.0 +anchor_top = 1.0 +anchor_right = 0.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 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 0 +min_value = 0.0 +max_value = 2.0 +step = 1.0 +page = 0.0 +value = 0.0 +exp_edit = false +rounded = false +editable = true +tick_count = 3 +ticks_on_borders = true +focus_mode = 2 +_sections_unfolded = [ "Rect" ] + +[node name="Label" type="Label" parent="LogLevelSlider" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = -35.0 +margin_top = 5.0 +margin_right = 25.0 +margin_bottom = 25.0 +rect_scale = Vector2( 0.5, 0.5 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +text = "Log Level" +align = 1 +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 +_sections_unfolded = [ "Rect" ] + +[node name="ScriptsList" type="ItemList" parent="." index="6"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 1.0 +margin_left = 180.0 +margin_top = 40.0 +margin_right = 620.0 +margin_bottom = -108.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = true +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +items = [ ] +select_mode = 0 +allow_reselect = true +icon_mode = 1 +fixed_icon_size = Vector2( 0, 0 ) +_sections_unfolded = [ "Anchor", "Columns", "Grow Direction", "Icon", "Margin", "Mouse", "Rect", "Size Flags", "Theme", "Visibility", "custom_colors", "custom_constants" ] + +[node name="ExtraOptions" type="Panel" parent="." index="7"] + +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 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +custom_styles/panel = SubResource( 1 ) +_sections_unfolded = [ "Anchor", "Visibility" ] + +[node name="IgnorePause" type="CheckBox" parent="ExtraOptions" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 10.0 +margin_top = 10.0 +margin_right = 128.0 +margin_bottom = 34.0 +rect_scale = Vector2( 1.5, 1.5 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = true +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Ignore Pauses" +flat = false +align = 0 +_sections_unfolded = [ "Rect" ] + +[node name="DisableBlocker" type="CheckBox" parent="ExtraOptions" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 10.0 +margin_top = 50.0 +margin_right = 130.0 +margin_bottom = 74.0 +rect_scale = Vector2( 1.5, 1.5 ) +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = true +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Selectable" +flat = false +align = 0 +_sections_unfolded = [ "Rect", "Size Flags" ] + +[node name="Copy" type="Button" parent="ExtraOptions" index="2"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 20.0 +margin_top = 90.0 +margin_right = 200.0 +margin_bottom = 130.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Copy" +flat = false +align = 1 + +[node name="ResizeHandle" type="Control" parent="." index="8"] + +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +margin_left = -40.0 +margin_top = -40.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Anchor", "Grow Direction", "Material", "Visibility" ] + +[node name="Continue" type="Panel" parent="." index="9"] + +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 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +_sections_unfolded = [ "Anchor", "Visibility" ] + +[node name="Continue" type="Button" parent="Continue" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 50.0 +margin_right = 119.0 +margin_bottom = 90.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +disabled = true +toggle_mode = false +enabled_focus_mode = 2 +shortcut = null +group = null +text = "Continue" +flat = false +align = 1 + +[node name="ShowExtras" type="Button" parent="Continue" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 50.0 +margin_right = 120.0 +margin_bottom = 40.0 +rect_pivot_offset = Vector2( 35, 20 ) +rect_clip_content = false +focus_mode = 2 +mouse_filter = 0 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 1 +toggle_mode = true +enabled_focus_mode = 2 +shortcut = null +group = null +text = "_" +flat = false +align = 1 +_sections_unfolded = [ "Rect" ] + +[node name="Summary" type="Node2D" parent="." index="10"] + +editor/display_folded = true +position = Vector2( 0, 3 ) +_sections_unfolded = [ "Transform" ] + +[node name="Passing" type="Label" parent="Summary" index="0"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_top = 10.0 +margin_right = 40.0 +margin_bottom = 24.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "0" +align = 1 +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -1 + +[node name="Failing" type="Label" parent="Summary" index="1"] + +anchor_left = 0.0 +anchor_top = 0.0 +anchor_right = 0.0 +anchor_bottom = 0.0 +margin_left = 40.0 +margin_top = 10.0 +margin_right = 80.0 +margin_bottom = 24.0 +rect_pivot_offset = Vector2( 0, 0 ) +rect_clip_content = false +mouse_filter = 2 +mouse_default_cursor_shape = 0 +size_flags_horizontal = 1 +size_flags_vertical = 4 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "0" +align = 1 +valign = 1 +percent_visible = 1.0 +lines_skipped = 0 +max_lines_visible = -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/gdhexgrid/addons/gut/LICENSE.md b/addons/gdhexgrid/addons/gut/LICENSE.md new file mode 100644 index 0000000..a38ac23 --- /dev/null +++ b/addons/gdhexgrid/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/gdhexgrid/addons/gut/doubler.gd b/addons/gdhexgrid/addons/gut/doubler.gd new file mode 100644 index 0000000..6926edb --- /dev/null +++ b/addons/gdhexgrid/addons/gut/doubler.gd @@ -0,0 +1,385 @@ +# ------------------------------------------------------------------------------ +# Utility class to hold the local and built in methods seperately. 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. + ] + + 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() + + 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(): + return get_loaded_class().new() + + # 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 = str("extends '", get_path(), '\'') + if(has_subpath()): + extend += str('.', get_subpath().replace('/', '.')) + return extend + + +# ------------------------------------------------------------------------------ +# START Doubler +# ------------------------------------------------------------------------------ +var _output_dir = null +var _stubber = null +var _double_count = 0 # used in making files names unique +var _use_unique_names = true +var _spy = null + +var _utils = load('res://addons/gut/utils.gd').new() +var _lgr = _utils.get_logger() +var _method_maker = _utils.MethodMaker.new() +var _strategy = null +var _swapped_out_strategy = null + +func _temp_strategy(strat): + _swapped_out_strategy = _strategy + _strategy = strat + +func _restore_strategy(): + _strategy = _swapped_out_strategy + + +func _init(strategy=_utils.DOUBLE_STRATEGY.PARTIAL): + # make sure _method_maker gets logger too + 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 _write_file(obj_info, dest_path, override_path=null): + var script_methods = _get_methods(obj_info) + + var metadata = _get_stubber_metadata_text(obj_info) + if(override_path): + metadata = _get_stubber_metadata_text(obj_info, override_path) + + var f = File.new() + f.open(dest_path, f.WRITE) + + + f.store_string(str(obj_info.get_extends_text(), "\n")) + f.store_string(metadata) + for i in range(script_methods.local_methods.size()): + f.store_string(_get_func_text(script_methods.local_methods[i])) + for i in range(script_methods.built_ins.size()): + f.store_string(_get_super_func_text(script_methods.built_ins[i])) + f.close() + +func _double_scene_and_script(target_path, dest_path): + var dir = Directory.new() + dir.copy(target_path, dest_path) + + var inst = load(target_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) + var double_path = _double(oi, target_path) + var dq = '"' + var f = File.new() + f.open(dest_path, f.READ) + var source = f.get_as_text() + f.close() + + source = source.replace(dq + script_path + dq, dq + double_path + dq) + + f.open(dest_path, f.WRITE) + f.store_string(source) + f.close() + + return script_path + +func _get_methods(object_info): + var obj = object_info.instantiate() + # any mehtod in the script or super script + var script_methods = ScriptMethods.new() + var methods = obj.get_method_list() + + # first pass is for local mehtods 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): + script_methods.add_local_method(methods[i]) + + + if(_strategy == _utils.DOUBLE_STRATEGY.FULL): + if(_utils.is_version_30()): + # 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): + script_methods.add_built_in_method(methods[i]) + else: + _lgr.warn('Full doubling is disabled in 3.1') + + 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_stubber_metadata_text(obj_info, override_path = null): + var path = obj_info.get_path() + if(override_path != null): + path = override_path + return "var __gut_metadata_ = {\n" + \ + "\tpath='" + path + "',\n" + \ + "\tsubpath='" + obj_info.get_subpath() + "',\n" + \ + "\tstubber=" + _get_inst_id_ref_str(_stubber) + ",\n" + \ + "\tspy=" + _get_inst_id_ref_str(_spy) + "\n" + \ + "}\n" + +func _get_spy_text(method_hash): + var txt = '' + if(_spy): + var called_with = _method_maker.get_spy_call_parameters_text(method_hash) + txt += "\t__gut_metadata_.spy.add_call(self, '" + method_hash.name + "', " + called_with + ")\n" + return txt + +func _get_func_text(method_hash): + var ftxt = _method_maker.get_decleration_text(method_hash) + "\n" + + var called_with = _method_maker.get_spy_call_parameters_text(method_hash) + ftxt += _get_spy_text(method_hash) + + if(_stubber and method_hash.name != '_init'): + ftxt += "\treturn __gut_metadata_.stubber.get_return(self, '" + method_hash.name + "', " + called_with + ")\n" + else: + ftxt += "\tpass\n" + + return ftxt + +func _get_super_func_text(method_hash): + var call_method = _method_maker.get_super_call_text(method_hash) + + var call_super_text = str("return ", call_method, "\n") + + var ftxt = _method_maker.get_decleration_text(method_hash) + "\n" + ftxt += _get_spy_text(method_hash) + + ftxt += _get_indented_line(1, call_super_text) + + return ftxt + +# returns the path to write the double file to +func _get_temp_path(object_info): + var file_name = object_info.get_path().get_file().get_basename() + var extension = object_info.get_path().get_extension() + + if(object_info.has_subpath()): + file_name += '__' + object_info.get_subpath().replace('/', '__') + + if(_use_unique_names): + file_name += str('__dbl', _double_count, '__.', extension) + else: + file_name += '.' + extension + + var to_return = _output_dir.plus_file(file_name) + return to_return + +func _double(obj_info, override_path=null): + var temp_path = _get_temp_path(obj_info) + _write_file(obj_info, temp_path, override_path) + _double_count += 1 + return temp_path + +# ############### +# Public +# ############### +func get_output_dir(): + return _output_dir + +func set_output_dir(output_dir): + _output_dir = output_dir + 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 + +# double a scene +func double_scene(path, strategy=_strategy): + _temp_strategy(strategy) + + var oi = ObjectInfo.new(path) + var temp_path = _get_temp_path(oi) + _double_scene_and_script(path, temp_path) + + _restore_strategy() + return load(temp_path) + +# double a script/object +func double(path, strategy=_strategy): + _temp_strategy(strategy) + + var oi = ObjectInfo.new(path) + var to_return = load(_double(oi)) + + _restore_strategy() + return to_return + +# double an inner class in a script +func double_inner(path, subpath, strategy=_strategy): + _temp_strategy(strategy) + + var oi = ObjectInfo.new(path, subpath) + var to_return = load(_double(oi)) + + _restore_strategy() + return to_return + +func clear_output_directory(): + 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 files = [] + 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) + +# When creating doubles a unique name is used that each double can be its own +# thing. Sometimes, for testing, we do not want to do this so this allows +# you to turn off creating unique names for each double class. +# +# THIS SHOULD NEVER BE USED OUTSIDE OF INTERNAL GUT TESTING. It can cause +# weird, hard to track down problems. +func set_use_unique_names(should): + _use_unique_names = should diff --git a/addons/gdhexgrid/addons/gut/gut.gd b/addons/gdhexgrid/addons/gut/gut.gd new file mode 100644 index 0000000..efa506d --- /dev/null +++ b/addons/gdhexgrid/addons/gut/gut.gd @@ -0,0 +1,1195 @@ +################################################################################ +#(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.7.0 +################################################################################ +#extends "res://addons/gut/gut_gui.gd" +tool +extends Control + +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 +# ########################### +# 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) + _doubler.set_logger(_lgr) + _lgr.set_gut(self) + + _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() + +################################################################################ +# +# 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') + 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.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 propigate 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 + +# ------------------------------------------------------------------------------ +# Initialize variables for each run of a single test script. +# ------------------------------------------------------------------------------ +func _init_run(): + _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)) + + + +# ------------------------------------------------------------------------------ +# Print out run information and close out the run. +# ------------------------------------------------------------------------------ +func _end_run(): + var failed_tests = [] + var more_than_one = _test_script_objects.size() > 1 + + 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() + 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 deprectated 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=[]): + _init_run() + _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) + + var file = File.new() + 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) + + # 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 = '' + # Inner classes don't get the line number set so don't print it + # since -1 isn't helpful + if(_current_test.line_number != -1): + line_text = ' at line ' + str(_current_test.line_number) + 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 + +func _pending(text=''): + if(_current_test): + _new_summary.add_pending(_current_test.name, text) +######################### +# +# 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. +# ------------------------------------------------------------------------------ +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): + print(to_print) + + _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 + d.open(path) + # true parameter tells list_dir_begin not to include "." and ".." diretories. + 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)): + add_script(full_path) + elif(get_include_subdirectories() and d.dir_exists(full_path)): + add_directory(full_path, prefix, suffix) + + fs_item = d.get_next() + d.list_dir_end() + +# ------------------------------------------------------------------------------ +# 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 simlar 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 #/')) + 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): + var to_return = '' + var f = File.new() + f.open(path, f.READ) + to_return = f.get_as_text() + f.close() + return to_return +# ------------------------------------------------------------------------------ +# 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 diff --git a/addons/gdhexgrid/addons/gut/gut_cmdln.gd b/addons/gdhexgrid/addons/gut/gut_cmdln.gd new file mode 100644 index 0000000..7abccaa --- /dev/null +++ b/addons/gdhexgrid/addons/gut/gut_cmdln.gd @@ -0,0 +1,332 @@ +################################################################################ +#(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.7.0 +################################################################################ +extends SceneTree + + +var Optparse = load('res://addons/gut/optparse.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 _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 = [], + double_strategy = 'partial', + ignore_pause = false, + include_subdirs = false, + inner_class = '', + log_level = 1, + opacity = 100, + prefix = 'test_', + selected = '', + should_exit = 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('-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('-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.tests = from.get_value('-gtest') + to.dirs = from.get_value('-gdir') + to.should_exit = from.get_value('-gexit') + to.should_maximize = from.get_value('-gmaximize') + to.log_level = from.get_value('-glog') + to.ignore_pause = from.get_value('-gignore_pause') + to.selected = from.get_value('-gselect') + to.prefix = from.get_value('-gprefix') + to.suffix = from.get_value('-gsuffix') + to.unit_test_name = from.get_value('-gunit_test_name') + to.config_file = from.get_value('-gconfig') + to.inner_class = from.get_value('-ginner_class') + to.opacity = from.get_value('-gopacity') + to.include_subdirs = from.get_value('-ginclude_subdirs') + to.double_strategy = from.get_value('-gdouble_strategy') + + +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 = load('res://addons/gut/gut.gd').new() + get_root().add_child(_tester) + _tester.connect('tests_finished', self, '_on_tests_finished', [opts.should_exit]) + _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) + +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')): + 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: + apply_options(opt_resolver.get_resolved_values()) + _tester.test_scripts(!_run_single) + +# exit if option is set. +func _on_tests_finished(should_exit): + if(_tester.get_fail_count()): + OS.exit_code = 1 + + if(should_exit): + quit() diff --git a/addons/gdhexgrid/addons/gut/gut_gui.gd b/addons/gdhexgrid/addons/gut/gut_gui.gd new file mode 100644 index 0000000..b61e897 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/gut_gui.gd @@ -0,0 +1,353 @@ +################################################################################ +#The MIT License (MIT) +#===================== +# +#Copyright (c) 2017 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. +# +################################################################################ + +################################################################################ +# This class contains all the GUI creation code for Gut. It was split out and +# hopefully can be moved to a scene in the future. +################################################################################ +extends WindowDialog + +# various counters. Most have been moved to the Summary object but not all. +var _summary = { + moved_methods = 0, + # these are used to display the tally in the top right corner. Since the + # implementation changed to summing things up at the end, the running + # update wasn't showing. Hack. + tally_passed = 0, + tally_failed = 0 +} + +var _is_running = false +var min_size = Vector2(650, 400) + +#controls +var _ctrls = { + text_box = TextEdit.new(), + run_button = Button.new(), + copy_button = Button.new(), + clear_button = Button.new(), + continue_button = Button.new(), + log_level_slider = HSlider.new(), + scripts_drop_down = OptionButton.new(), + next_button = Button.new(), + previous_button = Button.new(), + stop_button = Button.new(), + script_progress = ProgressBar.new(), + test_progress = ProgressBar.new(), + runtime_label = Label.new(), + ignore_continue_checkbox = CheckBox.new(), + pass_count = Label.new(), + run_rest = Button.new() +} + +var _mouse_down = false +var _mouse_down_pos = null +var _mouse_in = false + +func _set_anchor_top_right(obj): + obj.set_anchor(MARGIN_RIGHT, ANCHOR_BEGIN) + obj.set_anchor(MARGIN_LEFT, ANCHOR_END) + obj.set_anchor(MARGIN_TOP, ANCHOR_BEGIN) + +func _set_anchor_bottom_right(obj): + obj.set_anchor(MARGIN_LEFT, ANCHOR_END) + obj.set_anchor(MARGIN_RIGHT, ANCHOR_END) + obj.set_anchor(MARGIN_TOP, ANCHOR_END) + obj.set_anchor(MARGIN_BOTTOM, ANCHOR_END) + +func _set_anchor_bottom_left(obj): + obj.set_anchor(MARGIN_LEFT, ANCHOR_BEGIN) + obj.set_anchor(MARGIN_TOP, ANCHOR_END) + obj.set_anchor(MARGIN_TOP, ANCHOR_END) + +#------------------------------------------------------------------------------- +#------------------------------------------------------------------------------- +func setup_controls(): + var button_size = Vector2(75, 35) + var button_spacing = Vector2(10, 0) + var pos = Vector2(0, 0) + + add_child(_ctrls.text_box) + _ctrls.text_box.set_size(Vector2(get_size().x - 4, 300)) + _ctrls.text_box.set_position(Vector2(2, 0)) + _ctrls.text_box.set_readonly(true) + _ctrls.text_box.set_syntax_coloring(true) + _ctrls.text_box.set_anchor(MARGIN_LEFT, ANCHOR_BEGIN) + _ctrls.text_box.set_anchor(MARGIN_RIGHT, ANCHOR_END) + _ctrls.text_box.set_anchor(MARGIN_TOP, ANCHOR_BEGIN) + _ctrls.text_box.set_anchor(MARGIN_BOTTOM, ANCHOR_END) + + add_child(_ctrls.copy_button) + _ctrls.copy_button.set_text("Copy") + _ctrls.copy_button.set_size(button_size) + _ctrls.copy_button.set_position(Vector2(get_size().x - 5 - button_size.x, _ctrls.text_box.get_size().y + 10)) + _set_anchor_bottom_right(_ctrls.copy_button) + + add_child(_ctrls.clear_button) + _ctrls.clear_button.set_text("Clear") + _ctrls.clear_button.set_size(button_size) + _ctrls.clear_button.set_position(_ctrls.copy_button.get_position() - Vector2(button_size.x, 0) - button_spacing) + _set_anchor_bottom_right(_ctrls.clear_button) + + add_child(_ctrls.pass_count) + _ctrls.pass_count.set_text('0 - 0') + _ctrls.pass_count.set_size(Vector2(100, 30)) + _ctrls.pass_count.set_position(Vector2(550, 0)) + _ctrls.pass_count.set_align(HALIGN_RIGHT) + _set_anchor_top_right(_ctrls.pass_count) + + add_child(_ctrls.continue_button) + _ctrls.continue_button.set_text("Continue") + _ctrls.continue_button.set_size(Vector2(100, 25)) + _ctrls.continue_button.set_position(Vector2(_ctrls.clear_button.get_position().x, _ctrls.clear_button.get_position().y + _ctrls.clear_button.get_size().y + 10)) + _ctrls.continue_button.set_disabled(true) + _set_anchor_bottom_right(_ctrls.continue_button) + + add_child(_ctrls.ignore_continue_checkbox) + _ctrls.ignore_continue_checkbox.set_text("Ignore pauses") + #_ctrls.ignore_continue_checkbox.set_pressed(_ignore_pause_before_teardown) + _ctrls.ignore_continue_checkbox.set_size(Vector2(50, 30)) + _ctrls.ignore_continue_checkbox.set_position(Vector2(_ctrls.continue_button.get_position().x, _ctrls.continue_button.get_position().y + _ctrls.continue_button.get_size().y - 5)) + _set_anchor_bottom_right(_ctrls.ignore_continue_checkbox) + + var log_label = Label.new() + add_child(log_label) + log_label.set_text("Log Level") + log_label.set_position(Vector2(10, _ctrls.text_box.get_size().y + 1)) + _set_anchor_bottom_left(log_label) + + add_child(_ctrls.log_level_slider) + _ctrls.log_level_slider.set_size(Vector2(75, 30)) + _ctrls.log_level_slider.set_position(Vector2(10, log_label.get_position().y + 20)) + _ctrls.log_level_slider.set_min(0) + _ctrls.log_level_slider.set_max(2) + _ctrls.log_level_slider.set_ticks(3) + _ctrls.log_level_slider.set_ticks_on_borders(true) + _ctrls.log_level_slider.set_step(1) + #_ctrls.log_level_slider.set_rounded_values(true) + #_ctrls.log_level_slider.set_value(_log_level) + _set_anchor_bottom_left(_ctrls.log_level_slider) + + var script_prog_label = Label.new() + add_child(script_prog_label) + script_prog_label.set_position(Vector2(100, log_label.get_position().y)) + script_prog_label.set_text('Scripts:') + _set_anchor_bottom_left(script_prog_label) + + add_child(_ctrls.script_progress) + _ctrls.script_progress.set_size(Vector2(200, 10)) + _ctrls.script_progress.set_position(script_prog_label.get_position() + Vector2(70, 0)) + _ctrls.script_progress.set_min(0) + _ctrls.script_progress.set_max(1) + _ctrls.script_progress.set_step(1) + _set_anchor_bottom_left(_ctrls.script_progress) + + var test_prog_label = Label.new() + add_child(test_prog_label) + test_prog_label.set_position(Vector2(100, log_label.get_position().y + 15)) + test_prog_label.set_text('Tests:') + _set_anchor_bottom_left(test_prog_label) + + add_child(_ctrls.test_progress) + _ctrls.test_progress.set_size(Vector2(200, 10)) + _ctrls.test_progress.set_position(test_prog_label.get_position() + Vector2(70, 0)) + _ctrls.test_progress.set_min(0) + _ctrls.test_progress.set_max(1) + _ctrls.test_progress.set_step(1) + _set_anchor_bottom_left(_ctrls.test_progress) + + add_child(_ctrls.previous_button) + _ctrls.previous_button.set_size(Vector2(50, 25)) + pos = _ctrls.test_progress.get_position() + Vector2(250, 25) + pos.x -= 300 + _ctrls.previous_button.set_position(pos) + _ctrls.previous_button.set_text("<") + _set_anchor_bottom_left(_ctrls.previous_button) + + add_child(_ctrls.stop_button) + _ctrls.stop_button.set_size(Vector2(50, 25)) + pos.x += 60 + _ctrls.stop_button.set_position(pos) + _ctrls.stop_button.set_text('stop') + _set_anchor_bottom_left(_ctrls.stop_button) + + add_child(_ctrls.run_rest) + _ctrls.run_rest.set_text('run') + _ctrls.run_rest.set_size(Vector2(50, 25)) + pos.x += 60 + _ctrls.run_rest.set_position(pos) + _set_anchor_bottom_left(_ctrls.run_rest) + + add_child(_ctrls.next_button) + _ctrls.next_button.set_size(Vector2(50, 25)) + pos.x += 60 + _ctrls.next_button.set_position(pos) + _ctrls.next_button.set_text(">") + _set_anchor_bottom_left(_ctrls.next_button) + + add_child(_ctrls.runtime_label) + _ctrls.runtime_label.set_text('0.0') + _ctrls.runtime_label.set_size(Vector2(50, 30)) + _ctrls.runtime_label.set_position(Vector2(_ctrls.clear_button.get_position().x - 90, _ctrls.next_button.get_position().y)) + _set_anchor_bottom_right(_ctrls.runtime_label) + + # the drop down has to be one of the last added so that when then list of + # scripts is displayed, other controls do not get in the way of selecting + # an item in the list. + add_child(_ctrls.scripts_drop_down) + _ctrls.scripts_drop_down.set_size(Vector2(375, 25)) + _ctrls.scripts_drop_down.set_position(Vector2(10, _ctrls.log_level_slider.get_position().y + 50)) + _set_anchor_bottom_left(_ctrls.scripts_drop_down) + _ctrls.scripts_drop_down.set_clip_text(true) + + add_child(_ctrls.run_button) + _ctrls.run_button.set_text('<- run') + _ctrls.run_button.set_size(Vector2(50, 25)) + _ctrls.run_button.set_position(_ctrls.scripts_drop_down.get_position() + Vector2(_ctrls.scripts_drop_down.get_size().x + 5, 0)) + _set_anchor_bottom_left(_ctrls.run_button) + +func set_it_up(): + self.set_size(min_size) + setup_controls() + self.connect("mouse_entered", self, "_on_mouse_enter") + self.connect("mouse_exited", self, "_on_mouse_exit") + set_process(true) + set_pause_mode(PAUSE_MODE_PROCESS) + _update_controls() + +#------------------------------------------------------------------------------- +# Updates the display +#------------------------------------------------------------------------------- +func _update_controls(): + + if(_is_running): + _ctrls.previous_button.set_disabled(true) + _ctrls.next_button.set_disabled(true) + _ctrls.pass_count.show() + else: + _ctrls.previous_button.set_disabled(_ctrls.scripts_drop_down.get_selected() <= 0) + _ctrls.next_button.set_disabled(_ctrls.scripts_drop_down.get_selected() != -1 and _ctrls.scripts_drop_down.get_selected() == _ctrls.scripts_drop_down.get_item_count() -1) + _ctrls.pass_count.hide() + + # disabled during run + _ctrls.run_button.set_disabled(_is_running) + _ctrls.run_rest.set_disabled(_is_running) + _ctrls.scripts_drop_down.set_disabled(_is_running) + + # enabled during run + _ctrls.stop_button.set_disabled(!_is_running) + _ctrls.pass_count.set_text(str( _summary.tally_passed, ' - ', _summary.tally_failed)) + + +#------------------------------------------------------------------------------- +#detect mouse movement +#------------------------------------------------------------------------------- +func _on_mouse_enter(): + _mouse_in = true + +#------------------------------------------------------------------------------- +#detect mouse movement +#------------------------------------------------------------------------------- +func _on_mouse_exit(): + _mouse_in = false + _mouse_down = false + + +#------------------------------------------------------------------------------- +#Send text box text to clipboard +#------------------------------------------------------------------------------- +func _copy_button_pressed(): + _ctrls.text_box.select_all() + _ctrls.text_box.copy() + + +#------------------------------------------------------------------------------- +#------------------------------------------------------------------------------- +func _init_run(): + _ctrls.text_box.clear_colors() + _ctrls.text_box.add_keyword_color("PASSED", Color(0, 1, 0)) + _ctrls.text_box.add_keyword_color("FAILED", Color(1, 0, 0)) + _ctrls.text_box.add_color_region('/#', '#/', Color(.9, .6, 0)) + _ctrls.text_box.add_color_region('/-', '-/', Color(1, 1, 0)) + _ctrls.text_box.add_color_region('/*', '*/', Color(.5, .5, 1)) + #_ctrls.text_box.set_symbol_color(Color(.5, .5, .5)) + _ctrls.runtime_label.set_text('0.0') + _ctrls.test_progress.set_max(1) + +#------------------------------------------------------------------------------- +#------------------------------------------------------------------------------- +func _input(event): + #if the mouse is somewhere within the debug window + if(_mouse_in): + #Check for mouse click inside the resize handle + if(event is InputEventMouseButton): + if (event.button_index == 1): + #It's checking a square area for the bottom right corner, but that's close enough. I'm lazy + if(event.position.x > get_size().x + get_position().x - 10 and event.position.y > get_size().y + get_position().y - 10): + if event.pressed: + _mouse_down = true + _mouse_down_pos = event.position + else: + _mouse_down = false + #Reszie + if(event is InputEventMouseMotion): + if(_mouse_down): + if(get_size() >= min_size): + var new_size = get_size() + event.position - _mouse_down_pos + var new_mouse_down_pos = event.position + + if(new_size.x < min_size.x): + new_size.x = min_size.x + new_mouse_down_pos.x = _mouse_down_pos.x + + if(new_size.y < min_size.y): + new_size.y = min_size.y + new_mouse_down_pos.y = _mouse_down_pos.y + + _mouse_down_pos = new_mouse_down_pos + set_size(new_size) + +#------------------------------------------------------------------------------- +#Custom drawing to indicate results. +#------------------------------------------------------------------------------- +func _draw(): + #Draw the lines in the corner to show where you can + #drag to resize the dialog + var grab_margin = 2 + var line_space = 3 + var grab_line_color = Color(.4, .4, .4) + for i in range(1, 6): + draw_line(get_size() - Vector2(i * line_space, grab_margin), get_size() - Vector2(grab_margin, i * line_space), grab_line_color) + + return + + var where = Vector2(430, 565) + var r = 25 + if(_summary.tests > 0): + if(_summary.failed > 0): + draw_circle(where, r , Color(1, 0, 0, 1)) + else: + draw_circle(where, r, Color(0, 1, 0, 1)) diff --git a/addons/gdhexgrid/addons/gut/gut_plugin.gd b/addons/gdhexgrid/addons/gut/gut_plugin.gd new file mode 100644 index 0000000..b7c24f1 --- /dev/null +++ b/addons/gdhexgrid/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/gdhexgrid/addons/gut/icon.png b/addons/gdhexgrid/addons/gut/icon.png new file mode 100644 index 0000000..7c58987 Binary files /dev/null and b/addons/gdhexgrid/addons/gut/icon.png differ diff --git a/addons/gdhexgrid/addons/gut/icon.png.import b/addons/gdhexgrid/addons/gut/icon.png.import new file mode 100644 index 0000000..f44f80c --- /dev/null +++ b/addons/gdhexgrid/addons/gut/icon.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon.png-94d6536ee4af028a7ca4b709cae95011.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdhexgrid/addons/gut/icon.png" +dest_files=[ "res://.import/icon.png-94d6536ee4af028a7ca4b709cae95011.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/gdhexgrid/addons/gut/logger.gd b/addons/gdhexgrid/addons/gut/logger.gd new file mode 100644 index 0000000..c7cd458 --- /dev/null +++ b/addons/gdhexgrid/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/gdhexgrid/addons/gut/method_maker.gd b/addons/gdhexgrid/addons/gut/method_maker.gd new file mode 100644 index 0000000..7a4e0c6 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/method_maker.gd @@ -0,0 +1,194 @@ +# This class will generate method decleration 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 Vecotr 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 +# ############### + +func _is_supported_default(type_flag): + return type_flag >= 0 and type_flag < _supported_defaults.size() and [type_flag] != null + +# Creates a list of paramters 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]), ')') + # Everything else puts the prefix (if one is there) fomr _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 paramter has a default, a warning message will be +# printed and the decleration will return null. +func get_decleration_text(meta): + var param_text = _get_arg_text(meta) + var text = null + if(param_text != null): + text = str('func ', meta.name, '(', param_text, '):') + return text + +# creates a call to the function in meta in the super's class. +func get_super_call_text(meta): + var params = '' + var all_supported = true + + 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/gdhexgrid/addons/gut/optparse.gd b/addons/gdhexgrid/addons/gut/optparse.gd new file mode 100644 index 0000000..f05a31b --- /dev/null +++ b/addons/gdhexgrid/addons/gut/optparse.gd @@ -0,0 +1,246 @@ +################################################################################ +#(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.7.0 +################################################################################ + +#------------------------------------------------------------------------------- +# 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 = [] + var _opts = [] + + func _init(): + for i in range(OS.get_cmdline_args().size()): + _opts.append(OS.get_cmdline_args()[i]) + + # 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): + var split = full_option.split('=') + + if(split.size() > 1): + return split[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].find(name) == 0): + 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 specfied, 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].split('=')[0]) + + var script_option = to_return.find('-s') + 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(value, size, pad_with=' '): + var to_return = value + for i in range(value.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 processsed, 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/gdhexgrid/addons/gut/plugin.cfg b/addons/gdhexgrid/addons/gut/plugin.cfg new file mode 100644 index 0000000..e9d74a0 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Gut" +description="Unit Testing tool for Godot." +author="Butch Wesley" +version="6.7.0" +script="gut_plugin.gd" diff --git a/addons/gdhexgrid/addons/gut/signal_watcher.gd b/addons/gdhexgrid/addons/gut/signal_watcher.gd new file mode 100644 index 0000000..1ee2180 --- /dev/null +++ b/addons/gdhexgrid/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/gdhexgrid/addons/gut/source_code_pro.fnt b/addons/gdhexgrid/addons/gut/source_code_pro.fnt new file mode 100644 index 0000000..3367650 Binary files /dev/null and b/addons/gdhexgrid/addons/gut/source_code_pro.fnt differ diff --git a/addons/gdhexgrid/addons/gut/spy.gd b/addons/gdhexgrid/addons/gut/spy.gd new file mode 100644 index 0000000..32ae414 --- /dev/null +++ b/addons/gdhexgrid/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/gdhexgrid/addons/gut/stub_params.gd b/addons/gdhexgrid/addons/gut/stub_params.gd new file mode 100644 index 0000000..4211dd2 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/stub_params.gd @@ -0,0 +1,28 @@ +var return_val = null +var stub_target = null +var target_subpath = null +var parameters = null +var stub_method = null +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 + 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(): + return str(stub_target, '(', target_subpath, ').', stub_method, ' with (', parameters, ') = ', return_val) diff --git a/addons/gdhexgrid/addons/gut/stubber.gd b/addons/gdhexgrid/addons/gut/stubber.gd new file mode 100644 index 0000000..0a60059 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/stubber.gd @@ -0,0 +1,139 @@ +# { +# 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): + to_return += str('-', subpath) + TYPE_OBJECT: + if(_is_instance(obj)): + to_return = _make_key_from_metadata(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) + +# 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 objet was stubbed with no parameters then any parameters will match. +# If it was stubbed with specific paramter values then it will try to match. +# If the parameters do not match BUT there was also an empty paramter 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 +# paramters: optional array of paramter vales to find a return value for. +func get_return(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].return_val + # 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].return_val + else: + _lgr.warn(str('Call to [', method, '] was not stubbed for the supplied parameters ', parameters, '. Null was returned.')) + else: + _lgr.info('Unstubbed call to ' + method) + + + return to_return + +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/gdhexgrid/addons/gut/summary.gd b/addons/gdhexgrid/addons/gut/summary.gd new file mode 100644 index 0000000..7e68a3f --- /dev/null +++ b/addons/gdhexgrid/addons/gut/summary.gd @@ -0,0 +1,145 @@ +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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 + +# ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +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(name): + if(!_tests.has(name)): + _tests[name] = Test.new() + _test_order.append(name) + return _tests[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) + +# ------------------------------------------------------------------------------ +# Main class +# ------------------------------------------------------------------------------ +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 count = 0 + 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/gdhexgrid/addons/gut/test.gd b/addons/gdhexgrid/addons/gut/test.gd new file mode 100644 index 0000000..b8aab5d --- /dev/null +++ b/addons/gdhexgrid/addons/gut/test.gd @@ -0,0 +1,964 @@ +################################################################################ +#(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 + +# 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 passed = 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) + passed = false + + return passed + +# ------------------------------------------------------------------------------ +# 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), ")") + +# ####################### +# 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 definded 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)) + +# ------------------------------------------------------------------------------ +# 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 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 specfied 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) + + +# ------------------------------------------------------------------------------ +# 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 paramter "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(!_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(!_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) + + 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) + +# ------------------------------------------------------------------------------ +# 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 yeild. 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 double(thing, p2=null, p3=null): + var strategy = p2 + var subpath = null + + if(typeof(p2) == TYPE_STRING): + strategy = p3 + subpath = p2 + + var path = null + if(typeof(thing) == TYPE_OBJECT): + path = thing.resource_path + else: + path = thing + + var extension = path.get_extension() + var to_return = null + + if(extension == 'tscn'): + to_return = double_scene(path, strategy) + elif(extension == 'gd'): + if(subpath == null): + to_return = double_script(path, strategy) + else: + to_return = double_inner(path, subpath, strategy) + + return to_return + +# ------------------------------------------------------------------------------ +# 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) + +# ------------------------------------------------------------------------------ +# 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 refrences +# 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. +# ------------------------------------------------------------------------------ +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 objet 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/gdhexgrid/addons/gut/test_collector.gd b/addons/gdhexgrid/addons/gut/test_collector.gd new file mode 100644 index 0000000..89e7e30 --- /dev/null +++ b/addons/gdhexgrid/addons/gut/test_collector.gd @@ -0,0 +1,248 @@ +# ------------------------------------------------------------------------------ +# 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(path): + var to_return = path + if(!_utils.file_exists(path)): + _lgr.debug('Checking for remap for: ' + path) + var remap_path = 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): + if(_utils.is_version_31()): + _lgr.error("Exporting and importing not supported in 3.1 yet. There is a workaround, check the wiki.") + return false + + 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): + if(_utils.is_version_31()): + _lgr.error("Exporting and importing not supported in 3.1 yet. There is a workaround, check the wiki.") + return false + 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/gdhexgrid/addons/gut/thing_counter.gd b/addons/gdhexgrid/addons/gut/thing_counter.gd new file mode 100644 index 0000000..a9b0b48 --- /dev/null +++ b/addons/gdhexgrid/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/gdhexgrid/addons/gut/utils.gd b/addons/gdhexgrid/addons/gut/utils.gd new file mode 100644 index 0000000..db8cadb --- /dev/null +++ b/addons/gdhexgrid/addons/gut/utils.gd @@ -0,0 +1,108 @@ +var _Logger = load('res://addons/gut/logger.gd') # everything should use get_logger + +var Doubler = load('res://addons/gut/doubler.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') + +const GUT_METADATA = '__gut_metadata_' + +enum DOUBLE_STRATEGY{ + FULL, + PARTIAL +} + +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 seperated 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 == '' diff --git a/addons/gdhexgrid/default_env.tres b/addons/gdhexgrid/default_env.tres new file mode 100644 index 0000000..d37b59d --- /dev/null +++ b/addons/gdhexgrid/default_env.tres @@ -0,0 +1,13 @@ +[gd_resource type="Environment" load_steps=2 format=2] + +[sub_resource type="ProceduralSky" id=1] +sky_top_color = Color( 0.0470588, 0.454902, 0.976471, 1 ) +sky_horizon_color = Color( 0.556863, 0.823529, 0.909804, 1 ) +sky_curve = 0.25 +ground_bottom_color = Color( 0.101961, 0.145098, 0.188235, 1 ) +ground_horizon_color = Color( 0.482353, 0.788235, 0.952941, 1 ) +ground_curve = 0.01 + +[resource] +background_mode = 2 +background_sky = SubResource( 1 ) diff --git a/addons/gdhexgrid/demo_2d.gd b/addons/gdhexgrid/demo_2d.gd new file mode 100644 index 0000000..c003963 --- /dev/null +++ b/addons/gdhexgrid/demo_2d.gd @@ -0,0 +1,78 @@ +# Script to attach to a node which represents a hex grid +extends Node2D + +var HexGrid = preload("./HexGrid.gd").new() +var HexShape = preload("./HexShape.tscn") + +onready var highlight = get_node("Highlight") +onready var area_coords = get_node("Highlight/AreaCoords") +onready var hex_coords = get_node("Highlight/HexCoords") + +var rect_min = Vector2(-200, 50) +var rect_max = Vector2(400, 200) + +var drag_start = null + +func _ready(): + HexGrid.hex_scale = Vector2(50, 50) + +func _draw(): + var hex_cell_bb_min = HexGrid.get_hex_at(rect_min).offset_coords + var hex_cell_bb_max = HexGrid.get_hex_at(rect_max).offset_coords + + var hex_cell_offset_min = Vector2( + min(hex_cell_bb_min.x, hex_cell_bb_max.x), + min(hex_cell_bb_min.y, hex_cell_bb_max.y) + ) + + var hex_cell_offset_max = Vector2( + max(hex_cell_bb_min.x, hex_cell_bb_max.x), + max(hex_cell_bb_min.y, hex_cell_bb_max.y) + ) + + var rect_highlights = get_node ("rect_highlights") + if rect_highlights: + remove_child(rect_highlights) + rect_highlights.queue_free() + + rect_highlights = Node2D.new() + rect_highlights.name = "rect_highlights" + add_child(rect_highlights) + + for xi in range (hex_cell_offset_min.x, hex_cell_offset_max.x + 1): + for yi in range (hex_cell_offset_min.y, hex_cell_offset_max.y + 1): + var offset_coords = Vector2(xi, yi) + var hex_shape = HexShape.instance() + hex_shape.position = HexGrid.get_hex_center_from_offset(offset_coords) + var hex_shape_coords = hex_shape.get_node("HexCoords") + hex_shape_coords.text = str(offset_coords) + rect_highlights.add_child (hex_shape) + + var rect = Rect2(rect_min, rect_max - rect_min) + draw_rect (rect, Color("55FFFFFF")) + + +func _unhandled_input(event): + if 'position' in event: + var relative_pos = self.transform.affine_inverse() * event.position + # Display the coords used + if area_coords != null: + area_coords.text = str(relative_pos) + if hex_coords != null: + hex_coords.text = str(HexGrid.get_hex_at(relative_pos).axial_coords) + + # Snap the highlight to the nearest grid cell + if highlight != null: + highlight.position = HexGrid.get_hex_center(HexGrid.get_hex_at(relative_pos)) + + if event is InputEventMouseButton: + if event.button_index == BUTTON_LEFT: + if event.pressed: + drag_start = relative_pos + else: + drag_start = null + + if drag_start != null: + rect_min = Vector2(min(drag_start.x, relative_pos.x), min(drag_start.y, relative_pos.y)) + rect_max = Vector2(max(drag_start.x, relative_pos.x), max(drag_start.y, relative_pos.y)) + update() diff --git a/addons/gdhexgrid/demo_2d.tscn b/addons/gdhexgrid/demo_2d.tscn new file mode 100644 index 0000000..76a7cb4 --- /dev/null +++ b/addons/gdhexgrid/demo_2d.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=3 format=2] + +[ext_resource path="res://demo_2d.gd" type="Script" id=1] + +[sub_resource type="RectangleShape2D" id=1] +extents = Vector2( 512, 300 ) + +[node name="2D Demo" type="Node"] + +[node name="Area2D" type="Area2D" parent="."] +position = Vector2( 512, 300 ) +script = ExtResource( 1 ) + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Area2D"] +position = Vector2( 0.237854, 0 ) +shape = SubResource( 1 ) + +[node name="Highlight" type="Polygon2D" parent="Area2D"] +polygon = PoolVector2Array( -12.5, 21.6506, 12.5, 21.6506, 25, 0, 12.5, -21.6506, -12.5, -21.6506, -25, 0 ) + +[node name="Label" type="Label" parent="Area2D/Highlight"] +margin_left = 5.0 +margin_top = -39.0 +margin_right = 52.0 +margin_bottom = -25.0 +text = "SCREEN" + +[node name="AreaCoords" type="Label" parent="Area2D/Highlight"] +margin_left = 55.0 +margin_top = -39.0 +margin_right = 105.0 +margin_bottom = -25.0 +text = "SCREEN" + +[node name="Label2" type="Label" parent="Area2D/Highlight"] +margin_left = 25.0 +margin_top = -19.0 +margin_right = 56.0 +margin_bottom = -5.0 +text = "HEX" + +[node name="HexCoords" type="Label" parent="Area2D/Highlight"] +margin_left = 55.0 +margin_top = -19.0 +margin_right = 105.0 +margin_bottom = -5.0 +text = "HEX" diff --git a/addons/gdhexgrid/demo_3d.gd b/addons/gdhexgrid/demo_3d.gd new file mode 100644 index 0000000..0c410c5 --- /dev/null +++ b/addons/gdhexgrid/demo_3d.gd @@ -0,0 +1,25 @@ +# Script to attach to a node which represents a hex grid +extends Spatial + +var HexGrid = preload("./HexGrid.gd").new() + +onready var highlight = get_node("Highlight") +onready var plane_coords_label = get_node("Highlight/Viewport/PlaneCoords") +onready var hex_coords_label = get_node("Highlight/Viewport/HexCoords") + + +func _on_HexGrid_input_event(_camera, _event, click_position, _click_normal, _shape_idx): + # It's called click_position, but you don't need to click + var plane_coords = self.transform.affine_inverse() * click_position + plane_coords = Vector2(plane_coords.x, plane_coords.z) + # Display the coords used + if plane_coords_label != null: + plane_coords_label.text = str(plane_coords) + if hex_coords_label != null: + hex_coords_label.text = str(HexGrid.get_hex_at(plane_coords).axial_coords) + + # Snap the highlight to the nearest grid cell + if highlight != null: + var plane_pos = HexGrid.get_hex_center(HexGrid.get_hex_at(plane_coords)) + highlight.translation.x = plane_pos.x + highlight.translation.z = plane_pos.y diff --git a/addons/gdhexgrid/demo_3d.tscn b/addons/gdhexgrid/demo_3d.tscn new file mode 100644 index 0000000..8b2cc94 --- /dev/null +++ b/addons/gdhexgrid/demo_3d.tscn @@ -0,0 +1,166 @@ +[gd_scene load_steps=13 format=2] + +[ext_resource path="res://demo_3d.gd" type="Script" id=1] + +[sub_resource type="BoxShape" id=1] +extents = Vector3( 4, 0.1, 4 ) + +[sub_resource type="CubeMesh" id=2] +size = Vector3( 8, 0.2, 8 ) + +[sub_resource type="CylinderMesh" id=3] +top_radius = 0.5 +bottom_radius = 0.5 +height = 0.1 +radial_segments = 6 +rings = 1 + +[sub_resource type="SpatialMaterial" id=4] +roughness = 0.0 + +[sub_resource type="QuadMesh" id=5] +size = Vector2( 2, 1 ) + +[sub_resource type="ViewportTexture" id=6] +viewport_path = NodePath("HexGrid/Highlight/Viewport") + +[sub_resource type="SpatialMaterial" id=7] +resource_local_to_scene = true +flags_transparent = true +albedo_texture = SubResource( 6 ) + +[sub_resource type="BoxShape" id=8] +extents = Vector3( 4, 0.1, 2 ) + +[sub_resource type="CubeMesh" id=9] +size = Vector3( 8, 0.2, 4 ) + +[sub_resource type="ViewportTexture" id=10] +viewport_path = NodePath("HexGrid2/Highlight/Viewport") + +[sub_resource type="SpatialMaterial" id=11] +resource_local_to_scene = true +flags_transparent = true +albedo_texture = SubResource( 10 ) +roughness = 0.0 + +[node name="Spatial" type="Spatial"] + +[node name="Camera" type="Camera" parent="."] +transform = Transform( 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 5 ) + +[node name="HexGrid" type="StaticBody" parent="."] +script = ExtResource( 1 ) + +[node name="CollisionShape" type="CollisionShape" parent="HexGrid"] +shape = SubResource( 1 ) + +[node name="MeshInstance" type="MeshInstance" parent="HexGrid"] +mesh = SubResource( 2 ) +material/0 = null + +[node name="Highlight" type="MeshInstance" parent="HexGrid"] +transform = Transform( -1.62921e-07, 0, 1, 0, 1, 0, -1, 0, -1.62921e-07, 0, 0.2, 0 ) +mesh = SubResource( 3 ) +material/0 = SubResource( 4 ) + +[node name="Viewport" type="Viewport" parent="HexGrid/Highlight"] +size = Vector2( 200, 100 ) +transparent_bg = true +hdr = false +usage = 0 +render_target_v_flip = true + +[node name="Label" type="Label" parent="HexGrid/Highlight/Viewport"] +margin_right = 41.0 +margin_bottom = 14.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "PLANE" + +[node name="PlaneCoords" type="Label" parent="HexGrid/Highlight/Viewport"] +margin_left = 50.0 +margin_right = 97.0 +margin_bottom = 14.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "PLANE" + +[node name="Label2" type="Label" parent="HexGrid/Highlight/Viewport"] +margin_left = 18.0 +margin_top = 20.0 +margin_right = 58.0 +margin_bottom = 34.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "HEX" + +[node name="HexCoords" type="Label" parent="HexGrid/Highlight/Viewport"] +margin_left = 50.0 +margin_top = 20.0 +margin_right = 90.0 +margin_bottom = 34.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "HEX" + +[node name="LabelQuad" type="MeshInstance" parent="HexGrid/Highlight"] +transform = Transform( -2.8213e-07, 0, -1, 0, 1, 0, 1, 0, -2.8213e-07, 0, 0.3, 0.5 ) +mesh = SubResource( 5 ) +material/0 = SubResource( 7 ) + +[node name="HexGrid2" type="StaticBody" parent="."] +transform = Transform( 1, 0, 0, 0, -4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 3, -2 ) +script = ExtResource( 1 ) + +[node name="CollisionShape" type="CollisionShape" parent="HexGrid2"] +shape = SubResource( 8 ) + +[node name="MeshInstance" type="MeshInstance" parent="HexGrid2"] +mesh = SubResource( 9 ) +material/0 = null + +[node name="Highlight" type="MeshInstance" parent="HexGrid2"] +transform = Transform( -1.62921e-07, 0, 1, 0, 1, 0, -1, 0, -1.62921e-07, 0, 0.2, 0 ) +mesh = SubResource( 3 ) +material/0 = SubResource( 4 ) + +[node name="Viewport" type="Viewport" parent="HexGrid2/Highlight"] +size = Vector2( 200, 100 ) +transparent_bg = true +hdr = false +usage = 0 +render_target_v_flip = true + +[node name="Label" type="Label" parent="HexGrid2/Highlight/Viewport"] +margin_right = 40.0 +margin_bottom = 14.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "PLANE" + +[node name="PlaneCoords" type="Label" parent="HexGrid2/Highlight/Viewport"] +margin_left = 50.0 +margin_right = 97.0 +margin_bottom = 14.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "PLANE" + +[node name="Label2" type="Label" parent="HexGrid2/Highlight/Viewport"] +margin_left = 18.0 +margin_top = 20.0 +margin_right = 58.0 +margin_bottom = 34.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "HEX" + +[node name="HexCoords" type="Label" parent="HexGrid2/Highlight/Viewport"] +margin_left = 50.0 +margin_top = 20.0 +margin_right = 90.0 +margin_bottom = 34.0 +custom_colors/font_color = Color( 0, 0, 0, 1 ) +text = "HEX" + +[node name="LabelQuad" type="MeshInstance" parent="HexGrid2/Highlight"] +transform = Transform( -1.62921e-07, 1, 4.37114e-08, 0, -4.37114e-08, 1, 1, 1.62921e-07, 7.1215e-15, 0, 0.3, 0.5 ) +mesh = SubResource( 5 ) +material/0 = SubResource( 11 ) + +[connection signal="input_event" from="HexGrid" to="HexGrid" method="_on_HexGrid_input_event"] +[connection signal="input_event" from="HexGrid2" to="HexGrid2" method="_on_HexGrid_input_event"] diff --git a/addons/gdhexgrid/icon.png b/addons/gdhexgrid/icon.png new file mode 100644 index 0000000..a0b64ee Binary files /dev/null and b/addons/gdhexgrid/icon.png differ diff --git a/addons/gdhexgrid/icon.png.import b/addons/gdhexgrid/icon.png.import new file mode 100644 index 0000000..80cb81e --- /dev/null +++ b/addons/gdhexgrid/icon.png.import @@ -0,0 +1,35 @@ +[remap] + +importer="texture" +type="StreamTexture" +path="res://.import/icon.png-9e2c68854eff2ba15efab32e39b37c44.stex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/gdhexgrid/icon.png" +dest_files=[ "res://.import/icon.png-9e2c68854eff2ba15efab32e39b37c44.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 +process/normal_map_invert_y=false +stream=false +size_limit=0 +detect_3d=true +svg/scale=1.0 diff --git a/addons/gdhexgrid/test/test.tscn b/addons/gdhexgrid/test/test.tscn new file mode 100644 index 0000000..b1a4d3a --- /dev/null +++ b/addons/gdhexgrid/test/test.tscn @@ -0,0 +1,25 @@ +[gd_scene load_steps=3 format=2] + +[ext_resource path="res://addons/gut/gut.gd" type="Script" id=1] +[ext_resource path="res://addons/gut/icon.png" type="Texture" id=2] + +[node name="Node" type="Node"] + +[node name="Gut" type="WindowDialog" parent="."] +visible = true +self_modulate = Color( 1, 1, 1, 0 ) +margin_right = 851.0 +margin_bottom = 496.0 +rect_min_size = Vector2( 740, 250 ) +script = ExtResource( 1 ) +__meta__ = { +"_editor_icon": ExtResource( 2 ) +} +_select_script = null +_tests_like = null +_run_on_load = true +_yield_between_tests = false +_directory1 = "res://test/unit" +_directory2 = "res://test/integration" +_double_strategy = 1 + diff --git a/addons/gdhexgrid/test/unit/test_hexcell.gd b/addons/gdhexgrid/test/unit/test_hexcell.gd new file mode 100644 index 0000000..826f172 --- /dev/null +++ b/addons/gdhexgrid/test/unit/test_hexcell.gd @@ -0,0 +1,266 @@ +extends "res://addons/gut/test.gd" + +class TestNew: + extends "res://addons/gut/test.gd" + + var HexCell = load("res://HexCell.gd") + var cell + + func setup(): + cell = null + + + func test_null(): + cell = HexCell.new() + assert_eq(cell.axial_coords, Vector2(0, 0)) + + func test_cube(): + cell = HexCell.new(Vector3(1, 1, -2)) + assert_eq(cell.axial_coords, Vector2(1, 1)) + + func test_axial(): + cell = HexCell.new(Vector2(1, -1)) + assert_eq(cell.axial_coords, Vector2(1, -1)) + + func test_instance(): + var test_cell = HexCell.new(Vector3(-1, 2, -1)) + cell = HexCell.new(test_cell) + assert_eq(cell.axial_coords, Vector2(-1, 2)) + + +class TestConversions: + extends "res://addons/gut/test.gd" + + var HexCell = load("res://HexCell.gd") + var cell + + func setup(): + cell = HexCell.new() + + + func test_axial_to_cube(): + assert_eq(cell.axial_to_cube_coords(Vector2(2, 1)), Vector3(2, 1, -3)) + assert_eq(cell.axial_to_cube_coords(Vector2(-1, -1)), Vector3(-1, -1, 2)) + + func test_rounding(): + assert_eq(cell.round_coords(Vector3(0.1, 0.5, -0.6)), Vector3(0, 1, -1)) + assert_eq(cell.round_coords(Vector3(-0.4, -1.3, 1.7)), Vector3(-1, -1, 2)) + + assert_eq(cell.round_coords(Vector2(-0.1, 0.6)), Vector3(0, 1, -1)) + assert_eq(cell.round_coords(Vector2(4.2, -5.5)), Vector3(4, -5, 1)) + + +class TestCoords: + extends "res://addons/gut/test.gd" + + var HexCell = load("res://HexCell.gd") + var cell + + func setup(): + cell = HexCell.new() + + + func test_from_cubic_positive(): + cell.cube_coords = Vector3(2, 1, -3) + assert_eq(cell.cube_coords, Vector3(2, 1, -3)) + assert_eq(cell.axial_coords, Vector2(2, 1)) + assert_eq(cell.offset_coords, Vector2(2, 2)) + func test_from_cubic_negative(): + cell.cube_coords = Vector3(-1, -1, 2) + assert_eq(cell.cube_coords, Vector3(-1, -1, 2)) + assert_eq(cell.axial_coords, Vector2(-1, -1)) + assert_eq(cell.offset_coords, Vector2(-1, -2)) + func test_from_cubic_invalid(): + cell.cube_coords = Vector3(1, 2, 3) + assert_eq(cell.cube_coords, Vector3(0, 0, 0)) + + func test_from_axial_positive(): + cell.axial_coords = Vector2(2, 1) + assert_eq(cell.cube_coords, Vector3(2, 1, -3)) + assert_eq(cell.axial_coords, Vector2(2, 1)) + assert_eq(cell.offset_coords, Vector2(2, 2)) + func test_from_axial_negative(): + cell.axial_coords = Vector2(-1, -1) + assert_eq(cell.cube_coords, Vector3(-1, -1, 2)) + assert_eq(cell.axial_coords, Vector2(-1, -1)) + assert_eq(cell.offset_coords, Vector2(-1, -2)) + + func test_from_offset_positive(): + cell.offset_coords = Vector2(2, 2) + assert_eq(cell.cube_coords, Vector3(2, 1, -3)) + assert_eq(cell.axial_coords, Vector2(2, 1)) + assert_eq(cell.offset_coords, Vector2(2, 2)) + func test_from_offset_negative(): + cell.offset_coords = Vector2(-1, -2) + assert_eq(cell.cube_coords, Vector3(-1, -1, 2)) + assert_eq(cell.axial_coords, Vector2(-1, -1)) + assert_eq(cell.offset_coords, Vector2(-1, -2)) + + +class TestNearby: + extends "res://addons/gut/test.gd" + + var HexCell = load("res://HexCell.gd") + var cell + + func setup(): + cell = HexCell.new(Vector2(1, 2)) + + func check_expected(cells, expected): + # Check that a bunch of cells are what were expected + assert_eq(cells.size(), expected.size()) + for hex in cells: + assert_has(expected, hex.axial_coords) + + + func test_adjacent(): + var foo = cell.get_adjacent(HexCell.DIR_N) + assert_eq(foo.axial_coords, Vector2(1, 3)) + foo = cell.get_adjacent(HexCell.DIR_NE) + assert_eq(foo.axial_coords, Vector2(2, 2)) + foo = cell.get_adjacent(HexCell.DIR_SE) + assert_eq(foo.axial_coords, Vector2(2, 1)) + foo = cell.get_adjacent(HexCell.DIR_S) + assert_eq(foo.axial_coords, Vector2(1, 1)) + foo = cell.get_adjacent(HexCell.DIR_SW) + assert_eq(foo.axial_coords, Vector2(0, 2)) + foo = cell.get_adjacent(HexCell.DIR_NW) + assert_eq(foo.axial_coords, Vector2(0, 3)) + func test_not_really_adjacent(): + var foo = cell.get_adjacent(Vector3(-3, -3, 6)) + assert_eq(foo.axial_coords, Vector2(-2, -1)) + func test_adjacent_axial(): + var foo = cell.get_adjacent(Vector2(1, 1)) + assert_eq(foo.axial_coords, Vector2(2, 3)) + + func test_all_adjacent(): + var coords = [] + for foo in cell.get_all_adjacent(): + coords.append(foo.axial_coords) + assert_has(coords, Vector2(1, 3)) + assert_has(coords, Vector2(2, 2)) + assert_has(coords, Vector2(2, 1)) + assert_has(coords, Vector2(1, 1)) + assert_has(coords, Vector2(0, 2)) + assert_has(coords, Vector2(0, 3)) + + func test_all_within_0(): + var expected = [ + Vector2(1, 2), + ] + var cells = cell.get_all_within(0) + check_expected(cells, expected) + func test_all_within_1(): + var expected = [ + Vector2(1, 2), + Vector2(1, 3), + Vector2(2, 2), + Vector2(2, 1), + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + ] + var cells = cell.get_all_within(1) + check_expected(cells, expected) + func test_all_within_2(): + var expected = [ + Vector2(-1, 4), Vector2(0, 4), Vector2(1, 4), + Vector2(-1, 3), Vector2(0, 3), Vector2(1, 3), Vector2(2, 3), + Vector2(-1, 2), Vector2(0, 2), + Vector2(1, 2), + Vector2(2, 2), Vector2(3, 2), + Vector2(0, 1), Vector2(1, 1), Vector2(2, 1), Vector2(3, 1), + Vector2(1, 0), Vector2(2, 0), Vector2(3, 0), + ] + var cells = cell.get_all_within(2) + check_expected(cells, expected) + + func test_ring_0(): + var expected = [ + Vector2(1, 2), + ] + var cells = cell.get_ring(0) + check_expected(cells, expected) + func test_ring_1(): + var expected = [ + Vector2(1, 3), + Vector2(2, 2), + Vector2(2, 1), + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + ] + var cells = cell.get_ring(1) + check_expected(cells, expected) + func test_ring_2(): + var expected = [ + Vector2(1, 4), # Start at +2y + Vector2(2, 3), Vector2(3, 2), # SE + Vector2(3, 1), Vector2(3, 0), # S + Vector2(2, 0), Vector2(1, 0), # SW + Vector2(0, 1), Vector2(-1, 2), # NW + Vector2(-1, 3), Vector2(-1, 4), # N + Vector2(0, 4), # NE + ] + var cells = cell.get_ring(2) + check_expected(cells, expected) + + +class TestBetweenTwo: + extends "res://addons/gut/test.gd" + + var HexCell = load("res://HexCell.gd") + var cell + + func setup(): + cell = HexCell.new(Vector2(1, 2)) + + func test_distance(): + assert_eq(cell.distance_to(Vector2(0, 0)), 3) + assert_eq(cell.distance_to(Vector2(3, 4)), 4) + assert_eq(cell.distance_to(Vector2(-1, -1)), 5) + + func test_line_straight(): + # Straight line, nice and simple + var expected = [ + Vector2(1, 2), + Vector2(2, 2), + Vector2(3, 2), + Vector2(4, 2), + Vector2(5, 2), + ] + var path = cell.line_to(Vector2(5, 2)) + assert_eq(path.size(), expected.size()) + for idx in range(expected.size()): + assert_eq(path[idx].axial_coords, expected[idx]) + + func test_line_angled(): + # It's gone all wibbly-wobbly + var expected = [ + Vector2(1, 2), + Vector2(2, 2), + Vector2(2, 3), + Vector2(3, 3), + Vector2(4, 3), + Vector2(4, 4), + Vector2(5, 4), + ] + var path = cell.line_to(Vector2(5, 4)) + assert_eq(path.size(), expected.size()) + for idx in range(expected.size()): + assert_eq(path[idx].axial_coords, expected[idx]) + + func test_line_edge(): + # Living on the edge between two hexes + var expected = [ + Vector2(1, 2), + Vector2(1, 3), + Vector2(2, 3), + Vector2(2, 4), + Vector2(3, 4), + ] + var path = cell.line_to(Vector2(3, 4)) + assert_eq(path.size(), expected.size()) + for idx in range(expected.size()): + assert_eq(path[idx].axial_coords, expected[idx]) + diff --git a/addons/gdhexgrid/test/unit/test_hexgrid.gd b/addons/gdhexgrid/test/unit/test_hexgrid.gd new file mode 100644 index 0000000..ac64742 --- /dev/null +++ b/addons/gdhexgrid/test/unit/test_hexgrid.gd @@ -0,0 +1,97 @@ +extends "res://addons/gut/test.gd" + +var HexCell = load("res://HexCell.gd") +var HexGrid = load("res://HexGrid.gd") +var cell +var grid +var w +var h + +func setup(): + cell = HexCell.new() + grid = HexGrid.new() + w = grid.hex_size.x + h = grid.hex_size.y + + +func test_hex_to_projection(): + var tests = { + # Remember, projection +y => S + Vector2(0, 0): Vector2(0, 0), + Vector2(0, 1): Vector2(0, -h), + Vector2(1, 0): Vector2(w*0.75, -h/2), + Vector2(-4, -3): Vector2(4 * (-w*0.75), (3 * h) + (4 * h / 2)), + } + for hex in tests: + assert_eq(tests[hex], grid.get_hex_center(hex)) + +func test_hex_to_projection_scaled(): + grid.set_hex_scale(Vector2(2, 2)) + var tests = { + Vector2(0, 0): Vector2(0, 0), + Vector2(0, 1): 2 * Vector2(0, -h), + Vector2(1, 0): 2 * Vector2(w * 0.75, -h/2), + Vector2(-4, -3): 2 * Vector2(4 * (-w * 0.75), (3 * h) + (4 * h / 2)), + } + for hex in tests: + assert_eq(tests[hex], grid.get_hex_center(hex)) + +func test_hex_to_projection_squished(): + grid.set_hex_scale(Vector2(2, 1)) + var tests = { + Vector2(0, 0): Vector2(0, 0), + Vector2(0, 1): Vector2(0, -h), + Vector2(1, 0): Vector2(2 * w * 0.75, -h/2), + Vector2(-4, -3): Vector2(2 * 4 * (-w * 0.75), (3 * h) + (4 * h / 2)), + } + for hex in tests: + assert_eq(tests[hex], grid.get_hex_center(hex)) + +func test_hex_to_3d_projection(): + var tests = { + Vector2(0, 0): Vector3(0, 0, 0), + Vector2(0, 1): Vector3(0, 0, -h), + Vector2(1, 0): Vector3(w*0.75, 0, -h/2), + Vector2(-4, -3): Vector3(4 * (-w*0.75), 0, (3 * h) + (4 * h / 2)), + } + for hex in tests: + assert_eq(tests[hex], grid.get_hex_center3(hex)) + # Also test the second parameter + assert_eq( + Vector3(0, 1.2, 0), + grid.get_hex_center3(Vector2(0, 0), 1.2) + ) + + +func test_projection_to_hex(): + var tests = { + Vector2(0, 0): Vector2(0, 0), + Vector2(w / 2 - 0.01, 0): Vector2(0, 0), + Vector2(w / 2 - 0.01, h / 2): Vector2(1, -1), + Vector2(w / 2 - 0.01, -h / 2): Vector2(1, 0), + Vector2(0, h): Vector2(0, -1), + Vector2(-w - 0.01, 0): Vector2(-2, 1), + Vector2(-w, 0.01): Vector2(-1, 0), + Vector2(-w, -0.01): Vector2(-1, 1), + # Also Vector3s are valid input + Vector3(0, 0, 0): Vector2(0, 0), + Vector3(w / 2 - 0.01, 12, h / 2): Vector2(1, -1), + } + for coords in tests: + assert_eq(tests[coords], grid.get_hex_at(coords).axial_coords) + +func test_projection_to_hex_doublesquished(): + grid.set_hex_scale(Vector2(4, 2)) + var tests = { + Vector2(0, 0): Vector2(0, 0), + Vector2(4 * w / 2 - 0.01, 0): Vector2(0, 0), + Vector2(4 * w / 2 - 0.01, h / 2): Vector2(1, -1), + Vector2(4 * w / 2 - 0.01, -h / 2): Vector2(1, 0), + Vector2(0, 2 * h): Vector2(0, -1), + Vector2(4 * -w - 0.01, 0): Vector2(-2, 1), + Vector2(4 * -w, 0.01): Vector2(-1, 0), + Vector2(4 * -w, -0.01): Vector2(-1, 1), + } + for coords in tests: + assert_eq(tests[coords], grid.get_hex_at(coords).axial_coords) + diff --git a/addons/gdhexgrid/test/unit/test_pathfinding.gd b/addons/gdhexgrid/test/unit/test_pathfinding.gd new file mode 100644 index 0000000..9f4caff --- /dev/null +++ b/addons/gdhexgrid/test/unit/test_pathfinding.gd @@ -0,0 +1,331 @@ +extends "res://addons/gut/test.gd" + +var HexCell = load("res://HexCell.gd") +var HexGrid = load("res://HexGrid.gd") +var grid +var map +# This is the hex map we'll test with: +# remember: +y is N, +x is NE +""" + . + . + . . + . . + . . . + . . . + . . . . + . O B . + OF O . C + . E O . + O O D + . O . + . . + . A + G + . <- (0, 0) +""" +var a_pos = Vector2(2, 0) +var b_pos = Vector2(4, 2) +var c_pos = Vector2(7, 0) +var d_pos = Vector2(5, 0) +var e_pos = Vector2(2, 2) +var f_pos = Vector2(1, 3) +var g_pos = Vector2(1, 0) +var obstacles = [ + Vector2(2, 1), + Vector2(3, 1), + Vector2(4, 1), + Vector2(1, 2), + Vector2(3, 2), + Vector2(1, 3), + Vector2(2, 3), +] + +func setup(): + grid = HexGrid.new() + grid.set_bounds(Vector2(0, 0), Vector2(7, 4)) + grid.add_obstacles(obstacles) + +func test_bounds(): + # Push the boundaries + # Check that the test boundary works properly + assert_eq(grid.get_hex_cost(Vector2(0, 0)), grid.path_cost_default, "SW is open") + assert_eq(grid.get_hex_cost(Vector2(0, 4)), grid.path_cost_default, "W is open") + assert_eq(grid.get_hex_cost(Vector2(7, 0)), grid.path_cost_default, "E is open") + assert_eq(grid.get_hex_cost(Vector2(7, 4)), grid.path_cost_default, "NE is open") + assert_eq(grid.get_hex_cost(Vector2(8, 2)), 0, "Too much X is blocked") + assert_eq(grid.get_hex_cost(Vector2(6, 5)), 0, "Too much Y is blocked") + assert_eq(grid.get_hex_cost(Vector2(-1, 2)), 0, "Too little X is blocked") + assert_eq(grid.get_hex_cost(Vector2(6, -1)), 0, "Too little Y is blocked") +func test_negative_bounds(): + # Test negative space + grid = HexGrid.new() + grid.set_bounds(Vector2(-5, -5), Vector2(-2, -2)) + assert_eq(grid.get_hex_cost(Vector2(-2, -2)), grid.path_cost_default) + assert_eq(grid.get_hex_cost(Vector2(-5, -5)), grid.path_cost_default) + assert_eq(grid.get_hex_cost(Vector2(0, 0)), 0) + assert_eq(grid.get_hex_cost(Vector2(-6, -3)), 0) + assert_eq(grid.get_hex_cost(Vector2(-3, -1)), 0) +func test_roundabounds(): + # We can also go both ways + grid.set_bounds(Vector2(-3, -3), Vector2(2, 2)) + assert_eq(grid.get_hex_cost(Vector2(-3, -3)), grid.path_cost_default) + assert_eq(grid.get_hex_cost(Vector2(2, 2)), grid.path_cost_default) + assert_eq(grid.get_hex_cost(Vector2(0, 0)), grid.path_cost_default) + assert_eq(grid.get_hex_cost(Vector2(-4, 0)), 0) + assert_eq(grid.get_hex_cost(Vector2(0, 3)), 0) + +func test_grid_obstacles(): + # Make sure we can obstacleize the grid + assert_eq(grid.get_obstacles().size(), obstacles.size()) + # Test adding via a HexCell instance + grid.add_obstacles(HexCell.new(Vector2(0, 0))) + assert_eq(grid.get_obstacles()[Vector2(0, 0)], 0) + # Test replacing an obstacle + grid.add_obstacles(Vector2(0, 0), 2) + assert_eq(grid.get_obstacles()[Vector2(0, 0)], 2) + # Test removing an obstacle + grid.remove_obstacles(Vector2(0, 0)) + assert_does_not_have(grid.get_obstacles(), Vector2(0, 0)) + # Make sure removing a non-obstacle doesn't error + grid.remove_obstacles(Vector2(0, 0)) + +func test_grid_barriers(): + # Make sure we can barrier things on the grid + assert_eq(grid.get_barriers().size(), 0) + # Add a barrier + var coords = Vector2(0, 0) + var barriers = grid.get_barriers() + grid.add_barriers(coords, HexCell.DIR_N) + assert_eq(barriers.size(), 1) + assert_has(barriers, coords) + assert_eq(barriers[coords].size(), 1) + assert_has(barriers[coords], HexCell.DIR_N) + # Overwrite the barrier + grid.add_barriers(coords, HexCell.DIR_N, 1337) + assert_eq(barriers[coords][HexCell.DIR_N], 1337) + # Add more barrier to the hex + grid.add_barriers(coords, [HexCell.DIR_S, HexCell.DIR_NE]) + assert_eq(barriers[coords].size(), 3) + assert_has(barriers[coords], HexCell.DIR_N) + # Remove part of the hex's barrier + grid.remove_barriers(coords, [HexCell.DIR_N]) + assert_eq(barriers[coords].size(), 2) + assert_does_not_have(barriers[coords], HexCell.DIR_N) + assert_has(barriers[coords], HexCell.DIR_S) + assert_has(barriers[coords], HexCell.DIR_NE) + # Remove all the hex's barriers + grid.remove_barriers(coords) + assert_eq(barriers.size(), 0) + # Remove no barrier with no error + grid.remove_barriers([Vector2(1, 1), Vector2(2, 2)]) + + +func test_hex_costs(): + # Test that the price is right + assert_eq(grid.get_hex_cost(HexCell.new(Vector2(1, 1))), grid.path_cost_default, "Open hex is open") + assert_eq(grid.get_hex_cost(Vector3(2, 1, -3)), 0, "Obstacle being obstructive") + # Test partial obstacle + grid.add_obstacles(Vector2(1, 1), 1.337) + assert_eq(grid.get_hex_cost(Vector2(1, 1)), 1.337, "9") + +func test_move_costs(): + # Test that more than just hex costs are at work + assert_eq(grid.get_move_cost(Vector2(0, 0), HexCell.DIR_N), grid.path_cost_default) +func test_move_cost_barrier(): + # Put up a barrier + grid.add_barriers(Vector2(0, 0), HexCell.DIR_N) + assert_eq(grid.get_move_cost(Vector2(0, 0), HexCell.DIR_N), 0) +func test_move_cost_barrier_backside(): + # The destination has a barrier + grid.add_barriers(Vector2(0, 1), HexCell.DIR_S) + assert_eq(grid.get_move_cost(Vector2(0, 0), HexCell.DIR_N), 0) +func test_move_cost_cumulative(): + # Test that moving adds up hex and barrier values + # But NOT from the *starting* hex! + grid.add_obstacles(Vector2(0, 0), 1) + grid.add_obstacles(Vector2(0, 1), 2) + grid.add_barriers(Vector2(0, 0), HexCell.DIR_N, 4) + grid.add_barriers(Vector2(0, 1), HexCell.DIR_S, 8) + assert_eq(grid.get_move_cost(Vector2(0, 0), HexCell.DIR_N), 14) + + +func check_path(got, expected): + # Assert that the gotten path was the expected route + assert_eq(got.size(), expected.size(), "Path should be as long as expected") + for idx in range(min(got.size(), expected.size())): + var hex = got[idx] + var check = expected[idx] + if typeof(check) == TYPE_ARRAY: + # In case of multiple valid paths + assert_has(check, hex.axial_coords) + else: + assert_eq(check, hex.axial_coords) + +func test_straight_line(): + # Path between A and C is straight + var path = [ + a_pos, + Vector2(3, 0), + Vector2(4, 0), + Vector2(5, 0), + Vector2(6, 0), + c_pos, + ] + check_path(grid.find_path(a_pos, c_pos), path) + +func test_wonky_line(): + # Path between B and C is a bit wonky + var path = [ + b_pos, + [Vector2(5, 1), Vector2(5, 2)], + [Vector2(6, 0), Vector2(6, 1)], + c_pos, + ] + check_path(grid.find_path(HexCell.new(b_pos), HexCell.new(c_pos)), path) + +func test_obstacle(): + # Path between A and B should go around the bottom + var path = [ + a_pos, + Vector2(3, 0), + Vector2(4, 0), + Vector2(5, 0), + Vector2(5, 1), + b_pos, + ] + check_path(grid.find_path(a_pos, b_pos), path) + +func test_walls(): + # Test that we can't walk through walls + var walls = [ + HexCell.DIR_N, + HexCell.DIR_NE, + HexCell.DIR_SE, + HexCell.DIR_S, + # DIR_SE is the only opening + HexCell.DIR_NW, + ] + grid.add_barriers(g_pos, walls) + var path = [ + a_pos, + Vector2(1, 1), + Vector2(0, 1), + Vector2(0, 0), + g_pos, + ] + check_path(grid.find_path(a_pos, g_pos), path) + +func test_slopes(): + # Test that we *can* walk through *some* walls + # A barrier which is passable, but not worth our hex + grid.add_barriers(g_pos, HexCell.DIR_NE, 3) + # A barrier which is marginally better than moving that extra hex + grid.add_barriers(g_pos, HexCell.DIR_N, grid.path_cost_default - 0.1) + var path = [ + a_pos, + Vector2(1, 1), + g_pos, + ] + check_path(grid.find_path(a_pos, g_pos), path) + +func test_rough_terrain(): + # Path between A and B depends on the toughness of D + var short_path = [ + a_pos, + Vector2(3, 0), + Vector2(4, 0), + d_pos, + Vector2(5, 1), + b_pos, + ] + var long_path = [ + a_pos, + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + Vector2(0, 4), + Vector2(1, 4), + Vector2(2, 4), + Vector2(3, 3), + b_pos, + ] + # The long path is 9 long, the short 6, + # so it should take the long path once d_pos costs more than 3 over default + var tests = { + grid.path_cost_default: short_path, + grid.path_cost_default + 1: short_path, + grid.path_cost_default + 2.9: short_path, + grid.path_cost_default + 3.1: long_path, + grid.path_cost_default + 50: long_path, + 0: long_path, + } + for cost in tests: + grid.add_obstacles(d_pos, cost) + check_path(grid.find_path(a_pos, b_pos), tests[cost]) + +func test_exception(): + # D is impassable, so path between A and B should go around the top as well + var path = [ + a_pos, + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + Vector2(0, 4), + Vector2(1, 4), + Vector2(2, 4), + Vector2(3, 3), + b_pos, + ] + check_path(grid.find_path(a_pos, b_pos, [d_pos]), path) +func test_exception_hex(): + # Same as the above, but providing an exceptional HexCell instance + var path = [ + a_pos, + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + Vector2(0, 4), + Vector2(1, 4), + Vector2(2, 4), + Vector2(3, 3), + b_pos, + ] + check_path(grid.find_path(a_pos, b_pos, [HexCell.new(d_pos)]), path) + +func test_exceptional_goal(): + # If D is impassable, we should path to its neighbour + var path = [ + a_pos, + Vector2(3, 0), + Vector2(4, 0), + ] + check_path(grid.find_path(a_pos, d_pos, [d_pos]), path) + +func test_inaccessible(): + # E is inaccessible! + var path = grid.find_path(a_pos, e_pos) + assert_eq(path.size(), 0) + +func test_obstacle_neighbour(): + # Sometimes we can't get to something, but we can get next to it. + var path = [ + a_pos, + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + ] + check_path(grid.find_path(a_pos, f_pos), path) + +func test_difficult_goal(): + # We should be able to path to a goal, no matter how difficult the final step + grid.add_obstacles(f_pos, 1337) + var path = [ + a_pos, + Vector2(1, 1), + Vector2(0, 2), + Vector2(0, 3), + f_pos, + ] + check_path(grid.find_path(a_pos, f_pos), path) +