diff --git a/.gitignore b/.gitignore
index 6ac465db..5a54815d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
target/
/.idea/
+.env
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index c40f667e..f410eea0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,22 @@
javafx-fxml
${javafx.version}
+
+ io.github.cdimascio
+ dotenv-java
+ 3.2.0
+
+
+ tools.jackson.core
+ jackson-databind
+ 3.0.1
+
+
+ org.wiremock
+ wiremock
+ 4.0.0-beta.15
+ test
+
@@ -55,7 +71,7 @@
com.example.HelloFX
-
+
javafx
true
@@ -65,4 +81,4 @@
-
+
\ No newline at end of file
diff --git a/src/main/java/com/example/FxUtils.java b/src/main/java/com/example/FxUtils.java
new file mode 100644
index 00000000..c723c75d
--- /dev/null
+++ b/src/main/java/com/example/FxUtils.java
@@ -0,0 +1,26 @@
+package com.example;
+
+import javafx.application.Platform;
+
+public class FxUtils {
+
+ /**
+ * Runs a task on the JavaFX thread.
+ * If already on the thread, runs immediately.
+ * If not, schedules it to run later.
+ * If JavaFX is not initialized, runs on the current thread.
+ */
+
+ public static void runOnFx(Runnable task) {
+ try {
+ if (Platform.isFxApplicationThread()) {
+ task.run();
+ } else {
+ Platform.runLater(task);
+ }
+ } catch (IllegalStateException notInitialized) {
+ //headless
+ task.run();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java
index fdd160a0..d5f14f05 100644
--- a/src/main/java/com/example/HelloController.java
+++ b/src/main/java/com/example/HelloController.java
@@ -1,22 +1,156 @@
package com.example;
+import javafx.application.Platform;
+import javafx.beans.binding.Bindings;
+import javafx.event.ActionEvent;
import javafx.fxml.FXML;
-import javafx.scene.control.Label;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.*;
+import javafx.scene.layout.HBox;
+
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
/**
- * Controller layer: mediates between the view (FXML) and the model.
+ * Controller for the chat app.
+ *
+ * Connects the FXML view to the HelloModel.
+ * Handles sending messages, changing topics, and updating the UI.
*/
+
public class HelloController {
- private final HelloModel model = new HelloModel();
+ private final HelloModel model = new HelloModel(new NtfyConnectionImpl());
+
+ @FXML
+ private Button sendButton;
@FXML
private Label messageLabel;
+ @FXML
+ private Label topicLabel;
+
+ @FXML
+ private ListView messageView;
+
+ @FXML
+ private TextArea messageInput;
+
+ @FXML
+ private TextField topicInput;
+
+ @FXML
+ private Button changeTopicButton;
+
+ private final DateTimeFormatter timeFormatter =
+ DateTimeFormatter.ofPattern("HH:mm:ss")
+ .withZone(ZoneId.systemDefault());
+
@FXML
private void initialize() {
- if (messageLabel != null) {
- messageLabel.setText(model.getGreeting());
+ messageLabel.setText(model.getGreeting());
+
+ Platform.runLater(() -> messageInput.requestFocus());
+
+ topicLabel.setText("/" + model.getCurrentTopic());
+ model.currentTopicProperty().addListener((obs, oldVal, newVal) -> {
+ topicLabel.setText("/" + newVal);
+ });
+
+ messageView.setItems(model.getMessages());
+
+ messageInput.textProperty().bindBidirectional(model.messageToSendProperty());
+
+ sendButton.disableProperty().bind(Bindings.createBooleanBinding(
+ () -> {
+ String text = messageInput.getText();
+ return text == null || text.trim().isEmpty();
+ },
+ messageInput.textProperty()
+ ));
+
+ if (changeTopicButton != null) {
+ changeTopicButton.disableProperty().bind(Bindings.createBooleanBinding(
+ () -> {
+ String text = topicInput.getText();
+ return text == null || text.trim().isEmpty();
+ },
+ topicInput.textProperty()
+ ));
+ }
+
+
+ messageView.setCellFactory(lv -> new ListCell<>() {
+ @Override
+ protected void updateItem(NtfyMessageDto msg, boolean empty) {
+ super.updateItem(msg, empty);
+
+ if (empty || msg == null || msg.message() == null || msg.message().isBlank()) {
+ setText(null);
+ setGraphic(null);
+ } else {
+ // Skapa bubble-label
+ Label bubble = new Label(msg.message());
+ bubble.setWrapText(true);
+ bubble.setMaxWidth(250);
+ bubble.setPadding(new Insets(10));
+ bubble.getStyleClass().add("chat-bubble"); // Basstyle
+
+ HBox container = new HBox(bubble);
+ container.setPadding(new Insets(5));
+
+ // Använd CSS-klasser för skickat/mottaget
+ if (model.getUserId().equals(msg.id())) {
+ bubble.getStyleClass().add("chat-bubble-sent");
+ container.setAlignment(Pos.CENTER_RIGHT);
+ } else {
+ bubble.getStyleClass().add("chat-bubble-received");
+ container.setAlignment(Pos.CENTER_LEFT);
+ }
+
+ setText(null);
+ setGraphic(container);
+ }
+ }
+ });
+
+
+ // Scrolla ner till senaste meddelandet
+ model.getMessages().addListener((javafx.collections.ListChangeListener) change -> {
+ Platform.runLater(() -> {
+ if (!messageView.getItems().isEmpty()) {
+ messageView.scrollTo(messageView.getItems().size() - 1);
+ }
+ });
+ });
+ }
+
+ @FXML
+ private void sendMessage(ActionEvent actionEvent) {
+ model.sendMessageAsync(success -> {
+ if (success) {
+ Platform.runLater(() -> messageInput.clear());
+ Platform.runLater(() -> messageInput.requestFocus());
+ } else {
+ Platform.runLater(() -> {
+ Alert alert = new Alert(Alert.AlertType.ERROR);
+ alert.setTitle("Send Failed");
+ alert.setHeaderText("Failed to send message");
+ alert.setContentText("Could not send your message. Please try again.");
+ alert.showAndWait();
+ });
+ }
+ });
+ }
+
+ @FXML
+ private void changeTopic(ActionEvent actionEvent) {
+ String newTopic = topicInput.getText();
+ if (newTopic != null && !newTopic.isBlank()) {
+ model.setCurrentTopic(newTopic);
+ topicInput.clear();
}
}
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java
index 96bdc5ca..cd6c9775 100644
--- a/src/main/java/com/example/HelloFX.java
+++ b/src/main/java/com/example/HelloFX.java
@@ -6,16 +6,30 @@
import javafx.scene.Scene;
import javafx.stage.Stage;
+import java.util.Objects;
+
+/**
+ * Main JavaFX application class for RuneChat.
+ *
+ * Loads the FXML view, applies the stylesheet, and starts the application window.
+ */
+
public class HelloFX extends Application {
@Override
public void start(Stage stage) throws Exception {
FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml"));
Parent root = fxmlLoader.load();
- Scene scene = new Scene(root, 640, 480);
- stage.setTitle("Hello MVC");
+
+ Scene scene = new Scene(root, 768, 576);
+ stage.setTitle("RuneChat");
+
+ scene.getStylesheets().add(Objects.requireNonNull(HelloFX.class.getResource("style.css")).toExternalForm());
+
stage.setScene(scene);
stage.show();
+
+
}
public static void main(String[] args) {
diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java
index 385cfd10..fdf767a3 100644
--- a/src/main/java/com/example/HelloModel.java
+++ b/src/main/java/com/example/HelloModel.java
@@ -1,15 +1,109 @@
package com.example;
+import javafx.application.Platform;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+import java.util.function.Consumer;
+
+import static com.example.FxUtils.runOnFx;
+
/**
- * Model layer: encapsulates application data and business logic.
+ * Model layer for the chatapp RuneChat.
+ *
+ * Manages messages, the current topic, and sending/receiving messages via NtfyConnection.
*/
+
public class HelloModel {
- /**
- * Returns a greeting based on the current Java and JavaFX versions.
- */
+
+ private final NtfyConnection connection;
+ private final ObservableList messages = FXCollections.observableArrayList();
+ private final StringProperty messageToSend = new SimpleStringProperty();
+ private final StringProperty currentTopic = new SimpleStringProperty();
+
+ public HelloModel(NtfyConnection connection) {
+ this.connection = connection;
+ this.currentTopic.set(connection.getCurrentTopic());
+ receiveMessage();
+ }
+
+ public ObservableList getMessages() {
+ return messages;
+ }
+
+ public String getMessageToSend() {
+ return messageToSend.get();
+ }
+
+ public StringProperty messageToSendProperty() {
+ return messageToSend;
+ }
+
+ public void setMessageToSend(String message) {
+ messageToSend.set(message);
+ }
+
+ public String getCurrentTopic() {
+ return currentTopic.get();
+ }
+
+ public StringProperty currentTopicProperty() {
+ return currentTopic;
+ }
+
+ public void setCurrentTopic(String topic) {
+ if (topic != null && !topic.isBlank()) {
+ connection.setCurrentTopic(topic);
+ this.currentTopic.set(topic);
+ messages.clear();
+ receiveMessage();
+ }
+ }
+
+ public String getUserId() {
+ return connection.getUserId();
+ }
+
public String getGreeting() {
- String javaVersion = System.getProperty("java.version");
- String javafxVersion = System.getProperty("javafx.version");
- return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + ".";
+ return "RuneChat";
+ }
+
+ public boolean canSendMessage() {
+ String msg = messageToSend.get();
+ return msg != null && !msg.isBlank();
+ }
+
+ public void sendMessageAsync(Consumer callback) {
+ String msg = messageToSend.get();
+ if (msg == null || msg.isBlank()) {
+ System.out.println("Nothing to send!");
+ callback.accept(false);
+ return;
+ }
+
+ connection.send(msg, success -> {
+ if (success) {
+ runOnFx(() -> {
+ if (msg.equals(messageToSend.get())) {
+ messageToSend.set("");
+ }
+ });
+ callback.accept(true);
+ } else {
+ System.out.println("Failed to send message!");
+ callback.accept(false);
+ }
+ });
}
-}
+
+ public void receiveMessage() {
+ connection.receive(m -> {
+ if (m == null || m.message() == null || m.message().isBlank()) return;
+ runOnFx(() -> messages.add(m));
+ });
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java
new file mode 100644
index 00000000..c9ea2d90
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnection.java
@@ -0,0 +1,34 @@
+package com.example;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * Interface for sending and receiving messages via a notification service.
+ */
+public interface NtfyConnection {
+
+ void send(String message, Consumer callback);
+
+ /**
+ * Receives messages from the service.
+ * @param messageHandler called for each received message
+ */
+ void receive(Consumer messageHandler);
+
+ /** Returns the current topic. Default is "mytopic". */
+ default String getCurrentTopic() {
+ return "mytopic";
+ }
+
+ /** Sets the current topic. Default does nothing. */
+ default void setCurrentTopic(String topic) {
+
+ }
+
+ /** Returns the user ID. Default is "unknown". */
+ default String getUserId() {
+ return "unknown";
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java
new file mode 100644
index 00000000..176c4fad
--- /dev/null
+++ b/src/main/java/com/example/NtfyConnectionImpl.java
@@ -0,0 +1,109 @@
+package com.example;
+
+import io.github.cdimascio.dotenv.Dotenv;
+import tools.jackson.databind.ObjectMapper;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Objects;
+import java.util.function.Consumer;
+
+/**
+ * Implementation of NtfyConnection using HTTP for sending and receiving messages.
+ */
+public class NtfyConnectionImpl implements NtfyConnection {
+
+ private final HttpClient http = HttpClient.newHttpClient();
+ private final String hostName;
+ private final String userId;
+ private String currentTopic;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ /** Loads configuration from environment variables. */
+ public NtfyConnectionImpl() {
+ Dotenv dotenv = Dotenv.load();
+ this.hostName = Objects.requireNonNull(dotenv.get("HOST_NAME"));
+ this.userId = Objects.requireNonNull(dotenv.get("USER_ID"), "USER_ID");
+ this.currentTopic = dotenv.get("DEFAULT_TOPIC", "mytopic");
+ }
+
+ /** Creates connection with a custom host, default user and topic. */
+ public NtfyConnectionImpl(String hostName) {
+ this.hostName = hostName;
+ this.userId = "testuser";
+ this.currentTopic = "mytopic";
+ }
+
+ /** Creates connection with custom host, user, and topic. */
+ public NtfyConnectionImpl(String hostName, String userId, String topic) {
+ this.hostName = hostName;
+ this.userId = userId;
+ this.currentTopic = topic;
+ }
+
+ /** Returns the user ID. */
+ public String getUserId() {
+ return userId;
+ }
+
+ /** Returns the current topic. */
+ public String getCurrentTopic() {
+ return currentTopic;
+ }
+
+ /** Sets the current topic. */
+ public void setCurrentTopic(String topic) {
+ this.currentTopic = topic;
+ }
+
+ /**
+ * Sends a message asynchronously to the current topic.
+ * @param message message to send
+ * @param callback called with true if successful, false otherwise
+ */
+ @Override
+ public void send(String message, Consumer callback) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.ofString(message))
+ .header("Cache", "no")
+ .header("X-User-Id", userId)
+ .uri(URI.create(hostName + "/" + currentTopic))
+ .build();
+
+ http.sendAsync(httpRequest, HttpResponse.BodyHandlers.discarding())
+ .thenApply(response -> response.statusCode() / 100 == 2)
+ .exceptionally(ex -> {
+ System.out.println("Error sending message: " + ex.getMessage());
+ return false;
+ })
+ .thenAccept(callback);
+ }
+
+ /**
+ * Receives messages from the current topic asynchronously.
+ * @param messageHandler called for each received message
+ */
+ @Override
+ public void receive(Consumer messageHandler) {
+ HttpRequest httpRequest = HttpRequest.newBuilder()
+ .GET()
+ .uri(URI.create(hostName + "/" + currentTopic + "/json"))
+ .build();
+
+ http.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines())
+ .thenAccept(response -> response.body()
+ .map(s -> {
+ try {
+ return mapper.readValue(s, NtfyMessageDto.class);
+ } catch (Exception e) {
+ System.out.println("Failed to parse message: " + e.getMessage());
+ return null;
+ }
+ })
+ .filter(Objects::nonNull)
+ .peek(System.out::println)
+ .forEach(messageHandler));
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java
new file mode 100644
index 00000000..ce08bb5a
--- /dev/null
+++ b/src/main/java/com/example/NtfyMessageDto.java
@@ -0,0 +1,10 @@
+package com.example;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Data transfer object for messages from the Ntfy server.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record NtfyMessageDto(String id, long time, String event, String topic, String message) {
+}
\ No newline at end of file
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 71574a27..89e041cb 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -1,6 +1,10 @@
module hellofx {
requires javafx.controls;
requires javafx.fxml;
+ requires io.github.cdimascio.dotenv.java;
+ requires java.net.http;
+ requires tools.jackson.databind;
+ requires javafx.graphics;
opens com.example to javafx.fxml;
exports com.example;
diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml
index 20a7dc82..2c9739ce 100644
--- a/src/main/resources/com/example/hello-view.fxml
+++ b/src/main/resources/com/example/hello-view.fxml
@@ -1,9 +1,49 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/com/example/style.css b/src/main/resources/com/example/style.css
new file mode 100644
index 00000000..ea021e02
--- /dev/null
+++ b/src/main/resources/com/example/style.css
@@ -0,0 +1,196 @@
+/* ===== Huvudcontainer - RuneScape Classic ===== */
+.chat-container {
+ -fx-background-color: linear-gradient(to bottom, #2d2416, #1a1510);
+ -fx-padding: 20;
+ -fx-spacing: 16;
+ -fx-font-family: "Arial", sans-serif;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 3;
+}
+
+/* ===== Header ===== */
+.chat-title {
+ -fx-text-fill: #ffff00;
+ -fx-font-size: 28px;
+ -fx-font-weight: bold;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 2, 0, 1, 1);
+}
+
+/* ===== Current room ===== */
+.topic-label {
+ -fx-font-size: 15px;
+ -fx-font-weight: bold;
+ -fx-text-fill: #00ffff;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 1, 0, 1, 1);
+}
+
+/* ===== Room changer ===== */
+.topic-container {
+ -fx-background-color: #3e3426;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 2;
+ -fx-padding: 8 14;
+ -fx-background-radius: 3;
+ -fx-border-radius: 3;
+ -fx-spacing: 10;
+ -fx-alignment: CENTER_RIGHT;
+}
+
+.topic-input-label {
+ -fx-font-size: 13px;
+ -fx-text-fill: #ffffff;
+ -fx-font-weight: normal;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.8), 1, 0, 1, 1);
+}
+
+.topic-input {
+ -fx-background-color: #1a1510;
+ -fx-text-fill: #ffffff;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 2;
+ -fx-border-radius: 3;
+ -fx-background-radius: 3;
+ -fx-padding: 6 12;
+ -fx-font-size: 13px;
+ -fx-font-family: "Arial", sans-serif;
+}
+
+.topic-input:focused {
+ -fx-border-color: #c4a57b;
+ -fx-border-width: 2;
+}
+
+.topic-button {
+ -fx-background-color: linear-gradient(to bottom, #6b5d4f, #4a3f33);
+ -fx-text-fill: #ffff00;
+ -fx-background-radius: 3;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 2;
+ -fx-padding: 8 20;
+ -fx-font-size: 14px;
+ -fx-font-weight: bold;
+ -fx-cursor: hand;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.6), 2, 0, 1, 1);
+}
+
+.topic-button:hover {
+ -fx-background-color: linear-gradient(to bottom, #8b7355, #6b5d4f);
+ -fx-text-fill: #00ff00;
+ -fx-border-color: #c4a57b;
+}
+
+.topic-button:disabled {
+ -fx-opacity: 0.5;
+}
+
+/* ===== ListView styling ===== */
+.message-list {
+ -fx-background-color: #000000;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 3;
+ -fx-border-radius: 3;
+ -fx-background-radius: 3;
+}
+
+.message-list .list-cell {
+ -fx-background-color: transparent;
+ -fx-padding: 6 12;
+}
+
+.message-list .list-cell:selected {
+ -fx-background-color: transparent;
+}
+
+/* ===== Chat bubbles ===== */
+.chat-bubble {
+ -fx-background-radius: 3;
+ -fx-padding: 8 14;
+ -fx-wrap-text: true;
+ -fx-max-width: 280px;
+ -fx-font-family: "Arial", sans-serif;
+ -fx-font-size: 13px;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.7), 2, 0, 1, 1);
+}
+
+.chat-bubble-sent {
+ -fx-background-color: #1a4d1a;
+ -fx-text-fill: #00ff00;
+ -fx-background-radius: 3;
+ -fx-border-color: #2d7a2d;
+ -fx-border-width: 1;
+ -fx-font-weight: normal;
+}
+
+.chat-bubble-received {
+ -fx-background-color: #1a1a4d;
+ -fx-text-fill: #00ffff;
+ -fx-background-radius: 3;
+ -fx-border-color: #2d2d7a;
+ -fx-border-width: 1;
+ -fx-font-weight: normal;
+}
+
+/* ===== Input area ===== */
+.input-container {
+ -fx-alignment: CENTER;
+ -fx-spacing: 12;
+}
+
+.message-input {
+ -fx-background-color: #000000;
+ -fx-text-fill: #00ff00;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 2;
+ -fx-border-radius: 3;
+ -fx-background-radius: 3;
+ -fx-padding: 12 18;
+ -fx-font-size: 13px;
+ -fx-font-family: "Arial", sans-serif;
+}
+
+.message-input:focused {
+ -fx-border-color: #c4a57b;
+ -fx-border-width: 3;
+}
+
+/* ===== Send button ===== */
+.send-button {
+ -fx-background-color: linear-gradient(to bottom, #6b5d4f, #4a3f33);
+ -fx-text-fill: #ffff00;
+ -fx-background-radius: 3;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 2;
+ -fx-padding: 14 30;
+ -fx-font-weight: bold;
+ -fx-font-size: 15;
+ -fx-font-family: "Arial", sans-serif;
+ -fx-cursor: hand;
+ -fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.6), 3, 0, 1, 1);
+}
+
+.send-button:hover {
+ -fx-background-color: linear-gradient(to bottom, #8b7355, #6b5d4f);
+ -fx-text-fill: #00ff00;
+ -fx-border-color: #c4a57b;
+}
+
+.send-button:disabled {
+ -fx-opacity: 0.5;
+}
+
+/* ===== Scrollbar styling ===== */
+.list-view > .scroll-bar:vertical {
+ -fx-background-color: #2d2416;
+}
+
+.list-view > .scroll-bar:vertical .thumb {
+ -fx-background-color: #6b5d4f;
+ -fx-background-radius: 2;
+ -fx-border-color: #8b7355;
+ -fx-border-width: 1;
+}
+
+.list-view > .scroll-bar:vertical .thumb:hover {
+ -fx-background-color: #7d6b5a;
+ -fx-border-color: #c4a57b;
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/HelloModelTest.java b/src/test/java/com/example/HelloModelTest.java
new file mode 100644
index 00000000..29b07ec2
--- /dev/null
+++ b/src/test/java/com/example/HelloModelTest.java
@@ -0,0 +1,403 @@
+package com.example;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import javafx.collections.ListChangeListener;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.assertj.core.api.Assertions.assertThat;
+
+@WireMockTest
+@DisplayName("HelloModel Tests")
+class HelloModelTest {
+
+ @BeforeAll
+ static void initToolkit() {
+ System.out.println("Skipping FX initialization for headless test.");
+ }
+
+
+ /**
+ * Verifies that a valid message is sent successfully and captured by the connection spy.
+ */
+ @Test
+ @DisplayName("Should send message successfully when message is valid.")
+ void shouldSendValidMessageSuccessfully() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend("Hello World");
+
+ CountDownLatch latch = new CountDownLatch(1);
+
+ // Act
+ model.sendMessageAsync(success -> latch.countDown());
+
+ boolean completed = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(spy.message).isEqualTo("Hello World");
+ }
+
+ /**
+ * Ensures the message input field is cleared after a successful send operation.
+ */
+ @Test
+ @DisplayName("Should clear message field after successful send.")
+ void shouldClearMessageFieldAfterSuccessfulSend() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend("Test message");
+
+ CountDownLatch latch = new CountDownLatch(1);
+
+ // Act
+ model.sendMessageAsync(success -> latch.countDown());
+ boolean completed = latch.await(1, TimeUnit.SECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ Thread.sleep(100);
+ assertThat(model.getMessageToSend()).isEmpty();
+ }
+
+ /**
+ * Confirms that sending an empty string returns false and does not trigger a send.
+ */
+ @Test
+ @DisplayName("Should return false when sending empty string")
+ void shouldReturnFalseForEmptyMessage() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend("");
+
+ CountDownLatch latch = new CountDownLatch(1);
+ final boolean[] result = new boolean[1];
+
+ // Act
+ model.sendMessageAsync(success -> {
+ result[0] = success;
+ latch.countDown();
+ });
+
+ boolean completed = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(result[0]).isFalse();
+ assertThat(spy.message).isNull();
+ }
+
+ /**
+ * Confirms that sending a null message returns false and does not trigger a send.
+ */
+ @Test
+ @DisplayName("Should return false when sending null message")
+ void shouldReturnFalseForNullMessage() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ model.setMessageToSend(null);
+
+ CountDownLatch latch = new CountDownLatch(1);
+ final boolean[] result = new boolean[1];
+
+ // Act
+ model.sendMessageAsync(success -> {
+ result[0] = success;
+ latch.countDown();
+ });
+
+ boolean completed = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(result[0]).isFalse();
+ assertThat(spy.message).isNull();
+ }
+
+ /**
+ * Ensures failed sends are handled gracefully: callback receives false, message is retained.
+ */
+ @Test
+ @DisplayName("Should handle failed send and keep message")
+ void shouldHandleFailedSendGracefully() throws InterruptedException {
+ // Arrange
+ var failingConnection = new NtfyConnection() {
+ @Override
+ public void send(String message, Consumer callback) {
+ callback.accept(false);
+ }
+ @Override
+ public void receive(Consumer messageHandler) { }
+ };
+ var model = new HelloModel(failingConnection);
+ model.setMessageToSend("Fail this message");
+
+ CountDownLatch latch = new CountDownLatch(1);
+ final boolean[] result = new boolean[1];
+
+ // Act
+ model.sendMessageAsync(success -> {
+ result[0] = success;
+ latch.countDown();
+ });
+
+ boolean completed = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(result[0]).isFalse();
+ assertThat(model.getMessageToSend()).isEqualTo("Fail this message");
+ }
+
+ /**
+ * Verifies multiple sequential sends are processed correctly in order.
+ */
+ @Test
+ @DisplayName("Should handle multiple sequential sends correctly")
+ void shouldHandleMultipleSequentialSends() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+
+ CountDownLatch latch = new CountDownLatch(2);
+ final boolean[] results = new boolean[2];
+
+ // Act
+ model.setMessageToSend("First");
+ model.sendMessageAsync(success -> {
+ results[0] = success;
+ latch.countDown();
+ });
+
+ model.setMessageToSend("Second");
+ model.sendMessageAsync(success -> {
+ results[1] = success;
+ latch.countDown();
+ });
+
+ boolean completed = latch.await(1, TimeUnit.SECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(results[0]).isTrue();
+ assertThat(results[1]).isTrue();
+ assertThat(spy.message).isEqualTo("Second");
+ }
+
+ /**
+ * Ensures exceptions during send are caught and result in a failed callback (false).
+ */
+ @Test
+ @DisplayName("Should handle exceptions during send gracefully")
+ void shouldHandleExceptionsDuringSend() throws InterruptedException {
+ // Arrange
+ var crashingConnection = new NtfyConnection() {
+ @Override
+ public void send(String message, Consumer callback) {
+ throw new RuntimeException("Simulated crash");
+ }
+ @Override
+ public void receive(Consumer messageHandler) { }
+ };
+ var model = new HelloModel(crashingConnection);
+ model.setMessageToSend("Crash this");
+
+ CountDownLatch latch = new CountDownLatch(1);
+ final boolean[] result = {false};
+ final boolean[] exceptionCaught = {false};
+
+ // Act
+ try {
+ model.sendMessageAsync(success -> {
+ result[0] = success;
+ latch.countDown();
+ });
+ } catch (Exception e) {
+ exceptionCaught[0] = true;
+ latch.countDown();
+ }
+
+ boolean completed = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+
+ if (exceptionCaught[0]) {
+ assertThat(exceptionCaught[0]).isTrue();
+ } else {
+ assertThat(result[0]).isFalse();
+ }
+ }
+
+ /**
+ * Verifies that a valid incoming message is added to the observable message list.
+ */
+ @Test
+ @DisplayName("Should add received message to message list")
+ void shouldAddReceivedMessageToList() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+ var message = new NtfyMessageDto("Test", 1, "message", "myroom", "Test");
+
+ CountDownLatch latch = new CountDownLatch(1);
+
+ model.getMessages().addListener((ListChangeListener) change -> {
+ while (change.next()) {
+ if (change.wasAdded()) {
+ latch.countDown();
+ }
+ }
+ });
+
+ // Act
+ spy.simulateIncoming(message);
+
+ boolean completed = latch.await(1, TimeUnit.SECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(model.getMessages()).contains(message);
+ }
+
+ /**
+ * Ensures null messages received from the connection are ignored and not added.
+ */
+ @Test
+ @DisplayName("Should ignore null received messages")
+ void shouldIgnoreNullReceivedMessages() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+
+ CountDownLatch latch = new CountDownLatch(1);
+ model.getMessages().addListener((ListChangeListener) change -> {
+ while (change.next()) {
+ if (change.wasAdded()) latch.countDown();
+ }
+ });
+
+ // Act
+ spy.simulateIncoming(null);
+
+ boolean noAdd = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(noAdd).isFalse();
+ assertThat(model.getMessages()).isEmpty();
+ }
+
+ /**
+ * Confirms messages with blank or empty content are filtered out and not added.
+ */
+ @Test
+ @DisplayName("Should ignore messages with blank or empty content")
+ void shouldIgnoreBlankOrEmptyMessages() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+
+ CountDownLatch latch = new CountDownLatch(1);
+ model.getMessages().addListener((ListChangeListener) change -> {
+ while (change.next()) {
+ if (change.wasAdded()) latch.countDown();
+ }
+ });
+
+ // Act
+ var blank = new NtfyMessageDto("id1", 1, "message", "room", " ");
+ var empty = new NtfyMessageDto("id2", 2, "message", "room", "");
+
+ spy.simulateIncoming(blank);
+ spy.simulateIncoming(empty);
+
+ boolean noAdd = latch.await(500, TimeUnit.MILLISECONDS);
+
+ // Assert
+ assertThat(noAdd).isFalse();
+ assertThat(model.getMessages()).isEmpty();
+ }
+
+ /**
+ * Verifies multiple incoming messages are added in the correct arrival order.
+ */
+ @Test
+ @DisplayName("Should add multiple received messages in correct order")
+ void shouldAddMultipleMessagesInOrder() throws InterruptedException {
+ // Arrange
+ var spy = new NtfyConnectionSpy();
+ var model = new HelloModel(spy);
+
+ var message1 = new NtfyMessageDto("id1", 1000, "message", "mytopic", "First message");
+ var message2 = new NtfyMessageDto("id2", 2000, "message", "mytopic", "Second message");
+ var message3 = new NtfyMessageDto("id3", 3000, "message", "mytopic", "Third message");
+
+ CountDownLatch latch = new CountDownLatch(3);
+
+ model.getMessages().addListener((ListChangeListener) change -> {
+ while (change.next()) {
+ if (change.wasAdded()) {
+ latch.countDown();
+ }
+ }
+ });
+
+ // Act
+ spy.simulateIncoming(message1);
+ spy.simulateIncoming(message2);
+ spy.simulateIncoming(message3);
+
+ boolean completed = latch.await(1, TimeUnit.SECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(model.getMessages()).hasSize(3);
+ assertThat(model.getMessages().get(0)).isEqualTo(message1);
+ assertThat(model.getMessages().get(1)).isEqualTo(message2);
+ assertThat(model.getMessages().get(2)).isEqualTo(message3);
+ }
+
+
+ /**
+ * Integration test: verifies message is successfully sent to a mocked Ntfy server via HTTP.
+ */
+ @Test
+ @DisplayName("Should send message to fake server successfully")
+ void shouldSendMessageToFakeServer(WireMockRuntimeInfo wmRuntimeInfo) throws InterruptedException {
+ // Arrange
+ var con = new NtfyConnectionImpl("http://localhost:" + wmRuntimeInfo.getHttpPort());
+ var model = new HelloModel(con);
+ model.setMessageToSend("Hello World");
+
+ stubFor(post("/mytopic").willReturn(ok()));
+
+ CountDownLatch latch = new CountDownLatch(1);
+ final boolean[] result = new boolean[1];
+
+ // Act
+ model.sendMessageAsync(success -> {
+ result[0] = success;
+ latch.countDown();
+ });
+
+ boolean completed = latch.await(1, TimeUnit.SECONDS);
+
+ // Assert
+ assertThat(completed).isTrue();
+ assertThat(result[0]).isTrue();
+
+ verify(postRequestedFor(urlEqualTo("/mytopic"))
+ .withRequestBody(matching("Hello World")));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/NtfyConnectionSpy.java b/src/test/java/com/example/NtfyConnectionSpy.java
new file mode 100644
index 00000000..b47c521f
--- /dev/null
+++ b/src/test/java/com/example/NtfyConnectionSpy.java
@@ -0,0 +1,31 @@
+package com.example;
+
+import java.util.function.Consumer;
+
+/**
+ * Spy for testing NtfyConnection: captures sent messages and simulates incoming ones.
+ */
+public class NtfyConnectionSpy implements NtfyConnection{
+
+ String message;
+ Consumer handler;
+
+ /** Captures message and calls callback with true on a background thread. */
+ @Override
+ public void send(String message, Consumer callback) {
+ this.message = message;
+ new Thread(() -> callback.accept(true)).start();
+ }
+
+
+ /** Saves the handler for incoming messages. */
+ @Override
+ public void receive(Consumer messageHandler) {
+ this.handler = messageHandler;
+ }
+
+ /** Triggers the saved handler with the given message, if there is any. */
+ public void simulateIncoming(NtfyMessageDto msg) {
+ if (handler != null) handler.accept(msg);
+ }
+}
\ No newline at end of file