From ee1faac77261f2c1862ac81cdb28b8776932fad9 Mon Sep 17 00:00:00 2001 From: Tyler Veness Date: Sat, 14 Dec 2024 12:04:38 -0800 Subject: [PATCH 1/5] Make elasticlib C++ and Python libraries more idiomatic (#140) All: * Rename the Notification class to Alert to match the user-facing function * Remove the Elastic prefix from class names since the namespace (C++), outer class (Java), or package name (Python) already provides that namespacing C++: * Flatten the type structure * Put all classes and functions in an elastic namespace * Since the Alert class provides setters and getters, just make the members public to allow [aggregate initialization](https://en.cppreference.com/w/cpp/language/aggregate_initialization) * Remove redundant Javadoc tags from comments * Rewrite the Javadoc-like comments to follow Doxygen conventions * Make pixel counts use ints, since them being fractional makes no sense * Move the JSON serialization and NT publishing to a .cpp file * Use WPILib's bundled fmtlib instead of iostream for performance Python: * Flatten type structure * Remove decorator functions since optional keyword arguments provides the same functionality with the same reduction in verbosity as C++ * Rename the Notification class to Alert to match the user-facing function * Move JSON serialization into send_alert() I confirmed the C++ version built within https://github.com/wpilibsuite/allwpilib/tree/main/developerRobot. Co-authored-by: Gold87 <91761103+Gold872@users.noreply.github.com> --- elasticlib/Elastic.java | 54 +++--- elasticlib/elasticlib.cpp | 53 ++++++ elasticlib/elasticlib.h | 370 ++++---------------------------------- elasticlib/elasticlib.py | 344 +++++------------------------------ 4 files changed, 159 insertions(+), 662 deletions(-) create mode 100644 elasticlib/elasticlib.cpp diff --git a/elasticlib/Elastic.java b/elasticlib/Elastic.java index d8ab4df0..b124759a 100644 --- a/elasticlib/Elastic.java +++ b/elasticlib/Elastic.java @@ -21,25 +21,25 @@ public final class Elastic { private static final ObjectMapper objectMapper = new ObjectMapper(); /** - * Sends an alert notification to the Elastic dashboard. The alert is serialized as a JSON string + * Sends an notification to the Elastic dashboard. The notification is serialized as a JSON string * before being published. * - * @param alert the {@link ElasticNotification} object containing alert details + * @param notification the {@link Notification} object containing notification details */ - public static void sendAlert(ElasticNotification alert) { + public static void sendNotification(Notification notification) { try { - publisher.set(objectMapper.writeValueAsString(alert)); + publisher.set(objectMapper.writeValueAsString(notification)); } catch (JsonProcessingException e) { e.printStackTrace(); } } /** - * Represents a notification object to be sent to the Elastic dashboard. This object holds + * Represents an notification object to be sent to the Elastic dashboard. This object holds * properties such as level, title, description, display time, and dimensions to control how the - * alert is displayed on the dashboard. + * notification is displayed on the dashboard. */ - public static class ElasticNotification { + public static class Notification { @JsonProperty("level") private NotificationLevel level; @@ -59,17 +59,17 @@ public static class ElasticNotification { private double height; /** - * Creates a new ElasticNotification with all default parameters. This constructor is intended + * Creates a new Notification with all default parameters. This constructor is intended * to be used with the chainable decorator methods * *

Title and description fields are empty. */ - public ElasticNotification() { + public Notification() { this(NotificationLevel.INFO, "", ""); } /** - * Creates a new ElasticNotification with all properties specified. + * Creates a new Notification with all properties specified. * * @param level the level of the notification (e.g., INFO, WARNING, ERROR) * @param title the title text of the notification @@ -78,7 +78,7 @@ public ElasticNotification() { * @param width the width of the notification display area * @param height the height of the notification display area, inferred if below zero */ - public ElasticNotification( + public Notification( NotificationLevel level, String title, String description, @@ -94,32 +94,32 @@ public ElasticNotification( } /** - * Creates a new ElasticNotification with default display time and dimensions. + * Creates a new Notification with default display time and dimensions. * * @param level the level of the notification * @param title the title text of the notification * @param description the descriptive text of the notification */ - public ElasticNotification(NotificationLevel level, String title, String description) { + public Notification(NotificationLevel level, String title, String description) { this(level, title, description, 3000, 350, -1); } /** - * Creates a new ElasticNotification with a specified display time and default dimensions. + * Creates a new Notification with a specified display time and default dimensions. * * @param level the level of the notification * @param title the title text of the notification * @param description the descriptive text of the notification * @param displayTimeMillis the display time in milliseconds */ - public ElasticNotification( + public Notification( NotificationLevel level, String title, String description, int displayTimeMillis) { this(level, title, description, displayTimeMillis, 350, -1); } /** - * Creates a new ElasticNotification with specified dimensions and default display time. If the - * height is below zero, it is automatically inferred based on screen size. + * Creates a new Notification with specified dimensions and default display time. If the height + * is below zero, it is automatically inferred based on screen size. * * @param level the level of the notification * @param title the title text of the notification @@ -127,7 +127,7 @@ public ElasticNotification( * @param width the width of the notification display area * @param height the height of the notification display area, inferred if below zero */ - public ElasticNotification( + public Notification( NotificationLevel level, String title, String description, double width, double height) { this(level, title, description, 3000, width, height); } @@ -250,7 +250,7 @@ public double getHeight() { * @param level the level to set the notification to * @return the current notification */ - public ElasticNotification withLevel(NotificationLevel level) { + public Notification withLevel(NotificationLevel level) { this.level = level; return this; } @@ -261,7 +261,7 @@ public ElasticNotification withLevel(NotificationLevel level) { * @param title the title to set the notification to * @return the current notification */ - public ElasticNotification withTitle(String title) { + public Notification withTitle(String title) { setTitle(title); return this; } @@ -272,7 +272,7 @@ public ElasticNotification withTitle(String title) { * @param description the description to set the notification to * @return the current notification */ - public ElasticNotification withDescription(String description) { + public Notification withDescription(String description) { setDescription(description); return this; } @@ -283,7 +283,7 @@ public ElasticNotification withDescription(String description) { * @param seconds the number of seconds to display the notification for * @return the current notification */ - public ElasticNotification withDisplaySeconds(double seconds) { + public Notification withDisplaySeconds(double seconds) { return withDisplayMilliseconds((int) Math.round(seconds * 1000)); } @@ -293,7 +293,7 @@ public ElasticNotification withDisplaySeconds(double seconds) { * @param displayTimeMillis the number of milliseconds to display the notification for * @return the current notification */ - public ElasticNotification withDisplayMilliseconds(int displayTimeMillis) { + public Notification withDisplayMilliseconds(int displayTimeMillis) { setDisplayTimeMillis(displayTimeMillis); return this; } @@ -304,7 +304,7 @@ public ElasticNotification withDisplayMilliseconds(int displayTimeMillis) { * @param width the width to set the notification to * @return the current notification */ - public ElasticNotification withWidth(double width) { + public Notification withWidth(double width) { setWidth(width); return this; } @@ -315,7 +315,7 @@ public ElasticNotification withWidth(double width) { * @param height the height to set the notification to * @return the current notification */ - public ElasticNotification withHeight(double height) { + public Notification withHeight(double height) { setHeight(height); return this; } @@ -327,7 +327,7 @@ public ElasticNotification withHeight(double height) { * * @return the current notification */ - public ElasticNotification withAutomaticHeight() { + public Notification withAutomaticHeight() { setHeight(-1); return this; } @@ -342,7 +342,7 @@ public ElasticNotification withAutomaticHeight() { * * @return the current notification */ - public ElasticNotification withNoAutoDismiss() { + public Notification withNoAutoDismiss() { setDisplayTimeMillis(0); return this; } diff --git a/elasticlib/elasticlib.cpp b/elasticlib/elasticlib.cpp new file mode 100644 index 00000000..065322a3 --- /dev/null +++ b/elasticlib/elasticlib.cpp @@ -0,0 +1,53 @@ +// Copyright (c) 2023-2024 Gold87 and other Elastic contributors +// This software can be modified and/or shared under the terms +// defined by the Elastic license: +// https://github.com/Gold872/elastic-dashboard/blob/main/LICENSE + +#include "elasticlib.h" + +#include + +#include +#include +#include +#include + +namespace elastic { + +void SendNotification(const Notification& notification) { + static nt::StringTopic topic = + nt::NetworkTableInstance::GetDefault().GetStringTopic( + "/Elastic/RobotNotifications"); + static nt::StringPublisher publisher = + topic.Publish({.sendAll = true, .keepDuplicates = true}); + + try { + // Convert Notification to JSON string + wpi::json jsonData; + + if (notification.level == NotificationLevel::INFO) { + jsonData["level"] = "INFO"; + } else if (notification.level == NotificationLevel::WARNING) { + jsonData["level"] = "WARNING"; + } else if (notification.level == NotificationLevel::ERROR) { + jsonData["level"] = "ERROR"; + } else { + jsonData["level"] = "UNKNOWN"; + } + + jsonData["title"] = notification.title; + jsonData["description"] = notification.description; + jsonData["displayTime"] = notification.displayTime.value(); + jsonData["width"] = notification.width; + jsonData["height"] = notification.height; + + // Publish the JSON string + publisher.Set(jsonData.dump()); + } catch (const std::exception& e) { + fmt::println(stderr, "Error processing JSON: {}", e.what()); + } catch (...) { + fmt::println(stderr, "Unknown error occurred while processing JSON."); + } +} + +} // namespace elastic diff --git a/elasticlib/elasticlib.h b/elasticlib/elasticlib.h index 76854e88..d3697230 100644 --- a/elasticlib/elasticlib.h +++ b/elasticlib/elasticlib.h @@ -5,352 +5,52 @@ #pragma once -#include -#include -#include - -#include -#include -#include #include -/** - * @class Elastic - * @brief Handles publishing notifications to the Elastic Robot Notifications - * topic on NetworkTables. - */ -class Elastic { - public: - /** - * @struct Notification - * @brief Represents a notification with various display properties. - */ - struct Notification { - /** - * @enum Level - * @brief Defines severity levels for notifications. - */ - enum class Level { INFO, WARNING, ERROR }; - - Notification() : level(Level::INFO), title(""), description("") {} - - /** - * @brief Constructs a Notification with default display time and - * dimensions. - * @param level The severity level of the notification. - * @param title The title of the notification. - * @param description The description of the notification. - */ - Notification(Level level, const std::string& title, - const std::string& description) - : level(level), - title(title), - description(description), - displayTimeMillis(3000), - width(350), - height(-1) {} - - /** - * @brief Constructs a Notification with specified display time. - * @param level The severity level of the notification. - * @param title The title of the notification. - * @param description The description of the notification. - * @param displayTimeInMillis Duration to display the notification, in - * milliseconds. - */ - Notification(Level level, const std::string& title, - const std::string& description, int displayTimeInMillis) - : level(level), - title(title), - description(description), - displayTimeMillis(displayTimeInMillis), - width(350), - height(-1) {} - - /** - * @brief Constructs a Notification with specified display time and - * dimensions. - * @param level The severity level of the notification. - * @param title The title of the notification. - * @param description The description of the notification. - * @param displayTimeInMillis Duration to display the notification, in - * milliseconds. - * @param width Width of the notification display. - * @param height Height of the notification display. - */ - Notification(Level level, const std::string& title, - const std::string& description, int displayTimeInMillis, - double width, double height) - : level(level), - title(title), - description(description), - displayTimeMillis(displayTimeInMillis), - width(width), - height(height) {} - - /** - * @brief Sets the notification level. - * @param level The new severity level. - */ - void SetLevel(Level level) { this->level = level; } - - /** - * @brief Gets the notification level. - * @return The current severity level. - */ - Level GetLevel() const { return level; } - - /** - * @brief Sets the title of the notification. - * @param title The new title. - */ - void SetTitle(const std::string& title) { this->title = title; } - - /** - * @brief Gets the title of the notification. - * @return The current title. - */ - std::string GetTitle() const { return title; } - - /** - * @brief Sets the description of the notification. - * @param description The new description. - */ - void SetDescription(const std::string& description) { - this->description = description; - } - - /** - * @brief Gets the description of the notification. - * @return The current description. - */ - std::string GetDescription() const { return description; } - - /** - * @brief Gets the display duration of the notification. - * @return Display time in milliseconds. - */ - int GetDisplayTimeMillis() const { return displayTimeMillis; } - - /** - * @brief Sets the display duration of the notification. - * @param newDisplayTimeMillis New display time in milliseconds. - */ - void SetDisplayTimeMillis(int newDisplayTimeMillis) { - this->displayTimeMillis = newDisplayTimeMillis; - } - - void SetDisplayTimeSeconds(double seconds) { - SetDisplayTimeMillis((int)std::round(seconds * 1000)); - } +#include - /** - * @brief Gets the display width of the notification. - * @return The width in pixels. - */ - double GetWidth() const { return width; } +namespace elastic { - /** - * @brief Sets the display width of the notification. - * @param width The new width in pixels. - */ - void SetWidth(double width) { this->width = width; } - - /** - * @brief Gets the display height of the notification. - * @return The height in pixels. - */ - double GetHeight() const { return height; } - - /** - * @brief Sets the display height of the notification. - * @param height The new height in pixels. - */ - void SetHeight(double height) { this->height = height; } - - /** - * Modifies the notification's level and returns itself to allow for method - * chaining. - * - * @param level The level to set the notification to. - * @return A reference to the current notification. - */ - Notification& WithLevel(Level level) { - this->level = level; - return *this; - } - - /** - * Modifies the notification's title and returns itself to allow for method - * chaining. - * - * @param title The title to set the notification to. - * @return A reference to the current notification. - */ - Notification& WithTitle(const std::string& title) { - SetTitle(title); - return *this; - } - - /** - * Modifies the notification's description and returns itself to allow for - * method chaining. - * - * @param description The description to set the notification to. - * @return A reference to the current notification. - */ - Notification& WithDescription(const std::string& description) { - SetDescription(description); - return *this; - } - - /** - * Modifies the notification's display time and returns itself to allow for - * method chaining. - * - * @param seconds The number of seconds to display the notification for. - * @return A reference to the current notification. - */ - Notification& WithDisplaySeconds(double seconds) { - return WithDisplayMilliseconds( - static_cast(std::round(seconds * 1000))); - } - - /** - * Modifies the notification's display time and returns itself to allow for - * method chaining. - * - * @param displayTimeMillis The number of milliseconds to display the - * notification for. - * @return A reference to the current notification. - */ - Notification& WithDisplayMilliseconds(int displayTimeMillis) { - SetDisplayTimeMillis(displayTimeMillis); - return *this; - } - - /** - * Modifies the notification's width and returns itself to allow for method - * chaining. - * - * @param width The width to set the notification to. - * @return A reference to the current notification. - */ - Notification& WithWidth(double width) { - SetWidth(width); - return *this; - } - - /** - * Modifies the notification's height and returns itself to allow for method - * chaining. - * - * @param height The height to set the notification to. - * @return A reference to the current notification. - */ - Notification& WithHeight(double height) { - SetHeight(height); - return *this; - } +/** + * Defines severity levels for notifications. + */ +enum class NotificationLevel { INFO, WARNING, ERROR }; - /** - * Modifies the notification's height and returns itself to allow for method - * chaining. - * - *

This will set the height to -1 to have it automatically determined by - * the dashboard. - * - * @return A reference to the current notification. - */ - Notification& WithAutomaticHeight() { - SetHeight(-1); - return *this; - } +/** + * Represents an notification with various display properties. + */ +struct Notification { + /// Set the display time to this value to disable the auto-dismiss behavior. + static constexpr units::millisecond_t NO_AUTO_DISMISS{0_s}; - /** - * Modifies the notification to disable the auto-dismiss behavior. - * - *

This sets the display time to 0 milliseconds. - * - *

The auto-dismiss behavior can be re-enabled by setting the display - * time to a number greater than 0. - * - * @return A reference to the current notification. - */ - Notification& WithNoAutoDismiss() { - SetDisplayTimeMillis(0); - return *this; - } - /** - * @brief Converts the notification to a JSON string for publishing. - * @return JSON string representing the notification. - */ - std::string ToJson() const { - wpi::json jsonData; - jsonData["level"] = LevelToString(level); - jsonData["title"] = title; - jsonData["description"] = description; - jsonData["displayTime"] = displayTimeMillis; - jsonData["width"] = width; - jsonData["height"] = height; - return jsonData.dump(); - } + // Set the height to this value to have the dashboard automatically determine + // the height. + static constexpr int AUTOMATIC_HEIGHT = -1; - /** - * @brief Converts a notification level to its string representation. - * @param level The notification level. - * @return The string representation of the level. - */ - static std::string LevelToString(Level level) { - switch (level) { - case Level::INFO: - return "INFO"; - case Level::WARNING: - return "WARNING"; - case Level::ERROR: - return "ERROR"; - default: - return "UNKNOWN"; - } - } + /// Notification severity level. + NotificationLevel level = NotificationLevel::INFO; - private: - Level level; ///< Notification severity level. - std::string title; ///< Title of the notification. - std::string description; ///< Description of the notification. - int displayTimeMillis; ///< Display time in milliseconds. - double width; ///< Display width in pixels. - double height; ///< Display height in pixels. - }; + /// Title of the notification. + std::string title; - /** - * @brief Publishes a notification as a JSON string to the NetworkTables - * topic. - * @param alert The notification to send. - */ - static void SendAlert(const Notification& alert) { - try { - std::string jsonString = - alert.ToJson(); // Convert Notification to JSON string - GetPublisher().Set(jsonString); // Publish the JSON string - } catch (const std::exception& e) { - std::cerr << "Error processing JSON: " << e.what() << std::endl; - } catch (...) { - std::cerr << "Unknown error occurred while processing JSON." << std::endl; - } - } + /// Description of the notification. + std::string description; - private: - static nt::StringPublisher& GetPublisher() { - static nt::StringTopic topic = - nt::NetworkTableInstance::GetDefault().GetStringTopic( - "/Elastic/RobotNotifications"); + /// Display time. + units::millisecond_t displayTime{3_s}; - static nt::StringPublisher publisher = - topic.Publish({.sendAll = true, .keepDuplicates = true}); + /// Display width in pixels. + int width = 350; - return publisher; - } + /// Display height in pixels. + int height = AUTOMATIC_HEIGHT; }; -using ElasticNotification = Elastic::Notification; +/** + * Publishes an notification as a JSON string to the NetworkTables topic. + * + * @param notification The notification to send. + */ +void SendNotification(const Notification& notification); + +} // namespace elastic diff --git a/elasticlib/elasticlib.py b/elasticlib/elasticlib.py index b9c160a8..adaf8856 100644 --- a/elasticlib/elasticlib.py +++ b/elasticlib/elasticlib.py @@ -1,19 +1,18 @@ import json +from enum import Enum from typing import Dict + from ntcore import NetworkTableInstance, PubSubOptions -class ElasticNotification: - """ - Represents a notification object to be sent to the Elastic dashboard. - This object holds properties such as level, title, description, display time, - and dimensions to control how the alert is displayed on the dashboard. - """ +class NotificationLevel(Enum): + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" - class NotificationLevel: - INFO = "INFO" - WARNING = "WARNING" - ERROR = "ERROR" + +class Notification: + """Represents an notification with various display properties.""" def __init__( self, @@ -42,299 +41,44 @@ def __init__( self.width = width self.height = height - def to_dict(self) -> Dict[str, str | float | int | NotificationLevel]: - """ - Converts the notification to a dictionary for JSON serialization. - - Returns: - dict: A dictionary representation of the notification object. - """ - return { - "level": self.level, - "title": self.title, - "description": self.description, - "displayTime": self.display_time, - "width": self.width, - "height": self.height, - } - - def with_level(self, level: str): - """ - Sets the notification level and returns the object for chaining. - - Args: - level (str): The level to set the notification to. - - Returns: - ElasticNotification: The current notification object with the updated level. - """ - self.level = level - return self - - def with_title(self, title: str): - """ - Sets the title and returns the object for chaining. - - Args: - title (str): The title to set for the notification. - - Returns: - ElasticNotification: The current notification object with the updated title. - """ - self.title = title - return self - - def with_description(self, description: str): - """ - Sets the description and returns the object for chaining. - - Args: - description (str): The description to set for the notification. - - Returns: - ElasticNotification: The current notification object with the updated description. - """ - self.description = description - return self - - def with_display_seconds(self, seconds: float): - """ - Sets the display time in seconds and returns the object for chaining. - - Args: - seconds (float): The number of seconds the notification should be displayed for. - - Returns: - ElasticNotification: The current notification object with the updated display time. - """ - self.display_time = int(round(seconds * 1000)) - return self - - def with_display_milliseconds(self, display_time: int): - """ - Sets the display time in milliseconds and returns the object for chaining. - - Args: - display_time (int): The display time in milliseconds. - - Returns: - ElasticNotification: The current notification object with the updated display time. - """ - self.display_time = display_time - return self - - def with_width(self, width: float): - """ - Sets the display width and returns the object for chaining. - - Args: - width (float): The width to set for the notification. - - Returns: - ElasticNotification: The current notification object with the updated width. - """ - self.width = width - return self - - def with_height(self, height: float): - """ - Sets the display height and returns the object for chaining. - - Args: - height (float): The height to set for the notification. - - Returns: - ElasticNotification: The current notification object with the updated height. - """ - self.height = height - return self - - def with_automatic_height(self): - """ - Sets the height to automatic and returns the object for chaining. - - Returns: - ElasticNotification: The current notification object with automatic height. - """ - self.height = -1 - return self - - def with_no_auto_dismiss(self): - """ - Sets the display time to 0 to prevent automatic dismissal. - - This method prevents the notification from disappearing automatically. - - Returns: - None - """ - self.display_time = 0 - - def get_level(self) -> str: - """ - Returns the level of this notification. - - Returns: - str: The current level of the notification. - """ - return self.level - - def set_level(self, level: str): - """ - Updates the level of this notification. - - Args: - level (str): The level to set the notification to. - - Returns: - None - """ - self.level = level - - def get_title(self) -> str: - """ - Returns the title of this notification. - - Returns: - str: The current title of the notification. - """ - return self.title - - def set_title(self, title: str): - """ - Updates the title of this notification. - - Args: - title (str): The title to set the notification to. - - Returns: - None - """ - self.title = title - - def get_description(self) -> str: - """ - Returns the description of this notification. - - Returns: - str: The current description of the notification. - """ - return self.description - - def set_description(self, description: str): - """ - Updates the description of this notification. - - Args: - description (str): The description to set the notification to. - - Returns: - None - """ - self.description = description - - def get_display_time_millis(self) -> int: - """ - Returns the number of milliseconds the notification is displayed for. - Returns: - int: The display time in milliseconds. - """ - return self.display_time +__topic = None +__publisher = None - def set_display_time_seconds(self, seconds: float): - """ - Updates the display time of the notification in seconds. - - Args: - seconds (float): The number of seconds to display the notification for. - - Returns: - None - """ - self.display_time = int(round(seconds * 1000)) - - def set_display_time_millis(self, display_time_millis: int): - """ - Updates the display time of the notification in milliseconds. - Args: - display_time_millis (int): The number of milliseconds to display the notification for. - - Returns: - None - """ - self.display_time = display_time_millis - - def get_width(self) -> float: - """ - Returns the width of this notification. - - Returns: - float: The current width of the notification. - """ - return self.width - - def set_width(self, width: float): - """ - Updates the width of this notification. - - Args: - width (float): The width to set for the notification. - - Returns: - None - """ - self.width = width - - def get_height(self) -> float: - """ - Returns the height of this notification. - - Returns: - float: The current height of the notification. - """ - return self.height - - def set_height(self, height: float): - """ - Updates the height of this notification. - - Args: - height (float): The height to set for the notification. If height is -1, it indicates automatic height. - - Returns: - None - """ - self.height = height - - -class Elastic: +def send_notification(notification: Notification): """ - A class responsible for sending alert notifications to the Elastic dashboard. + Sends an notification notification to the Elastic dashboard. + The notification is serialized as a JSON string before being published. - This class uses NetworkTables to publish notifications to the dashboard. - The alerts are serialized as JSON strings before being sent. - """ - - _topic = NetworkTableInstance.getDefault().getStringTopic( - "/Elastic/RobotNotifications" - ) - _publisher = _topic.publish(PubSubOptions(sendAll=True, keepDuplicates=True)) - - @staticmethod - def send_alert(alert: ElasticNotification): - """ - Sends an alert notification to the Elastic dashboard. - The alert is serialized as a JSON string before being published. + Args: + notification (ElasticNotification): The notification object containing the notification details. - Args: - alert (ElasticNotification): The notification object containing the alert details. - - Raises: - Exception: If there is an error during serialization or publishing the alert. - """ - try: - Elastic._publisher.set(json.dumps(alert.to_dict())) - except Exception as e: - print(f"Error serializing alert: {e}") + Raises: + Exception: If there is an error during serialization or publishing the notification. + """ + global __topic + global __publisher + + if not __topic: + __topic = NetworkTableInstance.getDefault().getStringTopic( + "/Elastic/RobotNotifications" + ) + if not __publisher: + __publisher = __topic.publish(PubSubOptions(sendAll=True, keepDuplicates=True)) + + try: + __publisher.set( + json.dumps( + { + "level": notification.level, + "title": notification.title, + "description": notification.description, + "displayTime": notification.display_time, + "width": notification.width, + "height": notification.height, + } + ) + ) + except Exception as e: + print(f"Error serializing notification: {e}") From eea7ba69cdb2b8608e901c26a7cfa1f0d06a97ca Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sat, 14 Dec 2024 16:23:30 -0500 Subject: [PATCH 2/5] Add CI for ElasticLib formatting (#139) --- .github/workflows/elasticlib-ci.yaml | 32 ++++++++++++++++++++++++++++ elasticlib/.styleguide | 10 ++++++++- elasticlib/elasticlib.py | 1 - 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/elasticlib-ci.yaml diff --git a/.github/workflows/elasticlib-ci.yaml b/.github/workflows/elasticlib-ci.yaml new file mode 100644 index 00000000..adfeb4db --- /dev/null +++ b/.github/workflows/elasticlib-ci.yaml @@ -0,0 +1,32 @@ +name: ElasticLib + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +jobs: + wpiformat-analyze: + name: "Verify Formatting" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Fetch all history and metadata + run: | + git checkout -b pr + git branch -f main origin/main + - name: Set up Python 3.8 + uses: actions/setup-python@v5 + with: + python-version: 3.8 + - name: Install wpiformat + run: pip3 install wpiformat==2024.50 + - name: Run wpiformat + run: wpiformat -f Elastic.java elasticlib.h elasticlib.cpp elasticlib.py + working-directory: ./elasticlib + - name: Check output + run: git --no-pager diff --exit-code HEAD diff --git a/elasticlib/.styleguide b/elasticlib/.styleguide index 425b8074..2301a18e 100644 --- a/elasticlib/.styleguide +++ b/elasticlib/.styleguide @@ -12,11 +12,19 @@ cppSrcFileInclude { modifiableFileExclude { gradle/ assets/ + build/ + linux/ macos/ windows/ - linux/ test_resources/ screenshots/ installer_setup_script.iss README.md } + +includeOtherLibs { + ^fmt/ + ^networktables/ + ^units/ + ^wpi/ +} diff --git a/elasticlib/elasticlib.py b/elasticlib/elasticlib.py index adaf8856..566941c5 100644 --- a/elasticlib/elasticlib.py +++ b/elasticlib/elasticlib.py @@ -1,6 +1,5 @@ import json from enum import Enum -from typing import Dict from ntcore import NetworkTableInstance, PubSubOptions From 5ddce9410e1190a1eebdc07b8b2111606e8ddd8e Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 15 Dec 2024 11:20:21 -0500 Subject: [PATCH 3/5] Remote Layout Download from Robot (#153) Depends on https://github.com/wpilibsuite/allwpilib/pull/7527 - Adds the ability to download and merge a layout from a robot via HTTP - Deprecates the Shuffleboard API support as remote downloading is much more reliable and easier to maintain Resolves #134 --- lib/pages/dashboard_page.dart | 247 +++++++++++++++++--- lib/services/elastic_layout_downloader.dart | 45 ++++ lib/widgets/tab_grid.dart | 189 ++++++++------- 3 files changed, 362 insertions(+), 119 deletions(-) create mode 100644 lib/services/elastic_layout_downloader.dart diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index c3a99a41..6d4a5133 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -20,6 +20,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import 'package:elastic_dashboard/services/app_distributor.dart'; +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; import 'package:elastic_dashboard/services/hotkey_manager.dart'; import 'package:elastic_dashboard/services/ip_address_util.dart'; import 'package:elastic_dashboard/services/log.dart'; @@ -66,6 +67,9 @@ class DashboardPage extends StatefulWidget { class _DashboardPageState extends State with WindowListener { late final SharedPreferences preferences = widget.preferences; late final RobotNotificationsListener _robotNotificationListener; + late final ElasticLayoutDownloader _layoutDownloader; + + bool _seenShuffleboardWarning = false; final List _tabData = []; @@ -164,6 +168,7 @@ class _DashboardPageState extends State with WindowListener { }); }, onTabCreated: (tab) { + _showShuffleboardWarningMessage(); if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; @@ -185,43 +190,47 @@ class _DashboardPageState extends State with WindowListener { )); }, onWidgetAdded: (widgetData) { + _showShuffleboardWarningMessage(); if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - // Needs to be done in case if widget data gets erased by the listener - Map widgetDataCopy = {}; - - widgetData.forEach( - (key, value) => widgetDataCopy.putIfAbsent(key, () => value)); + // Needs to be converted into the tab json format + Map tabJson = {}; - List tabNamesList = _tabData.map((data) => data.name).toList(); + String tabName = widgetData['tab']; + tabJson.addAll({'containers': >[]}); + tabJson.addAll({'layouts': >[]}); - String tabName = widgetDataCopy['tab']; + if (!(widgetData.containsKey('layout') && widgetData['layout'])) { + tabJson['containers']!.add(widgetData); + } else { + tabJson['layouts']!.add(widgetData); + } - if (!tabNamesList.contains(tabName)) { + if (!_tabData.any((tab) => tab.name == tabName)) { _tabData.add( TabData( name: tabName, - tabGrid: TabGridModel( + tabGrid: TabGridModel.fromJson( ntConnection: widget.ntConnection, preferences: widget.preferences, + jsonData: tabJson, + onJsonLoadingWarning: _showJsonLoadingWarning, onAddWidgetPressed: _displayAddWidgetDialog, ), ), ); - - tabNamesList.add(tabName); - } - - int tabIndex = tabNamesList.indexOf(tabName); - - if (tabIndex == -1) { - return; + } else { + _tabData + .firstWhere((tab) => tab.name == tabName) + .tabGrid + .mergeFromJson( + jsonData: tabJson, + onJsonLoadingWarning: _showJsonLoadingWarning, + ); } - _tabData[tabIndex].tabGrid.addWidgetFromTabJson(widgetDataCopy); - setState(() {}); }, ); @@ -268,6 +277,8 @@ class _DashboardPageState extends State with WindowListener { }); }); _robotNotificationListener.listen(); + + _layoutDownloader = ElasticLayoutDownloader(); } @override @@ -421,11 +432,13 @@ class _DashboardPageState extends State with WindowListener { await launchUrl(url); } }, - child: Text('Update', - style: textTheme.bodyMedium!.copyWith( - color: buttonTheme.colorScheme?.primary, - fontWeight: FontWeight.bold, - )), + child: Text( + 'Update', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.bold, + ), + ), ), ); @@ -609,6 +622,77 @@ class _DashboardPageState extends State with WindowListener { } } + bool _mergeLayoutFromJsonData(String jsonString) { + logger.info('Merging layout from json'); + + Map? jsonData = tryCast(jsonDecode(jsonString)); + + if (!_validateJsonData(jsonData)) { + return false; + } + + for (Map tabJson in jsonData!['tabs']) { + String tabName = tabJson['name']; + if (!_tabData.any((tab) => tab.name == tabName)) { + _tabData.add( + TabData( + name: tabName, + tabGrid: TabGridModel.fromJson( + ntConnection: widget.ntConnection, + preferences: widget.preferences, + jsonData: tabJson['grid_layout'], + onAddWidgetPressed: _displayAddWidgetDialog, + onJsonLoadingWarning: _showJsonLoadingWarning, + ), + ), + ); + } else { + TabGridModel existingTab = + _tabData.firstWhere((tab) => tab.name == tabName).tabGrid; + existingTab.mergeFromJson( + jsonData: tabJson['grid_layout'], + onJsonLoadingWarning: _showJsonLoadingWarning, + ); + } + } + + _showNotification( + title: 'Successfully Downloaded Layout', + message: 'Remote layout has been successfully downloaded and merged!', + color: const Color(0xff01CB67), + icon: const Icon(Icons.error, color: Color(0xff01CB67)), + width: 350, + ); + + setState(() {}); + + return true; + } + + void _loadLayoutFromRobot() async { + if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { + return; + } + + LayoutDownloadResponse response = await _layoutDownloader.downloadLayout( + ntConnection: widget.ntConnection, + preferences: preferences, + ); + + if (!response.successful) { + _showNotification( + title: 'Failed to Download Layout', + message: response.data, + color: const Color(0xffFE355C), + icon: const Icon(Icons.error, color: Color(0xffFE355C)), + width: 400, + ); + return; + } + + _mergeLayoutFromJsonData(response.data); + } + void _createDefaultTabs() { if (_tabData.isEmpty) { logger.info('Creating default Teleoperated and Autonomous tabs'); @@ -635,6 +719,58 @@ class _DashboardPageState extends State with WindowListener { } } + void _showShuffleboardWarningMessage() { + if (_seenShuffleboardWarning) { + return; + } + ColorScheme colorScheme = Theme.of(context).colorScheme; + TextTheme textTheme = Theme.of(context).textTheme; + ButtonThemeData buttonTheme = ButtonTheme.of(context); + + ElegantNotification notification = ElegantNotification( + autoDismiss: false, + background: colorScheme.surface, + showProgressIndicator: false, + width: 450, + height: 160, + position: Alignment.bottomRight, + icon: const Icon(Icons.warning, color: Colors.yellow), + action: TextButton( + onPressed: () async { + Uri url = Uri.parse( + 'https://frc-elastic.gitbook.io/docs/additional-features-and-references/shuffleboard-api-integration'); + + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: Text( + 'Documentation', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + title: Text( + 'Shuffleboard API Deprecation', + style: textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + description: const Text( + 'Support for the Shuffleboard API is deprecated in favor of remote layout downloading and will be removed after the 2025 season. See the documentation for more details about migration.', + overflow: TextOverflow.ellipsis, + maxLines: 4, + ), + ); + + if (mounted) { + notification.show(context); + } + _seenShuffleboardWarning = true; + } + void _showJsonLoadingError(String errorMessage) { logger.error(errorMessage); Future(() { @@ -728,6 +864,21 @@ class _DashboardPageState extends State with WindowListener { ), callback: _exportLayout, ); + // Download from robot (Ctrl + D) + hotKeyManager.register( + HotKey( + LogicalKeyboardKey.keyD, + modifiers: [KeyModifier.control], + ), + callback: () { + if (preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked) { + return; + } + + _loadLayoutFromRobot(); + }, + ); // Switch to Tab (Ctrl + Tab #) for (int i = 1; i <= 9; i++) { hotKeyManager.register( @@ -1457,18 +1608,42 @@ class _DashboardPageState extends State with WindowListener { ), // Export layout MenuItemButton( - style: menuButtonStyle, - onPressed: _exportLayout, - shortcut: const SingleActivator(LogicalKeyboardKey.keyS, - shift: true, control: true), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.save_as_outlined), - SizedBox(width: 8), - Text('Save As'), - ], - )), + style: menuButtonStyle, + onPressed: _exportLayout, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyS, + shift: true, + control: true, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.save_as_outlined), + SizedBox(width: 8), + Text('Save As'), + ], + ), + ), + // Download layout + MenuItemButton( + style: menuButtonStyle, + onPressed: !(preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked) + ? _loadLayoutFromRobot + : null, + shortcut: const SingleActivator( + LogicalKeyboardKey.keyD, + control: true, + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download), + SizedBox(width: 8), + Text('Download From Robot'), + ], + ), + ), ], child: const Text( 'File', diff --git a/lib/services/elastic_layout_downloader.dart b/lib/services/elastic_layout_downloader.dart new file mode 100644 index 00000000..c1daf7cd --- /dev/null +++ b/lib/services/elastic_layout_downloader.dart @@ -0,0 +1,45 @@ +import 'package:http/http.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/settings.dart'; + +typedef LayoutDownloadResponse = ({bool successful, String data}); + +class ElasticLayoutDownloader { + final Client client = Client(); + + Future downloadLayout({ + required NTConnection ntConnection, + required SharedPreferences preferences, + }) async { + if (!ntConnection.isNT4Connected) { + return ( + successful: false, + data: + 'Cannot download a remote layout while disconnected from the robot.' + ); + } + String robotIP = + preferences.getString(PrefKeys.ipAddress) ?? Defaults.ipAddress; + Uri robotUri = Uri.parse( + 'http://$robotIP:5800/elastic-layout.json', + ); + Response response; + try { + response = await client.get(robotUri); + } on ClientException catch (e) { + return (successful: false, data: e.message); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + String errorMessage = switch (response.statusCode) { + 404 => + 'File "elastic-layout.json" was not found, ensure that you have deployed a file named "elastic_layout.json" in the deploy directory', + _ => 'Request returned status code ${response.statusCode}', + }; + + return (successful: false, data: errorMessage); + } + return (successful: true, data: response.body); + } +} diff --git a/lib/widgets/tab_grid.dart b/lib/widgets/tab_grid.dart index 8ece0484..9d641292 100644 --- a/lib/widgets/tab_grid.dart +++ b/lib/widgets/tab_grid.dart @@ -34,10 +34,11 @@ class TabGridModel extends ChangeNotifier { final VoidCallback onAddWidgetPressed; - TabGridModel( - {required this.ntConnection, - required this.preferences, - required this.onAddWidgetPressed}); + TabGridModel({ + required this.ntConnection, + required this.preferences, + required this.onAddWidgetPressed, + }); TabGridModel.fromJson({ required this.ntConnection, @@ -47,8 +48,10 @@ class TabGridModel extends ChangeNotifier { Function(String message)? onJsonLoadingWarning, }) { if (jsonData['containers'] != null) { - loadContainersFromJson(jsonData, - onJsonLoadingWarning: onJsonLoadingWarning); + loadContainersFromJson( + jsonData, + onJsonLoadingWarning: onJsonLoadingWarning, + ); } if (jsonData['layouts'] != null) { @@ -60,6 +63,103 @@ class TabGridModel extends ChangeNotifier { } } + void mergeFromJson({ + required Map jsonData, + Function(String message)? onJsonLoadingWarning, + }) { + if (jsonData['containers'] != null) { + for (Map widgetData in jsonData['containers']) { + Rect newWidgetLocation = Rect.fromLTWH( + tryCast(widgetData['x']) ?? 0.0, + tryCast(widgetData['y']) ?? 0.0, + tryCast(widgetData['width']) ?? 0.0, + tryCast(widgetData['height']) ?? 0.0, + ); + + bool valid = true; + + for (NTWidgetContainerModel container + in _widgetModels.whereType()) { + String? title = container.title; + String? type = container.childModel.type; + String? topic = container.childModel.topic; + bool validLocation = isValidLocation(newWidgetLocation); + + if (title == widgetData['title'] && + type == widgetData['type'] && + topic == widgetData['properties']['topic'] || + !validLocation) { + valid = false; + break; + } + } + + if (valid) { + addWidget( + NTWidgetContainerModel.fromJson( + ntConnection: ntConnection, + jsonData: widgetData, + preferences: preferences, + enabled: ntConnection.isNT4Connected, + onJsonLoadingWarning: onJsonLoadingWarning, + ), + ); + } + } + } + + if (jsonData['layouts'] != null) { + for (Map widgetData in jsonData['layouts']) { + Rect newWidgetLocation = Rect.fromLTWH( + tryCast(widgetData['x']) ?? 0.0, + tryCast(widgetData['y']) ?? 0.0, + tryCast(widgetData['width']) ?? 0.0, + tryCast(widgetData['height']) ?? 0.0, + ); + + bool valid = true; + + for (LayoutContainerModel container + in _widgetModels.whereType()) { + String? title = container.title; + String type = container.type; + bool validLocation = isValidLocation(newWidgetLocation); + + if (title == widgetData['title'] && type == widgetData['type'] || + !validLocation) { + valid = false; + break; + } + } + + if (valid && widgetData['type'] == 'List Layout') { + addWidget( + ListLayoutModel.fromJson( + jsonData: widgetData, + preferences: preferences, + ntWidgetBuilder: (preferences, jsonData, enabled, + {onJsonLoadingWarning}) => + NTWidgetContainerModel.fromJson( + ntConnection: ntConnection, + jsonData: jsonData, + preferences: preferences, + onJsonLoadingWarning: onJsonLoadingWarning, + ), + enabled: ntConnection.isNT4Connected, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), + onDragCancel: _layoutContainerOnDragCancel, + minWidth: 128.0 * 2, + minHeight: 128.0 * 2, + ), + ); + } + } + } + } + void loadContainersFromJson(Map jsonData, {Function(String message)? onJsonLoadingWarning}) { for (Map containerData in jsonData['containers']) { @@ -560,83 +660,6 @@ class TabGridModel extends ChangeNotifier { notifyListeners(); } - void addWidgetFromTabJson(Map widgetData) { - Rect newWidgetLocation = Rect.fromLTWH( - tryCast(widgetData['x']) ?? 0.0, - tryCast(widgetData['y']) ?? 0.0, - tryCast(widgetData['width']) ?? 0.0, - tryCast(widgetData['height']) ?? 0.0, - ); - // If the widget is already in the tab, don't add it - if (!(widgetData.containsKey('layout') && widgetData['layout'])) { - for (NTWidgetContainerModel container - in _widgetModels.whereType()) { - String? title = container.title; - String? type = container.childModel.type; - String? topic = container.childModel.topic; - bool validLocation = isValidLocation(newWidgetLocation); - - if (title == widgetData['title'] && - type == widgetData['type'] && - topic == widgetData['properties']['topic'] && - !validLocation) { - return; - } - } - } else { - for (LayoutContainerModel container - in _widgetModels.whereType()) { - String? title = container.title; - String type = container.type; - bool validLocation = isValidLocation(newWidgetLocation); - - if (title == widgetData['title'] && - type == widgetData['type'] && - !validLocation) { - return; - } - } - } - - if (widgetData.containsKey('layout') && widgetData['layout']) { - switch (widgetData['type']) { - case 'List Layout': - addWidget( - ListLayoutModel.fromJson( - preferences: preferences, - jsonData: widgetData, - ntWidgetBuilder: (preferences, jsonData, enabled, - {onJsonLoadingWarning}) => - NTWidgetContainerModel.fromJson( - ntConnection: ntConnection, - jsonData: jsonData, - preferences: preferences, - onJsonLoadingWarning: onJsonLoadingWarning, - ), - enabled: ntConnection.isNT4Connected, - dragOutFunctions: ( - dragOutUpdate: layoutDragOutUpdate, - dragOutEnd: layoutDragOutEnd, - ), - onDragCancel: _layoutContainerOnDragCancel, - minWidth: 128.0 * 2, - minHeight: 128.0 * 2, - ), - ); - break; - } - } else { - addWidget( - NTWidgetContainerModel.fromJson( - ntConnection: ntConnection, - preferences: preferences, - enabled: ntConnection.isNT4Connected, - jsonData: widgetData, - ), - ); - } - } - void removeWidget(WidgetContainerModel widget) { widget.removeListener(notifyListeners); widget.disposeModel(deleting: true); From 66a882c415eb353f6a576349447acd4cd14099cd Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 15 Dec 2024 11:37:33 -0500 Subject: [PATCH 4/5] Update version to 2025.0.0-beta-5 (#157) --- lib/pages/dashboard_page.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 6d4a5133..55b3b6fe 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -738,7 +738,7 @@ class _DashboardPageState extends State with WindowListener { action: TextButton( onPressed: () async { Uri url = Uri.parse( - 'https://frc-elastic.gitbook.io/docs/additional-features-and-references/shuffleboard-api-integration'); + 'https://frc-elastic.gitbook.io/docs/additional-features-and-references/remote-layout-downloading#shuffleboard-api-migration-guide'); if (await canLaunchUrl(url)) { await launchUrl(url); diff --git a/pubspec.yaml b/pubspec.yaml index 8560f9b1..6c2a2fdb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: elastic_dashboard description: A simple and modern dashboard for FRC. publish_to: 'none' -version: 2025.0.0-beta-4 +version: 2025.0.0-beta-5 environment: sdk: '>=3.0.2 <4.0.0' From 08deead82acb831d1ae91a3113b8e113c5eefc47 Mon Sep 17 00:00:00 2001 From: Gold87 <91761103+Gold872@users.noreply.github.com> Date: Sun, 15 Dec 2024 16:57:38 -0500 Subject: [PATCH 5/5] Improvements to Remote Layout Downloading (#158) - Add menu to select which file to download from the robot - Write unit tests --- lib/pages/dashboard_page.dart | 90 +- lib/services/elastic_layout_downloader.dart | 66 +- lib/widgets/tab_grid.dart | 35 +- test/pages/dashboard_page_test.dart | 2823 ++++++++++------- .../elastic_layout_downloader_test.dart | 256 ++ test/test_util.dart | 16 + 6 files changed, 2101 insertions(+), 1185 deletions(-) create mode 100644 test/services/elastic_layout_downloader_test.dart diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 55b3b6fe..6ce6f336 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -12,6 +12,7 @@ import 'package:elegant_notification/elegant_notification.dart'; import 'package:elegant_notification/resources/stacked_options.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; +import 'package:http/http.dart'; import 'package:path/path.dart' as path; import 'package:popover/popover.dart'; import 'package:screen_retriever/screen_retriever.dart'; @@ -31,6 +32,7 @@ import 'package:elastic_dashboard/services/shuffleboard_nt_listener.dart'; import 'package:elastic_dashboard/services/update_checker.dart'; import 'package:elastic_dashboard/util/tab_data.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_toggle_switch.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/layout_drag_tile.dart'; @@ -47,6 +49,7 @@ class DashboardPage extends StatefulWidget { final NTConnection ntConnection; final SharedPreferences preferences; final UpdateChecker updateChecker; + final ElasticLayoutDownloader? layoutDownloader; final Function(Color color)? onColorChanged; final Function(FlexSchemeVariant variant)? onThemeVariantChanged; @@ -56,6 +59,7 @@ class DashboardPage extends StatefulWidget { required this.preferences, required this.version, required this.updateChecker, + this.layoutDownloader, this.onColorChanged, this.onThemeVariantChanged, }); @@ -65,7 +69,7 @@ class DashboardPage extends StatefulWidget { } class _DashboardPageState extends State with WindowListener { - late final SharedPreferences preferences = widget.preferences; + SharedPreferences get preferences => widget.preferences; late final RobotNotificationsListener _robotNotificationListener; late final ElasticLayoutDownloader _layoutDownloader; @@ -278,7 +282,8 @@ class _DashboardPageState extends State with WindowListener { }); _robotNotificationListener.listen(); - _layoutDownloader = ElasticLayoutDownloader(); + _layoutDownloader = + widget.layoutDownloader ?? ElasticLayoutDownloader(Client()); } @override @@ -669,14 +674,95 @@ class _DashboardPageState extends State with WindowListener { return true; } + Future _showRemoteLayoutSelection(List fileNames) async { + if (!mounted) { + return null; + } + ValueNotifier currentSelection = ValueNotifier(null); + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Select Layout'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: currentSelection, + builder: (_, value, child) => DialogDropdownChooser( + choices: fileNames, + initialValue: value, + onSelectionChanged: (selection) => + currentSelection.value = selection, + ), + ) + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + ValueListenableBuilder( + valueListenable: currentSelection, + builder: (_, value, child) => TextButton( + onPressed: (value != null) + ? () => Navigator.of(context).pop(value) + : null, + child: const Text('Download'), + ), + ), + ], + ), + ); + } + void _loadLayoutFromRobot() async { if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } + LayoutDownloadResponse> layoutsResponse = + await _layoutDownloader.getAvailableLayouts( + ntConnection: widget.ntConnection, + preferences: preferences, + ); + + if (!layoutsResponse.successful) { + _showNotification( + title: 'Failed to Retrieve Layout List', + message: layoutsResponse.data.firstOrNull ?? + 'Unable to retrieve list of available layouts', + color: const Color(0xffFE355C), + icon: const Icon(Icons.error, color: Color(0xffFE355C)), + width: 400, + ); + return; + } + + if (layoutsResponse.data.isEmpty) { + _showNotification( + title: 'Failed to Retrieve Layout List', + message: + 'No layouts were found, ensure a valid layout json file is placed in the root directory of your deploy directory.', + color: const Color(0xffFE355C), + icon: const Icon(Icons.error, color: Color(0xffFE355C)), + width: 400, + ); + return; + } + + String? selectedLayout = await _showRemoteLayoutSelection( + layoutsResponse.data.sorted((a, b) => a.compareTo(b)), + ); + + if (selectedLayout == null) { + return; + } + LayoutDownloadResponse response = await _layoutDownloader.downloadLayout( ntConnection: widget.ntConnection, preferences: preferences, + layoutName: selectedLayout, ); if (!response.successful) { diff --git a/lib/services/elastic_layout_downloader.dart b/lib/services/elastic_layout_downloader.dart index c1daf7cd..9dac4579 100644 --- a/lib/services/elastic_layout_downloader.dart +++ b/lib/services/elastic_layout_downloader.dart @@ -1,17 +1,23 @@ +import 'dart:convert'; + +import 'package:dot_cast/dot_cast.dart'; import 'package:http/http.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; import 'package:elastic_dashboard/services/settings.dart'; -typedef LayoutDownloadResponse = ({bool successful, String data}); +typedef LayoutDownloadResponse = ({bool successful, T data}); class ElasticLayoutDownloader { - final Client client = Client(); + final Client client; + + ElasticLayoutDownloader(this.client); - Future downloadLayout({ + Future> downloadLayout({ required NTConnection ntConnection, required SharedPreferences preferences, + required String layoutName, }) async { if (!ntConnection.isNT4Connected) { return ( @@ -22,8 +28,9 @@ class ElasticLayoutDownloader { } String robotIP = preferences.getString(PrefKeys.ipAddress) ?? Defaults.ipAddress; + String escapedName = Uri.encodeComponent('$layoutName.json'); Uri robotUri = Uri.parse( - 'http://$robotIP:5800/elastic-layout.json', + 'http://$robotIP:5800/$escapedName', ); Response response; try { @@ -33,8 +40,7 @@ class ElasticLayoutDownloader { } if (response.statusCode < 200 || response.statusCode >= 300) { String errorMessage = switch (response.statusCode) { - 404 => - 'File "elastic-layout.json" was not found, ensure that you have deployed a file named "elastic_layout.json" in the deploy directory', + 404 => 'File "$layoutName.json" was not found', _ => 'Request returned status code ${response.statusCode}', }; @@ -42,4 +48,52 @@ class ElasticLayoutDownloader { } return (successful: true, data: response.body); } + + Future>> getAvailableLayouts({ + required NTConnection ntConnection, + required SharedPreferences preferences, + }) async { + if (!ntConnection.isNT4Connected) { + return ( + successful: false, + data: [ + 'Cannot fetch remote layouts while disconnected from the robot' + ] + ); + } + String robotIP = + preferences.getString(PrefKeys.ipAddress) ?? Defaults.ipAddress; + Uri robotUri = Uri.parse( + 'http://$robotIP:5800/?format=json', + ); + Response response; + try { + response = await client.get(robotUri); + } on ClientException catch (e) { + print('Houston we have a problem\n\n ${e.message}'); + return (successful: false, data: [e.message]); + } + Map? responseJson = tryCast(jsonDecode(response.body)); + if (responseJson == null) { + return (successful: false, data: ['Response was not a json object']); + } + if (!responseJson.containsKey('files')) { + return ( + successful: false, + data: ['Response json does not contain files list'] + ); + } + + List fileNames = []; + for (Map fileData in responseJson['files']) { + String? name = fileData['name']; + if (name == null) { + continue; + } + if (name.endsWith('json')) { + fileNames.add(name.substring(0, name.length - '.json'.length)); + } + } + return (successful: true, data: fileNames); + } } diff --git a/lib/widgets/tab_grid.dart b/lib/widgets/tab_grid.dart index 9d641292..cfa23f6f 100644 --- a/lib/widgets/tab_grid.dart +++ b/lib/widgets/tab_grid.dart @@ -78,17 +78,22 @@ class TabGridModel extends ChangeNotifier { bool valid = true; - for (NTWidgetContainerModel container - in _widgetModels.whereType()) { + for (WidgetContainerModel container in _widgetModels) { String? title = container.title; - String? type = container.childModel.type; - String? topic = container.childModel.topic; + if (container is NTWidgetContainerModel) { + String? type = container.childModel.type; + String? topic = container.childModel.topic; + + if (title == widgetData['title'] && + type == widgetData['type'] && + topic == widgetData['properties']['topic']) { + valid = false; + break; + } + } bool validLocation = isValidLocation(newWidgetLocation); - if (title == widgetData['title'] && - type == widgetData['type'] && - topic == widgetData['properties']['topic'] || - !validLocation) { + if (!validLocation) { valid = false; break; } @@ -119,14 +124,18 @@ class TabGridModel extends ChangeNotifier { bool valid = true; - for (LayoutContainerModel container - in _widgetModels.whereType()) { + for (WidgetContainerModel container in _widgetModels) { String? title = container.title; - String type = container.type; + if (container is ListLayoutModel) { + String type = container.type; + if (title == widgetData['title'] && type == widgetData['type']) { + valid = false; + break; + } + } bool validLocation = isValidLocation(newWidgetLocation); - if (title == widgetData['title'] && type == widgetData['type'] || - !validLocation) { + if (!validLocation) { valid = false; break; } diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index b7316a88..6075a3b5 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -7,11 +7,13 @@ import 'package:flutter/services.dart'; import 'package:elegant_notification/elegant_notification.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'package:elastic_dashboard/pages/dashboard_page.dart'; +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; import 'package:elastic_dashboard/services/field_images.dart'; import 'package:elastic_dashboard/services/hotkey_manager.dart'; import 'package:elastic_dashboard/services/ip_address_util.dart'; @@ -19,6 +21,7 @@ import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; import 'package:elastic_dashboard/services/settings.dart'; import 'package:elastic_dashboard/widgets/custom_appbar.dart'; +import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_dropdown_chooser.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart'; import 'package:elastic_dashboard/widgets/dialog_widgets/layout_drag_tile.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/draggable_list_layout.dart'; @@ -27,9 +30,11 @@ import 'package:elastic_dashboard/widgets/draggable_dialog.dart'; import 'package:elastic_dashboard/widgets/editable_tab_bar.dart'; import 'package:elastic_dashboard/widgets/network_tree/networktables_tree.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/combo_box_chooser.dart'; +import 'package:elastic_dashboard/widgets/nt_widgets/multi-topic/gyro.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/single_topic/boolean_box.dart'; import 'package:elastic_dashboard/widgets/settings_dialog.dart'; import 'package:elastic_dashboard/widgets/tab_grid.dart'; +import '../services/elastic_layout_downloader_test.dart'; import '../test_util.dart'; import '../test_util.mocks.dart'; @@ -63,754 +68,1228 @@ void main() { hotKeyManager.tearDown(); }); - testWidgets('Dashboard page loading offline', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + group('[Loading and Saving]:', skip: true, () { + testWidgets('offline loading', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.textContaining('Network Tables: Disconnected'), findsOneWidget); - expect(find.textContaining('Network Tables: Connected'), findsNothing); - expect(find.textContaining('(10.3.53.2)'), findsNothing); - expect(find.text('Team 353'), findsOneWidget); + expect( + find.textContaining('Network Tables: Disconnected'), findsOneWidget); + expect(find.textContaining('Network Tables: Connected'), findsNothing); + expect(find.textContaining('(10.3.53.2)'), findsNothing); + expect(find.text('Team 353'), findsOneWidget); - expect(find.text('Teleoperated'), findsOneWidget); - expect(find.text('Autonomous'), findsOneWidget); - }); + expect(find.text('Teleoperated'), findsOneWidget); + expect(find.text('Autonomous'), findsOneWidget); + }); - testWidgets('Dashboard page loading online', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('online loading', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.textContaining('Network Tables: Disconnected'), findsNothing); - expect(find.textContaining('Network Tables: Connected'), findsWidgets); - expect(find.textContaining('(10.3.53.2)'), findsWidgets); - expect(find.text('Team 353'), findsOneWidget); + expect(find.textContaining('Network Tables: Disconnected'), findsNothing); + expect(find.textContaining('Network Tables: Connected'), findsWidgets); + expect(find.textContaining('(10.3.53.2)'), findsWidgets); + expect(find.text('Team 353'), findsOneWidget); - expect(find.text('Teleoperated'), findsOneWidget); - expect(find.text('Autonomous'), findsOneWidget); - }); + expect(find.text('Teleoperated'), findsOneWidget); + expect(find.text('Autonomous'), findsOneWidget); + }); - testWidgets('Save layout (button)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Save layout (button)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final fileButton = find.widgetWithText(SubmenuButton, 'File'); + final fileButton = find.widgetWithText(SubmenuButton, 'File'); - expect(fileButton, findsOneWidget); + expect(fileButton, findsOneWidget); - await widgetTester.tap(fileButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(fileButton); + await widgetTester.pumpAndSettle(); - final saveButton = find.widgetWithText(MenuItemButton, 'Save'); + final saveButton = find.widgetWithText(MenuItemButton, 'Save'); - expect(saveButton, findsOneWidget); + expect(saveButton, findsOneWidget); - await widgetTester.tap(saveButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(saveButton); + await widgetTester.pumpAndSettle(); - expect(jsonString, preferences.getString(PrefKeys.layout)); - }); + expect(jsonString, preferences.getString(PrefKeys.layout)); + }); - testWidgets('Save layout (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Save layout (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyS); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyS); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyS); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyS); + await widgetTester.pumpAndSettle(); - expect(jsonString, preferences.getString(PrefKeys.layout)); + expect(jsonString, preferences.getString(PrefKeys.layout)); + }); }); - testWidgets('Add widget dialog search', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + group('[Adding Widgets]:', () { + testWidgets('Add widget dialog search', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - // widgetTester.tap() doesn't work :shrug: - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + // widgetTester.tap() doesn't work :shrug: + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final smartDashboardTile = find.widgetWithText(TreeTile, 'SmartDashboard'); + final smartDashboardTile = + find.widgetWithText(TreeTile, 'SmartDashboard'); - expect(smartDashboardTile, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); - await widgetTester.tap(smartDashboardTile); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(smartDashboardTile); + await widgetTester.pumpAndSettle(); - final searchQuery = find.widgetWithText(DialogTextInput, 'Search'); - expect(searchQuery, findsOneWidget); + final searchQuery = find.widgetWithText(DialogTextInput, 'Search'); + expect(searchQuery, findsOneWidget); - final testValueOne = find.widgetWithText(TreeTile, 'Test Value 1'); - final testValueTwo = find.widgetWithText(TreeTile, 'Test Value 2'); + final testValueOne = find.widgetWithText(TreeTile, 'Test Value 1'); + final testValueTwo = find.widgetWithText(TreeTile, 'Test Value 2'); - expect(testValueOne, findsOneWidget); - expect(testValueTwo, findsOneWidget); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); - // Both match - await widgetTester.enterText(searchQuery, 'Test Value'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); + // Both match + await widgetTester.enterText(searchQuery, 'Test Value'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(testValueOne, findsOneWidget); - expect(testValueTwo, findsOneWidget); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); - // One match - await widgetTester.enterText(searchQuery, 'Test Value 1'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); + // One match + await widgetTester.enterText(searchQuery, 'Test Value 1'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(testValueOne, findsOneWidget); - expect(testValueTwo, findsNothing); - expect(smartDashboardTile, findsOneWidget); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsNothing); + expect(smartDashboardTile, findsOneWidget); - // No matches - await widgetTester.enterText(searchQuery, 'no match'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); + // No matches + await widgetTester.enterText(searchQuery, 'no match'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(testValueOne, findsNothing); - expect(testValueTwo, findsNothing); - expect(smartDashboardTile, findsNothing); + expect(testValueOne, findsNothing); + expect(testValueTwo, findsNothing); + expect(smartDashboardTile, findsNothing); - // Match only smart dashboard tile (all should show) - await widgetTester.enterText(searchQuery, 'Smart'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); + // Match only smart dashboard tile (all should show) + await widgetTester.enterText(searchQuery, 'Smart'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(testValueOne, findsOneWidget); - expect(testValueTwo, findsOneWidget); - expect(smartDashboardTile, findsOneWidget); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); - // Empty text (both should be visible) - await widgetTester.enterText(searchQuery, ''); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); + // Empty text (both should be visible) + await widgetTester.enterText(searchQuery, ''); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(testValueOne, findsOneWidget); - expect(testValueTwo, findsOneWidget); - expect(smartDashboardTile, findsOneWidget); - }); + expect(testValueOne, findsOneWidget); + expect(testValueTwo, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); + }); - testWidgets('Add widget dialog (widgets)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Add widget dialog (widgets)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final smartDashboardTile = find.widgetWithText(TreeTile, 'SmartDashboard'); + final smartDashboardTile = + find.widgetWithText(TreeTile, 'SmartDashboard'); - expect(smartDashboardTile, findsOneWidget); + expect(smartDashboardTile, findsOneWidget); - await widgetTester.tap(smartDashboardTile); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(smartDashboardTile); + await widgetTester.pumpAndSettle(); - final testValueTile = find.widgetWithText(TreeTile, 'Test Value 1'); - final testValueContainer = - find.widgetWithText(WidgetContainer, 'Test Value 1'); + final testValueTile = find.widgetWithText(TreeTile, 'Test Value 1'); + final testValueContainer = + find.widgetWithText(WidgetContainer, 'Test Value 1'); - expect(testValueTile, findsOneWidget); - expect(find.widgetWithText(TreeTile, 'Test Value 2'), findsOneWidget); + expect(testValueTile, findsOneWidget); + expect(find.widgetWithText(TreeTile, 'Test Value 2'), findsOneWidget); - await widgetTester.drag(testValueTile, const Offset(100, 100), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.drag(testValueTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - expect(testValueContainer, findsNothing); + expect(testValueContainer, findsNothing); - await widgetTester.drag(testValueTile, const Offset(300, -150), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.drag(testValueTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - expect(testValueContainer, findsOneWidget); + expect(testValueContainer, findsOneWidget); - final dialogDragHandle = find.byIcon(Icons.drag_handle); + final dialogDragHandle = find.byIcon(Icons.drag_handle); - expect(dialogDragHandle, findsOneWidget); + expect(dialogDragHandle, findsOneWidget); - await widgetTester.drag(dialogDragHandle, const Offset(100, 0)); - await widgetTester.pumpAndSettle(); - }); + await widgetTester.drag(dialogDragHandle, const Offset(100, 0)); + await widgetTester.pumpAndSettle(); + }); - testWidgets('Add widget dialog (layouts)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Add widget dialog (layouts)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); - - await widgetTester.pumpAndSettle(); + ); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + await widgetTester.pumpAndSettle(); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - addWidgetButton.onPressed?.call(); + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - await widgetTester.pumpAndSettle(); + addWidgetButton.onPressed?.call(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + await widgetTester.pumpAndSettle(); - final layoutsTab = find.text('Layouts'); - expect(layoutsTab, findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - await widgetTester.tap(layoutsTab); - await widgetTester.pumpAndSettle(); + final layoutsTab = find.text('Layouts'); + expect(layoutsTab, findsOneWidget); - final listLayoutContainer = - find.widgetWithText(WidgetContainer, 'List Layout'); - expect(listLayoutContainer, findsNothing); + await widgetTester.tap(layoutsTab); + await widgetTester.pumpAndSettle(); - final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); - expect(listLayoutTile, findsOneWidget); + final listLayoutContainer = + find.widgetWithText(WidgetContainer, 'List Layout'); + expect(listLayoutContainer, findsNothing); - await widgetTester.drag(listLayoutTile, const Offset(100, 100), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); + expect(listLayoutTile, findsOneWidget); - expect(listLayoutContainer, findsNothing); + await widgetTester.drag(listLayoutTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - await widgetTester.drag(listLayoutTile, const Offset(300, -150), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + expect(listLayoutContainer, findsNothing); - expect(listLayoutContainer, findsOneWidget); - }); + await widgetTester.drag(listLayoutTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - testWidgets('Add widget dialog (list layout sub-table)', - (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + expect(listLayoutContainer, findsOneWidget); + }); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4( - virtualTopics: [ - NT4Topic( - name: '/Non-Typed/Value 1', - type: NT4TypeStr.kInt, - properties: {}, - ), - NT4Topic( - name: '/Non-Typed/Value 2', - type: NT4TypeStr.kInt, - properties: {}, - ), - NT4Topic( - name: '/Non-Typed/Value 3', - type: NT4TypeStr.kInt, - properties: {}, - ), - ], + testWidgets('Add widget dialog (list layout sub-table)', + (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Non-Typed/Value 1', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Typed/Value 2', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Typed/Value 3', + type: NT4TypeStr.kInt, + properties: {}, + ), + ], + ), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), ), - ), - ); - - await widgetTester.pumpAndSettle(); + ); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + await widgetTester.pumpAndSettle(); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - addWidgetButton.onPressed?.call(); + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - await widgetTester.pumpAndSettle(); + addWidgetButton.onPressed?.call(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + await widgetTester.pumpAndSettle(); - final nonTypedTile = find.widgetWithText(TreeTile, 'Non-Typed'); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - expect(nonTypedTile, findsOneWidget); + final nonTypedTile = find.widgetWithText(TreeTile, 'Non-Typed'); - final nonTypedContainer = find.widgetWithText(WidgetContainer, 'Non-Typed'); - expect(nonTypedContainer, findsNothing); + expect(nonTypedTile, findsOneWidget); - await widgetTester.drag( - nonTypedTile, - const Offset(250, 0), - kind: PointerDeviceKind.mouse, - ); - await widgetTester.pumpAndSettle(); + final nonTypedContainer = + find.widgetWithText(WidgetContainer, 'Non-Typed'); + expect(nonTypedContainer, findsNothing); - expect(nonTypedContainer, findsOneWidget); - }); + await widgetTester.drag( + nonTypedTile, + const Offset(250, 0), + kind: PointerDeviceKind.mouse, + ); + await widgetTester.pumpAndSettle(); - testWidgets('Add widget dialog (unregistered sendable)', - (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + expect(nonTypedContainer, findsOneWidget); + }); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4( - virtualTopics: [ - NT4Topic( - name: '/Non-Registered/.type', - type: NT4TypeStr.kString, - properties: {}, - ), - NT4Topic( - name: '/Non-Registered/Value 1', - type: NT4TypeStr.kInt, - properties: {}, - ), - NT4Topic( - name: '/Non-Registered/Value 2', - type: NT4TypeStr.kInt, - properties: {}, - ), - NT4Topic( - name: '/Non-Registered/Value 3', - type: NT4TypeStr.kInt, - properties: {}, - ), - ], - virtualValues: { - '/Non-Registered/.type': 'Non Registered Type', - }, + testWidgets('Add widget dialog (unregistered sendable)', + (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Non-Registered/.type', + type: NT4TypeStr.kString, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 1', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 2', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 3', + type: NT4TypeStr.kInt, + properties: {}, + ), + ], + virtualValues: { + '/Non-Registered/.type': 'Non Registered Type', + }, + ), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final nonRegisteredTile = find.widgetWithText(TreeTile, 'Non-Registered'); + final nonRegisteredTile = find.widgetWithText(TreeTile, 'Non-Registered'); - expect(nonRegisteredTile, findsOneWidget); + expect(nonRegisteredTile, findsOneWidget); - final nonRegistered = - find.widgetWithText(WidgetContainer, 'Non-Registered'); - expect(nonRegistered, findsNothing); + final nonRegistered = + find.widgetWithText(WidgetContainer, 'Non-Registered'); + expect(nonRegistered, findsNothing); - await widgetTester.drag( - nonRegisteredTile, - const Offset(250, 0), - kind: PointerDeviceKind.mouse, - ); - await widgetTester.pumpAndSettle(); + await widgetTester.drag( + nonRegisteredTile, + const Offset(250, 0), + kind: PointerDeviceKind.mouse, + ); + await widgetTester.pumpAndSettle(); - expect(nonRegistered, findsOneWidget); - }); + expect(nonRegistered, findsOneWidget); + }); - testWidgets('List Layouts', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('List Layouts', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOnlineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); - expect(addWidget, findsOneWidget); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); - MenuItemButton addWidgetButton = - addWidget.evaluate().first.widget as MenuItemButton; + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; - addWidgetButton.onPressed?.call(); + addWidgetButton.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + expect( + find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); - final layoutsTab = find.text('Layouts'); - expect(layoutsTab, findsOneWidget); + final layoutsTab = find.text('Layouts'); + expect(layoutsTab, findsOneWidget); - await widgetTester.tap(layoutsTab); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(layoutsTab); + await widgetTester.pumpAndSettle(); - final listLayoutContainer = - find.widgetWithText(WidgetContainer, 'List Layout'); - expect(listLayoutContainer, findsNothing); + final listLayoutContainer = + find.widgetWithText(WidgetContainer, 'List Layout'); + expect(listLayoutContainer, findsNothing); - final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); - expect(listLayoutTile, findsOneWidget); + final listLayoutTile = find.widgetWithText(LayoutDragTile, 'List Layout'); + expect(listLayoutTile, findsOneWidget); - await widgetTester.drag(listLayoutTile, const Offset(100, 100), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.drag(listLayoutTile, const Offset(100, 100), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - expect(listLayoutContainer, findsNothing); + expect(listLayoutContainer, findsNothing); - await widgetTester.drag(listLayoutTile, const Offset(300, -150), - kind: PointerDeviceKind.mouse); - await widgetTester.pumpAndSettle(); + await widgetTester.drag(listLayoutTile, const Offset(300, -150), + kind: PointerDeviceKind.mouse); + await widgetTester.pumpAndSettle(); - expect(listLayoutContainer, findsOneWidget); + expect(listLayoutContainer, findsOneWidget); - final closeButton = find.widgetWithText(TextButton, 'Close'); - expect(closeButton, findsOneWidget); + final closeButton = find.widgetWithText(TextButton, 'Close'); + expect(closeButton, findsOneWidget); - await widgetTester.tap(closeButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(closeButton); + await widgetTester.pumpAndSettle(); - final listLayout = find.ancestor( - of: find.widgetWithText(WidgetContainer, 'List Layout'), - matching: find.byType(DraggableListLayout)); - expect(listLayout, findsOneWidget); + final listLayout = find.ancestor( + of: find.widgetWithText(WidgetContainer, 'List Layout'), + matching: find.byType(DraggableListLayout)); + expect(listLayout, findsOneWidget); - final testBooleanContainer = - find.widgetWithText(WidgetContainer, 'Test Boolean'); - expect(testBooleanContainer, findsOneWidget); + final testBooleanContainer = + find.widgetWithText(WidgetContainer, 'Test Boolean'); + expect(testBooleanContainer, findsOneWidget); - final testBooleanInLayout = find.descendant( - of: find.widgetWithText(WidgetContainer, 'List Layout'), - matching: find.byType(BooleanBox)); + final testBooleanInLayout = find.descendant( + of: find.widgetWithText(WidgetContainer, 'List Layout'), + matching: find.byType(BooleanBox)); - expect(testBooleanInLayout, findsNothing); + expect(testBooleanInLayout, findsNothing); - // Drag into layout - await widgetTester.timedDrag(testBooleanContainer, const Offset(250, 32), - const Duration(milliseconds: 500)); - await widgetTester.pumpAndSettle(); + // Drag into layout + await widgetTester.timedDrag(testBooleanContainer, const Offset(250, 32), + const Duration(milliseconds: 500)); + await widgetTester.pumpAndSettle(); - expect(testBooleanInLayout, findsOneWidget); + expect(testBooleanInLayout, findsOneWidget); - // Drag out of layout - await widgetTester.timedDrag(testBooleanInLayout, const Offset(-200, -60), - const Duration(milliseconds: 500)); - await widgetTester.pumpAndSettle(); + // Drag out of layout + await widgetTester.timedDrag(testBooleanInLayout, const Offset(-200, -60), + const Duration(milliseconds: 500)); + await widgetTester.pumpAndSettle(); - expect(testBooleanInLayout, findsNothing); + expect(testBooleanInLayout, findsNothing); + }); }); - testWidgets('Adding widgets from shuffleboard api', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + group('Shuffleboard API', () { + testWidgets('adding widgets', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - List fakeAnnounceCallbacks = []; + List fakeAnnounceCallbacks = []; - // A custom mock is set up to reproduce behavior when actually running - final mockNT4Connection = MockNTConnection(); - final mockSubscription = MockNT4Subscription(); + // A custom mock is set up to reproduce behavior when actually running + final mockNT4Connection = MockNTConnection(); + final mockSubscription = MockNT4Subscription(); - when(mockNT4Connection.isNT4Connected).thenReturn(true); - when(mockNT4Connection.ntConnected).thenReturn(ValueNotifier(true)); - when(mockNT4Connection.connectionStatus()) - .thenAnswer((_) => Stream.value(true)); - when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); + when(mockNT4Connection.isNT4Connected).thenReturn(true); + when(mockNT4Connection.ntConnected).thenReturn(ValueNotifier(true)); + when(mockNT4Connection.connectionStatus()) + .thenAnswer((_) => Stream.value(true)); + when(mockNT4Connection.latencyStream()) + .thenAnswer((_) => Stream.value(0)); - when(mockSubscription.periodicStream()) - .thenAnswer((_) => Stream.value(null)); + when(mockSubscription.periodicStream()) + .thenAnswer((_) => Stream.value(null)); - when(mockSubscription.listen(any)).thenAnswer((realInvocation) {}); + when(mockSubscription.listen(any)).thenAnswer((realInvocation) {}); - when(mockNT4Connection.addTopicAnnounceListener(any)) - .thenAnswer((realInvocation) { - fakeAnnounceCallbacks.add(realInvocation.positionalArguments[0]); - }); + when(mockNT4Connection.addTopicAnnounceListener(any)) + .thenAnswer((realInvocation) { + fakeAnnounceCallbacks.add(realInvocation.positionalArguments[0]); + }); - when(mockNT4Connection.getLastAnnouncedValue(any)).thenReturn(null); + when(mockNT4Connection.getLastAnnouncedValue(any)).thenReturn(null); - when(mockNT4Connection.subscribe(any, any)).thenReturn(mockSubscription); + when(mockNT4Connection.subscribe(any, any)).thenReturn(mockSubscription); - when(mockNT4Connection.subscribe(any)).thenReturn(mockSubscription); + when(mockNT4Connection.subscribe(any)).thenReturn(mockSubscription); - when(mockNT4Connection.subscribeAll(any, any)).thenReturn(mockSubscription); + when(mockNT4Connection.subscribeAll(any, any)) + .thenReturn(mockSubscription); - when(mockNT4Connection.subscribeAll(any)).thenReturn(mockSubscription); + when(mockNT4Connection.subscribeAll(any)).thenReturn(mockSubscription); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position')) - .thenAnswer((realInvocation) => Future.value([2.0, 0.0])); + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position')) + .thenAnswer((realInvocation) => Future.value([2.0, 0.0])); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size')) - .thenAnswer((realInvocation) => Future.value([2.0, 2.0])); + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size')) + .thenAnswer((realInvocation) => Future.value([2.0, 2.0])); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position')) - .thenAnswer((realInvocation) => Future.value([0.0, 0.0])); + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position')) + .thenAnswer((realInvocation) => Future.value([0.0, 0.0])); - when(mockNT4Connection.subscribeAndRetrieveData>( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size')) - .thenAnswer((realInvocation) => Future.value([2.0, 3.0])); + when(mockNT4Connection.subscribeAndRetrieveData>( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size')) + .thenAnswer((realInvocation) => Future.value([2.0, 3.0])); - when(mockNT4Connection.subscribeAndRetrieveData( - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent')) - .thenAnswer((realInvocation) => Future.value('List Layout')); + when(mockNT4Connection.subscribeAndRetrieveData( + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent')) + .thenAnswer((realInvocation) => Future.value('List Layout')); - when(mockNT4Connection.subscribeAndRetrieveData( - '/Shuffleboard/Test-Tab/Shuffleboard Test Layout/.type')) - .thenAnswer((realInvocation) => Future.value('ShuffleboardLayout')); + when(mockNT4Connection.subscribeAndRetrieveData( + '/Shuffleboard/Test-Tab/Shuffleboard Test Layout/.type')) + .thenAnswer((realInvocation) => Future.value('ShuffleboardLayout')); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: mockNT4Connection, - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: mockNT4Connection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); - await widgetTester.pumpAndSettle(); + ); + await widgetTester.pumpAndSettle(); + + await widgetTester.runAsync(() async { + for (final callback in fakeAnnounceCallbacks) { + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: '/Shuffleboard/Test-Tab/Shuffleboard Test Number', + type: NT4TypeStr.kInt, + properties: {}, + )); + + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size', + type: NT4TypeStr.kFloat32Arr, + properties: {}, + )); + callback.call(NT4Topic( + name: + '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent', + type: NT4TypeStr.kString, + properties: {}, + )); + callback.call(NT4Topic( + name: '/Shuffleboard/Test-Tab/Shuffleboard Test Layout', + type: NT4TypeStr.kInt, + properties: {}, + )); + } - await widgetTester.runAsync(() async { - for (final callback in fakeAnnounceCallbacks) { - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Position', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Number/Size', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: '/Shuffleboard/Test-Tab/Shuffleboard Test Number', - type: NT4TypeStr.kInt, - properties: {}, - )); - - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Position', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/Size', - type: NT4TypeStr.kFloat32Arr, - properties: {}, - )); - callback.call(NT4Topic( - name: - '/Shuffleboard/.metadata/Test-Tab/Shuffleboard Test Layout/PreferredComponent', - type: NT4TypeStr.kString, - properties: {}, - )); - callback.call(NT4Topic( - name: '/Shuffleboard/Test-Tab/Shuffleboard Test Layout', - type: NT4TypeStr.kInt, - properties: {}, - )); - } - - // Gives enough time for the widgets to be placed automatically - // It has to be done this way since the listener runs the functions asynchronously - await Future.delayed(const Duration(seconds: 3)); + // Gives enough time for the widgets to be placed automatically + // It has to be done this way since the listener runs the functions asynchronously + await Future.delayed(const Duration(seconds: 3)); + }); + + await widgetTester.pumpAndSettle(); + + expect( + find.widgetWithText(AnimatedContainer, 'Test-Tab'), findsOneWidget); + expect( + find.widgetWithText(WidgetContainer, 'Shuffleboard Test Number', + skipOffstage: false), + findsOneWidget); + expect( + find.widgetWithText(WidgetContainer, 'Shuffleboard Test Layout', + skipOffstage: false), + findsOneWidget); + expect(find.bySubtype(skipOffstage: false), + findsOneWidget); }); - await widgetTester.pumpAndSettle(); + testWidgets('switching tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + NTConnection ntConnection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Shuffleboard/.metadata/Selected', + type: NT4TypeStr.kString, + properties: {}, + ), + ], + ); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: ntConnection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final editableTabBar = find.byType(EditableTabBar); + + expect(editableTabBar, findsOneWidget); + + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); + + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', 'Autonomous'); + + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1); - expect(find.widgetWithText(AnimatedContainer, 'Test-Tab'), findsOneWidget); - expect( - find.widgetWithText(WidgetContainer, 'Shuffleboard Test Number', - skipOffstage: false), - findsOneWidget); - expect( - find.widgetWithText(WidgetContainer, 'Shuffleboard Test Layout', - skipOffstage: false), - findsOneWidget); - expect(find.bySubtype(skipOffstage: false), - findsOneWidget); + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', 'Random Name'); + + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should not change since selected tab doesn\'t exist'); + + ntConnection.updateDataFromTopicName( + '/Shuffleboard/.metadata/Selected', '0'); + + await widgetTester.pumpAndSettle(); + + expect(editableTabBarWidget().currentIndex, 0); + }); }); - testWidgets('Switch tabs from Shuffleboard api', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + group('[Remote Layouts]:', () { + setUp(() async { + SharedPreferences.setMockInitialValues({ + PrefKeys.ipAddress: '127.0.0.1', + PrefKeys.layoutLocked: false, + }); - NTConnection ntConnection = createMockOnlineNT4( - virtualTopics: [ - NT4Topic( - name: '/Shuffleboard/.metadata/Selected', - type: NT4TypeStr.kString, - properties: {}, - ), - ], - ); + preferences = await SharedPreferences.getInstance(); + }); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: ntConnection, - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + testWidgets('Shows list of layouts', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200) + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final editableTabBar = find.byType(EditableTabBar); + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); - expect(editableTabBar, findsOneWidget); + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); - ntConnection.updateDataFromTopicName( - '/Shuffleboard/.metadata/Selected', 'Autonomous'); + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpAndSettle(); + expect(find.text('elastic-layout 1'), findsOneWidget); + expect(find.text('elastic-layout 2'), findsOneWidget); + }); + + group('Download layout', () { + testWidgets('without merges', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); + + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); + + expect(find.text('elastic-layout 1'), findsOneWidget); + + await widgetTester.tap(find.text('elastic-layout 1')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download'), findsOneWidget); + await widgetTester.tap(find.text('Download')); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, 'Successfully Downloaded Layout'), + findsOneWidget); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Tab'), findsOneWidget); + await widgetTester.tap(find.text('Test Tab')); + await widgetTester.pumpAndSettle(); + + expect(find.byType(Gyro), findsNWidgets(2)); + }); + + testWidgets('with merges', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + SharedPreferences.setMockInitialValues({ + PrefKeys.layout: jsonEncode({ + 'version': 1.0, + 'grid_size': 128.0, + 'tabs': [ + { + 'name': 'Test Tab', + 'grid_layout': { + 'layouts': [], + 'containers': [ + { + 'title': 'Blocking Widget', + 'x': 384.0, + 'y': 128.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'Text Display', + 'properties': { + 'topic': '/Test Tab/Blocking Widget', + 'period': 0.06, + }, + } + ], + }, + }, + ], + }), + PrefKeys.ipAddress: '127.0.0.1', + }); + + SharedPreferences preferences = await SharedPreferences.getInstance(); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); - expect(editableTabBarWidget().currentIndex, 1); + await widgetTester.pumpAndSettle(); - ntConnection.updateDataFromTopicName( - '/Shuffleboard/.metadata/Selected', 'Random Name'); + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpAndSettle(); + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should not change since selected tab doesn\'t exist'); + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); - ntConnection.updateDataFromTopicName( - '/Shuffleboard/.metadata/Selected', '0'); + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpAndSettle(); + expect(find.text('elastic-layout 1'), findsOneWidget); + + await widgetTester.tap(find.text('elastic-layout 1')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download'), findsOneWidget); + await widgetTester.tap(find.text('Download')); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, 'Successfully Downloaded Layout'), + findsOneWidget); + + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); + expect(find.text('Test Tab'), findsOneWidget); + await widgetTester.tap(find.text('Test Tab')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Blocking Widget'), findsOneWidget); + expect(find.byType(Gyro), findsOneWidget); + }); + }); + + group('Shows error when', () { + testWidgets('network tables is disconnected', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient(); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'Cannot fetch remote layouts while disconnected from the robot', + ), + findsOneWidget); + }); + + testWidgets('layout fetching is not a json', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': Response('[1, 2, 3]', 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'Response was not a json object', + ), + findsOneWidget); + }); + + testWidgets('layout json does not list files', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': Response('{}', 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'Response json does not contain files list', + ), + findsOneWidget); + }); + + testWidgets('layout json has empty files list', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode({'files': []}), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'No layouts were found, ensure a valid layout json file is placed in the root directory of your deploy directory.', + ), + findsOneWidget); + }); + + testWidgets('selected file was not found', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response('', 404), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + layoutDownloader: layoutDownloader, + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + expect(find.text('File'), findsOneWidget); + await widgetTester.tap(find.text('File')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download From Robot'), findsOneWidget); + await widgetTester.tap(find.text('Download From Robot')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Select Layout'), findsOneWidget); + expect(find.byType(DialogDropdownChooser), findsOneWidget); + + await widgetTester.tap(find.byType(DialogDropdownChooser)); + await widgetTester.pumpAndSettle(); + + expect(find.text('elastic-layout 1'), findsOneWidget); + + await widgetTester.tap(find.text('elastic-layout 1')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Download'), findsOneWidget); + await widgetTester.tap(find.text('Download')); + await widgetTester.pump(Duration.zero); + await widgetTester.pump(Duration.zero); + + expect( + find.widgetWithText( + ElegantNotification, + 'File "elastic-layout 1.json" was not found', + ), + findsOneWidget); + }); + }); }); testWidgets('About dialog', (widgetTester) async { @@ -846,876 +1325,892 @@ void main() { expect(find.byType(AboutDialog), findsOneWidget); }); - testWidgets('Changing tabs', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + group('[Tab Manipulation]:', () { + testWidgets('Changing tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(ComboBoxChooser), findsNothing); + expect(find.byType(ComboBoxChooser), findsNothing); - expect(find.byType(EditableTabBar), findsOneWidget); + expect(find.byType(EditableTabBar), findsOneWidget); - final autonomousTab = find.widgetWithText(AnimatedContainer, 'Autonomous'); + final autonomousTab = + find.widgetWithText(AnimatedContainer, 'Autonomous'); - expect(autonomousTab, findsOneWidget); + expect(autonomousTab, findsOneWidget); - await widgetTester.tap(autonomousTab); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(autonomousTab); + await widgetTester.pumpAndSettle(); - expect(find.byType(ComboBoxChooser), findsOneWidget); - }); + expect(find.byType(ComboBoxChooser), findsOneWidget); + }); - testWidgets('Creating new tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Creating new tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - expect(find.byType(EditableTabBar), findsOneWidget); + expect(find.byType(EditableTabBar), findsOneWidget); - final createNewTabButton = find.descendant( - of: find.byType(EditableTabBar), matching: find.byIcon(Icons.add)); + final createNewTabButton = find.descendant( + of: find.byType(EditableTabBar), matching: find.byIcon(Icons.add)); - expect(createNewTabButton, findsOneWidget); + expect(createNewTabButton, findsOneWidget); - await widgetTester.tap(createNewTabButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(createNewTabButton); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); + }); - testWidgets('Creating new tab (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Creating new tab (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyT); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyT); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyT); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyT); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(3)); + }); - testWidgets('Closing tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Closing tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - expect(find.byType(EditableTabBar), findsOneWidget); + expect(find.byType(EditableTabBar), findsOneWidget); - final closeTabButton = find - .descendant( - of: find.byType(EditableTabBar), matching: find.byIcon(Icons.close)) - .last; + final closeTabButton = find + .descendant( + of: find.byType(EditableTabBar), + matching: find.byIcon(Icons.close)) + .last; - expect(closeTabButton, findsOneWidget); + expect(closeTabButton, findsOneWidget); - await widgetTester.tap(closeTabButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(closeTabButton); + await widgetTester.pumpAndSettle(); - expect(find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); + expect( + find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); - final confirmButton = - find.widgetWithText(TextButton, 'OK', skipOffstage: false); + final confirmButton = + find.widgetWithText(TextButton, 'OK', skipOffstage: false); - expect(confirmButton, findsOneWidget); + expect(confirmButton, findsOneWidget); - await widgetTester.tap(confirmButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(confirmButton); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); + }); - testWidgets('Closing tab (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Closing tab (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyW); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyW); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyW); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyW); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); + expect( + find.text('Confirm Tab Close', skipOffstage: false), findsOneWidget); - final confirmButton = - find.widgetWithText(TextButton, 'OK', skipOffstage: false); + final confirmButton = + find.widgetWithText(TextButton, 'OK', skipOffstage: false); - expect(confirmButton, findsOneWidget); + expect(confirmButton, findsOneWidget); - await widgetTester.tap(confirmButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(confirmButton); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); - }); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(1)); + }); - testWidgets('Reordering tabs', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Reordering tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - final editableTabBar = find.byType(EditableTabBar); + final editableTabBar = find.byType(EditableTabBar); - expect(editableTabBar, findsOneWidget); + expect(editableTabBar, findsOneWidget); - final tabLeftButton = - find.descendant(of: editableTabBar, matching: find.byIcon(Icons.west)); - final tabRightButton = - find.descendant(of: editableTabBar, matching: find.byIcon(Icons.east)); + final tabLeftButton = find.descendant( + of: editableTabBar, matching: find.byIcon(Icons.west)); + final tabRightButton = find.descendant( + of: editableTabBar, matching: find.byIcon(Icons.east)); - expect(tabLeftButton, findsOneWidget); - expect(tabRightButton, findsOneWidget); + expect(tabLeftButton, findsOneWidget); + expect(tabRightButton, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); - expect(editableTabBarWidget().currentIndex, 0); + expect(editableTabBarWidget().currentIndex, 0); - await widgetTester.tap(tabLeftButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabLeftButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should not change since index is 0'); + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should not change since index is 0'); - await widgetTester.tap(tabRightButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabRightButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1); + expect(editableTabBarWidget().currentIndex, 1); - await widgetTester.tap(tabRightButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabRightButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should not change since index is equal to number of tabs'); + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should not change since index is equal to number of tabs'); - await widgetTester.tap(tabLeftButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(tabLeftButton); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); - }); + expect(editableTabBarWidget().currentIndex, 0); + }); - testWidgets('Reordering tabs (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Reordering tabs (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - final editableTabBar = find.byType(EditableTabBar); + final editableTabBar = find.byType(EditableTabBar); - expect(editableTabBar, findsOneWidget); + expect(editableTabBar, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); - expect(editableTabBarWidget().currentIndex, 0); + expect(editableTabBarWidget().currentIndex, 0); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should not change since index is 0'); + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should not change since index is 0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1); + expect(editableTabBarWidget().currentIndex, 1); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should not change since index is equal to number of tabs'); + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should not change since index is equal to number of tabs'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); - }); + expect(editableTabBarWidget().currentIndex, 0); + }); - testWidgets('Navigate tabs left right (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Navigate tabs left right (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - final editableTabBar = find.byType(EditableTabBar); + final editableTabBar = find.byType(EditableTabBar); - expect(editableTabBar, findsOneWidget); + expect(editableTabBar, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); - expect(editableTabBarWidget().currentIndex, 0); + expect(editableTabBarWidget().currentIndex, 0); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: 'Tab index should roll over'); + expect(editableTabBarWidget().currentIndex, 1, + reason: 'Tab index should roll over'); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should roll back over to 0'); + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should roll back over to 0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: 'Tab index should increase to 1 (no rollover)'); + expect(editableTabBarWidget().currentIndex, 1, + reason: 'Tab index should increase to 1 (no rollover)'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0); - }); + expect(editableTabBarWidget().currentIndex, 0); + }); - testWidgets('Navigate to specific tabs', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Navigate to specific tabs', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); + expect(find.byType(TabGrid, skipOffstage: false), findsNWidgets(2)); - final editableTabBar = find.byType(EditableTabBar); + final editableTabBar = find.byType(EditableTabBar); - expect(editableTabBar, findsOneWidget); + expect(editableTabBar, findsOneWidget); - editableTabBarWidget() => - (editableTabBar.evaluate().first.widget as EditableTabBar); + editableTabBarWidget() => + (editableTabBar.evaluate().first.widget as EditableTabBar); - expect(editableTabBarWidget().currentIndex, 0); + expect(editableTabBarWidget().currentIndex, 0); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit1); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit1); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit1); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit1); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 0, - reason: 'Tab index should remain at 0'); + expect(editableTabBarWidget().currentIndex, 0, + reason: 'Tab index should remain at 0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit2); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit2); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit2); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit2); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1); + expect(editableTabBarWidget().currentIndex, 1); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit5); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit5); - await widgetTester.pumpAndSettle(); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.digit5); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.digit5); + await widgetTester.pumpAndSettle(); - expect(editableTabBarWidget().currentIndex, 1, - reason: - 'Tab index should remain at 1 since there is no tab at index 4'); - }); + expect(editableTabBarWidget().currentIndex, 1, + reason: + 'Tab index should remain at 1 since there is no tab at index 4'); + }); - testWidgets('Renaming tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Renaming tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); + final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); - expect(teleopTab, findsOneWidget); + expect(teleopTab, findsOneWidget); - await widgetTester.tap(teleopTab, buttons: kSecondaryButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(teleopTab, buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); - final renameButton = find.text('Rename'); + final renameButton = find.text('Rename'); - expect(renameButton, findsOneWidget); + expect(renameButton, findsOneWidget); - await widgetTester.tap(renameButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(renameButton); + await widgetTester.pumpAndSettle(); - expect(find.text('Rename Tab'), findsOneWidget); + expect(find.text('Rename Tab'), findsOneWidget); - final nameTextField = find.widgetWithText(DialogTextInput, 'Name'); + final nameTextField = find.widgetWithText(DialogTextInput, 'Name'); - expect(nameTextField, findsOneWidget); + expect(nameTextField, findsOneWidget); - await widgetTester.enterText(nameTextField, 'New Tab Name!'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pump(); + await widgetTester.enterText(nameTextField, 'New Tab Name!'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pump(); - final saveButton = find.widgetWithText(TextButton, 'Save'); + final saveButton = find.widgetWithText(TextButton, 'Save'); - expect(saveButton, findsOneWidget); + expect(saveButton, findsOneWidget); - await widgetTester.tap(saveButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(saveButton); + await widgetTester.pumpAndSettle(); - expect( - find.widgetWithText(AnimatedContainer, 'Teleoperated'), findsNothing); - expect(find.widgetWithText(AnimatedContainer, 'New Tab Name!'), - findsOneWidget); - }); + expect( + find.widgetWithText(AnimatedContainer, 'Teleoperated'), findsNothing); + expect(find.widgetWithText(AnimatedContainer, 'New Tab Name!'), + findsOneWidget); + }); - testWidgets('Duplicating tab', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Duplicating tab', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); + final teleopTab = find.widgetWithText(AnimatedContainer, 'Teleoperated'); - expect(teleopTab, findsOneWidget); + expect(teleopTab, findsOneWidget); - await widgetTester.tap(teleopTab, buttons: kSecondaryButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(teleopTab, buttons: kSecondaryButton); + await widgetTester.pumpAndSettle(); - final duplicateButton = find.text('Duplicate'); + final duplicateButton = find.text('Duplicate'); - expect(duplicateButton, findsOneWidget); + expect(duplicateButton, findsOneWidget); - await widgetTester.tap(duplicateButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(duplicateButton); + await widgetTester.pumpAndSettle(); - expect(find.text('Teleoperated (Copy)'), findsOneWidget); + expect(find.text('Teleoperated (Copy)'), findsOneWidget); + }); }); - testWidgets('Minimizing window', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + group('[Window Manipulation]:', () { + testWidgets('Minimizing window', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final minimizeButton = find.ancestor( - of: find.byType(DecoratedMinimizeButton), - matching: find.byType(InkWell)); + final minimizeButton = find.ancestor( + of: find.byType(DecoratedMinimizeButton), + matching: find.byType(InkWell)); - expect(minimizeButton, findsOneWidget); + expect(minimizeButton, findsOneWidget); - await widgetTester.tap(minimizeButton); - }); + await widgetTester.tap(minimizeButton); + }); - testWidgets('Maximizing/unmaximizing window', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Maximizing/unmaximizing window', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final appBar = find.byType(CustomAppBar); + final appBar = find.byType(CustomAppBar); - expect(appBar, findsOneWidget); + expect(appBar, findsOneWidget); - await widgetTester.tapAt(const Offset(250, 0)); - await widgetTester.pump(kDoubleTapMinTime); - await widgetTester.tapAt(const Offset(250, 0)); + await widgetTester.tapAt(const Offset(250, 0)); + await widgetTester.pump(kDoubleTapMinTime); + await widgetTester.tapAt(const Offset(250, 0)); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final maximizeButton = find.ancestor( - of: find.byType(DecoratedMaximizeButton), - matching: find.byType(InkWell)); + final maximizeButton = find.ancestor( + of: find.byType(DecoratedMaximizeButton), + matching: find.byType(InkWell)); - await widgetTester.tap(maximizeButton); - }); + await widgetTester.tap(maximizeButton); + }); - testWidgets('Closing window (All changes saved)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Closing window (All changes saved)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); + final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); - expect(gyroWidget, findsOneWidget); + expect(gyroWidget, findsOneWidget); - // Drag to a location - await widgetTester.drag(gyroWidget, const Offset(256, -128)); - await widgetTester.pumpAndSettle(); + // Drag to a location + await widgetTester.drag(gyroWidget, const Offset(256, -128)); + await widgetTester.pumpAndSettle(); - // Drag back to its original location - await widgetTester.drag(gyroWidget, const Offset(-256, 128)); - await widgetTester.pumpAndSettle(); + // Drag back to its original location + await widgetTester.drag(gyroWidget, const Offset(-256, 128)); + await widgetTester.pumpAndSettle(); - final closeButton = find.ancestor( - of: find.byType(DecoratedCloseButton), matching: find.byType(InkWell)); + final closeButton = find.ancestor( + of: find.byType(DecoratedCloseButton), + matching: find.byType(InkWell)); - await widgetTester.tap(closeButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(closeButton); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsNothing); - }); + expect(find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsNothing); + }); - testWidgets('Closing window (Unsaved changes)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Closing window (Unsaved changes)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); + final gyroWidget = find.widgetWithText(WidgetContainer, 'Test Gyro'); - expect(gyroWidget, findsOneWidget); + expect(gyroWidget, findsOneWidget); - // Drag to a location - await widgetTester.drag(gyroWidget, const Offset(256, -128)); + // Drag to a location + await widgetTester.drag(gyroWidget, const Offset(256, -128)); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final closeButton = find.ancestor( - of: find.byType(DecoratedCloseButton), matching: find.byType(InkWell)); + final closeButton = find.ancestor( + of: find.byType(DecoratedCloseButton), + matching: find.byType(InkWell)); - expect(closeButton, findsOneWidget); + expect(closeButton, findsOneWidget); - await widgetTester.tap(closeButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(closeButton); + await widgetTester.pumpAndSettle(); - expect(find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsOneWidget); + expect( + find.widgetWithText(AlertDialog, 'Unsaved Changes'), findsOneWidget); - final discardButton = find.widgetWithText(TextButton, 'Discard'); + final discardButton = find.widgetWithText(TextButton, 'Discard'); - expect(discardButton, findsOneWidget); + expect(discardButton, findsOneWidget); - await widgetTester.tap(discardButton); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(discardButton); + await widgetTester.pumpAndSettle(); + }); }); - testWidgets('Opening settings', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + group('[Misc Shortcuts]:', () { + testWidgets('Opening settings', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final settingsButton = find.widgetWithIcon(MenuItemButton, Icons.settings); + final settingsButton = + find.widgetWithIcon(MenuItemButton, Icons.settings); - expect(settingsButton, findsOneWidget); + expect(settingsButton, findsOneWidget); - final settingsButtonWidget = - settingsButton.evaluate().first.widget as MenuItemButton; + final settingsButtonWidget = + settingsButton.evaluate().first.widget as MenuItemButton; - settingsButtonWidget.onPressed?.call(); + settingsButtonWidget.onPressed?.call(); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(find.byType(SettingsDialog), findsOneWidget); - }); + expect(find.byType(SettingsDialog), findsOneWidget); + }); - testWidgets('Opening settings (shortcut)', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + testWidgets('Opening settings (shortcut)', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); - - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.pumpAndSettle(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.comma); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.comma); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.comma); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.comma); - await widgetTester.pumpAndSettle(); - - expect(find.byType(SettingsDialog), findsOneWidget); - }); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - testWidgets('IP Address shortcuts', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + await widgetTester.pumpAndSettle(); - SharedPreferences.setMockInitialValues({ - PrefKeys.ipAddressMode: IPAddressMode.custom.index, - PrefKeys.ipAddress: '127.0.0.1', - PrefKeys.teamNumber: 353, + expect(find.byType(SettingsDialog), findsOneWidget); }); - MockNTConnection ntConnection = createMockOfflineNT4(); - MockDSInteropClient dsClient = MockDSInteropClient(); - when(dsClient.lastAnnouncedIP).thenReturn(null); - when(ntConnection.dsClient).thenReturn(dsClient); - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: ntConnection, - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + testWidgets('IP Address shortcuts', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + SharedPreferences.setMockInitialValues({ + PrefKeys.ipAddressMode: IPAddressMode.custom.index, + PrefKeys.ipAddress: '127.0.0.1', + PrefKeys.teamNumber: 353, + }); + + MockNTConnection ntConnection = createMockOfflineNT4(); + MockDSInteropClient dsClient = MockDSInteropClient(); + when(dsClient.lastAnnouncedIP).thenReturn(null); + when(ntConnection.dsClient).thenReturn(dsClient); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: ntConnection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(preferences.getInt(PrefKeys.ipAddressMode), - IPAddressMode.driverStation.index); - expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.driverStation.index); + expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); - await preferences.setString(PrefKeys.ipAddress, '0.0.0.0'); + await preferences.setString(PrefKeys.ipAddress, '0.0.0.0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - // IP Address shouldn't change since it's already driver station - expect(preferences.getInt(PrefKeys.ipAddressMode), - IPAddressMode.driverStation.index); - expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); + // IP Address shouldn't change since it's already driver station + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.driverStation.index); + expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(preferences.getInt(PrefKeys.ipAddressMode), - IPAddressMode.localhost.index); - expect(preferences.getString(PrefKeys.ipAddress), 'localhost'); + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.localhost.index); + expect(preferences.getString(PrefKeys.ipAddress), 'localhost'); - await preferences.setString(PrefKeys.ipAddress, '0.0.0.0'); + await preferences.setString(PrefKeys.ipAddress, '0.0.0.0'); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.shift); - await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyDownEvent(LogicalKeyboardKey.keyK); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.keyK); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); - await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.control); + await widgetTester.sendKeyUpEvent(LogicalKeyboardKey.shift); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - // IP address shouldn't change since mode is set to localhost - expect(preferences.getInt(PrefKeys.ipAddressMode), - IPAddressMode.localhost.index); - expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); + // IP address shouldn't change since mode is set to localhost + expect(preferences.getInt(PrefKeys.ipAddressMode), + IPAddressMode.localhost.index); + expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); + }); }); - testWidgets('Robot Notifications', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - final Map data = { - 'title': 'Robot Notification Title', - 'description': 'Robot Notification Description', - 'level': 'INFO', - 'displayTime': 350, - 'width': 300.0, - 'height': 300.0, - }; - - MockNTConnection connection = createMockOnlineNT4( - virtualTopics: [ - NT4Topic( - name: '/Elastic/RobotNotifications', - type: NT4TypeStr.kString, - properties: {}, - ) - ], - virtualValues: {'/Elastic/RobotNotifications': jsonEncode(data)}, - serverTime: 5000000, - ); - MockNT4Subscription mockSub = MockNT4Subscription(); - - List listeners = []; - when(mockSub.listen(any)).thenAnswer( - (realInvocation) { - listeners.add(realInvocation.positionalArguments[0]); - mockSub.updateValue(jsonEncode(data), 0); - }, - ); - - when(mockSub.updateValue(any, any)).thenAnswer( - (invoc) { - for (var value in listeners) { - value.call( - invoc.positionalArguments[0], invoc.positionalArguments[1]); - } - }, - ); - - when(connection.subscribeAll(any, any)).thenAnswer( - (realInvocation) { - return mockSub; - }, - ); - - final notificationWidget = - find.widgetWithText(ElegantNotification, data['title']); - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: connection, - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker(), + group('[Notifications]:', () { + testWidgets('Robot Notifications', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + final Map data = { + 'title': 'Robot Notification Title', + 'description': 'Robot Notification Description', + 'level': 'INFO', + 'displayTime': 350, + 'width': 300.0, + 'height': 300.0, + }; + + MockNTConnection connection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Elastic/RobotNotifications', + type: NT4TypeStr.kString, + properties: {}, + ) + ], + virtualValues: {'/Elastic/RobotNotifications': jsonEncode(data)}, + serverTime: 5000000, + ); + MockNT4Subscription mockSub = MockNT4Subscription(); + + List listeners = []; + when(mockSub.listen(any)).thenAnswer( + (realInvocation) { + listeners.add(realInvocation.positionalArguments[0]); + mockSub.updateValue(jsonEncode(data), 0); + }, + ); + + when(mockSub.updateValue(any, any)).thenAnswer( + (invoc) { + for (var value in listeners) { + value.call( + invoc.positionalArguments[0], invoc.positionalArguments[1]); + } + }, + ); + + when(connection.subscribeAll(any, any)).thenAnswer( + (realInvocation) { + return mockSub; + }, + ); + + final notificationWidget = + find.widgetWithText(ElegantNotification, data['title']); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: connection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), ), - ), - ); - expect(notificationWidget, findsNothing); - - await widgetTester.pumpAndSettle(); - connection - .subscribeAll('/Elastic/RobotNotifications', 0.2) - .updateValue(jsonEncode(data), 1); + ); + expect(notificationWidget, findsNothing); - await widgetTester.pump(); + await widgetTester.pumpAndSettle(); + connection + .subscribeAll('/Elastic/RobotNotifications', 0.2) + .updateValue(jsonEncode(data), 1); - expect(notificationWidget, findsOneWidget); + await widgetTester.pump(); - await widgetTester.pumpAndSettle(); + expect(notificationWidget, findsOneWidget); - expect(notificationWidget, findsNothing); + await widgetTester.pumpAndSettle(); - connection - .subscribeAll('/Elastic/RobotNotifications', 0.2) - .updateValue(jsonEncode(data), 1); - }); + expect(notificationWidget, findsNothing); - testWidgets('Update Notification', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; + connection + .subscribeAll('/Elastic/RobotNotifications', 0.2) + .updateValue(jsonEncode(data), 1); + }); - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - version: '0.0.0.0', - updateChecker: createMockUpdateChecker( - updateAvailable: true, latestVersion: '2025.0.1'), + testWidgets('Update Notification', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker( + updateAvailable: true, latestVersion: '2025.0.1'), + ), ), - ), - ); + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - final notificationWidget = - find.widgetWithText(ElegantNotification, 'Version 2025.0.1 Available'); - final notificationIcon = find.byIcon(Icons.update); + final notificationWidget = find.widgetWithText( + ElegantNotification, 'Version 2025.0.1 Available'); + final notificationIcon = find.byIcon(Icons.update); - expect(notificationWidget, findsOneWidget); - expect(notificationIcon, findsOneWidget); + expect(notificationWidget, findsOneWidget); + expect(notificationIcon, findsOneWidget); + }); }); } diff --git a/test/services/elastic_layout_downloader_test.dart b/test/services/elastic_layout_downloader_test.dart new file mode 100644 index 00000000..0641bcc1 --- /dev/null +++ b/test/services/elastic_layout_downloader_test.dart @@ -0,0 +1,256 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:elastic_dashboard/services/elastic_layout_downloader.dart'; +import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/settings.dart'; +import '../test_util.dart'; +import '../test_util.mocks.dart'; + +const Map layoutFiles = { + 'dirs': [], + 'files': [ + { + 'name': 'elastic-layout 1.json', + 'size': 1000, + }, + { + 'name': 'elastic-layout 2.json', + 'size': 1000, + }, + { + 'name': 'example.txt', + 'size': 1, + }, + ], +}; + +const Map layoutOne = { + 'version': 1.0, + 'grid_size': 128.0, + 'tabs': [ + { + 'name': 'Test Tab', + 'grid_layout': { + 'layouts': [ + { + 'title': 'Subsystem', + 'x': 384.0, + 'y': 128.0, + 'width': 256.0, + 'height': 384.0, + 'type': 'List Layout', + 'properties': {'label_position': 'TOP'}, + 'children': [ + { + 'title': 'ExampleSubsystem', + 'x': 0.0, + 'y': 0.0, + 'width': 256.0, + 'height': 128.0, + 'type': 'Subsystem', + 'properties': { + 'topic': '/Test Tab/ExampleSubsystem', + 'period': 0.06 + }, + }, + { + 'title': 'Gyro', + 'x': 0.0, + 'y': 0.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'Gyro', + 'properties': { + 'topic': '/Test Tab/Gyro', + 'period': 0.06, + 'counter_clockwise_positive': false + }, + }, + ] + }, + { + 'title': 'Empty Layout', + 'x': 640.0, + 'y': 0.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'List Layout', + 'properties': {'label_position': 'TOP'}, + 'children': [], + }, + ], + 'containers': [ + { + 'title': 'Test Widget', + 'x': 128.0, + 'y': 128.0, + 'width': 256.0, + 'height': 256.0, + 'type': 'Gyro', + 'properties': { + 'topic': '/Test Tab/Gyro', + 'period': 0.06, + 'counter_clockwise_positive': false + }, + }, + ], + }, + }, + ], +}; + +void main() { + late SharedPreferences preferences; + late NTConnection ntConnection; + + setUpAll(() async { + SharedPreferences.setMockInitialValues({ + PrefKeys.ipAddress: '127.0.0.1', + PrefKeys.layoutLocked: false, + }); + + preferences = await SharedPreferences.getInstance(); + + ntConnection = createMockOnlineNT4(); + }); + + test('Get list of available layouts', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/?format=json': + Response(jsonEncode(layoutFiles), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.getAvailableLayouts( + ntConnection: ntConnection, preferences: preferences); + + expect(downloadResponse.successful, isTrue); + expect( + downloadResponse.data, + unorderedEquals([ + 'elastic-layout 1', + 'elastic-layout 2', + ])); + }); + + test('Download layout', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: ntConnection, + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, isTrue); + expect(downloadResponse.data, jsonEncode(layoutOne)); + }); + + group('Unsuccessful if', () { + test('network tables is disconnected', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 200), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect(downloadResponse.data, + 'Cannot download a remote layout while disconnected from the robot.'); + }); + + test('client response throws an error', () async { + MockClient mockClient = MockClient(); + when(mockClient.get(any)) + .thenAnswer((_) => throw ClientException('Client Exception')); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect(downloadResponse.data, 'Client Exception'); + }); + + test('file is not found', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 404), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect( + downloadResponse.data, 'File "elastic-layout 1.json" was not found'); + }); + + test('http request gives invalid status code', () async { + Client mockClient = createHttpClient( + mockGetResponses: { + 'http://127.0.0.1:5800/${Uri.encodeComponent('elastic-layout 1.json')}': + Response(jsonEncode(layoutOne), 353), + }, + ); + + ElasticLayoutDownloader layoutDownloader = + ElasticLayoutDownloader(mockClient); + + LayoutDownloadResponse downloadResponse = + await layoutDownloader.downloadLayout( + ntConnection: createMockOnlineNT4(), + preferences: preferences, + layoutName: 'elastic-layout 1', + ); + + expect(downloadResponse.successful, false); + expect(downloadResponse.data, 'Request returned status code 353'); + }); + }); +} diff --git a/test/test_util.dart b/test/test_util.dart index 3e55f466..329e6ae6 100644 --- a/test/test_util.dart +++ b/test/test_util.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:http/http.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -258,6 +259,21 @@ MockUpdateChecker createMockUpdateChecker( return updateChecker; } +@GenerateNiceMocks([ + MockSpec(), +]) +MockClient createHttpClient({Map? mockGetResponses}) { + MockClient mockClient = MockClient(); + + if (mockGetResponses != null) { + for (MapEntry mockRequest in mockGetResponses.entries) { + when(mockClient.get(Uri.parse(mockRequest.key))) + .thenAnswer((_) => Future.value(mockRequest.value)); + } + } + return mockClient; +} + void ignoreOverflowErrors( FlutterErrorDetails details, { bool forceReport = false,