From b643a36ab5d5ce93a529d43fbf61ff58ec0b993d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hallvard=20Tr=C3=A6tteberg?= <hal@ntnu.no>
Date: Wed, 18 Aug 2021 14:53:22 +0000
Subject: [PATCH] Laget to varianter til og la til flere tester

---
 .vscode/settings.json                         |   3 +
 javafx-template/src/main/java/app/Calc.java   |   8 ++
 .../src/test/java/app/CalcTest.java           |  75 ++++++++++
 modules-template/.gitignore                   |   2 +
 modules-template/core/pom.xml                 |  43 ++++++
 .../core/src/main/java/core/Calc.java         | 108 ++++++++++++++
 .../core/src/test/java/core/CalcTest.java     | 114 +++++++++++++++
 modules-template/pom.xml                      |  62 ++++++++
 modules-template/src/main/java/core/Calc.java | 108 ++++++++++++++
 .../src/test/java/core/CalcTest.java          |  39 +++++
 modules-template/ui/pom.xml                   |  98 +++++++++++++
 modules-template/ui/src/main/java/ui/App.java |  27 ++++
 .../ui/src/main/java/ui/AppController.java    | 135 ++++++++++++++++++
 .../ui/src/main/resources/ui/App.fxml         |  64 +++++++++
 .../ui/src/test/java/ui/AppTest.java          | 130 +++++++++++++++++
 .../ui/src/test/java/ui/README.md             |  38 +++++
 packages-template/.gitignore                  |   2 +
 packages-template/pom.xml                     | 103 +++++++++++++
 .../src/main/java/core/Calc.java              | 108 ++++++++++++++
 packages-template/src/main/java/ui/App.java   |  27 ++++
 .../src/main/java/ui/AppController.java       | 135 ++++++++++++++++++
 .../src/main/resources/ui/App.fxml            |  64 +++++++++
 .../src/test/java/core/CalcTest.java          | 114 +++++++++++++++
 .../src/test/java/ui/AppTest.java             | 130 +++++++++++++++++
 packages-template/src/test/java/ui/README.md  |  38 +++++
 25 files changed, 1775 insertions(+)
 create mode 100644 .vscode/settings.json
 create mode 100644 modules-template/.gitignore
 create mode 100644 modules-template/core/pom.xml
 create mode 100644 modules-template/core/src/main/java/core/Calc.java
 create mode 100644 modules-template/core/src/test/java/core/CalcTest.java
 create mode 100644 modules-template/pom.xml
 create mode 100644 modules-template/src/main/java/core/Calc.java
 create mode 100644 modules-template/src/test/java/core/CalcTest.java
 create mode 100644 modules-template/ui/pom.xml
 create mode 100644 modules-template/ui/src/main/java/ui/App.java
 create mode 100644 modules-template/ui/src/main/java/ui/AppController.java
 create mode 100644 modules-template/ui/src/main/resources/ui/App.fxml
 create mode 100644 modules-template/ui/src/test/java/ui/AppTest.java
 create mode 100644 modules-template/ui/src/test/java/ui/README.md
 create mode 100644 packages-template/.gitignore
 create mode 100644 packages-template/pom.xml
 create mode 100644 packages-template/src/main/java/core/Calc.java
 create mode 100644 packages-template/src/main/java/ui/App.java
 create mode 100644 packages-template/src/main/java/ui/AppController.java
 create mode 100644 packages-template/src/main/resources/ui/App.fxml
 create mode 100644 packages-template/src/test/java/core/CalcTest.java
 create mode 100644 packages-template/src/test/java/ui/AppTest.java
 create mode 100644 packages-template/src/test/java/ui/README.md

diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..e0f15db
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+    "java.configuration.updateBuildConfiguration": "automatic"
+}
\ No newline at end of file
diff --git a/javafx-template/src/main/java/app/Calc.java b/javafx-template/src/main/java/app/Calc.java
index 8ff70b8..1f0c5c8 100644
--- a/javafx-template/src/main/java/app/Calc.java
+++ b/javafx-template/src/main/java/app/Calc.java
@@ -25,6 +25,7 @@ public class Calc {
 
     /**
      * Pushes a new operand onto top of the stack.
+     *
      * @param d the new operand
      */
     public void pushOperand(double d) {
@@ -52,6 +53,7 @@ public class Calc {
 
     /**
      * Removes and returns the top operand.
+     *
      * @return the top operand
      * @throws IllegalStateException if the stack is empty
      */
@@ -65,6 +67,7 @@ public class Calc {
     /**
      * Performs the provided operation in the top operand, and
      * replaces it with the result.
+     *
      * @param op the operation to perform
      * @return the result of performing the operation
      * @throws IllegalStateException if the operand stack is empty
@@ -77,6 +80,7 @@ public class Calc {
     /**
      * Performs the provided operation in the two topmost operands, and
      * replaces them with the result.
+     *
      * @param op the operation to perform
      * @return the result of performing the operation
      * @throws IllegalStateException if the operand count is less than two
@@ -94,6 +98,8 @@ public class Calc {
 
     /**
      * Swaps the two topmost operands.
+     *
+     * @throws IllegalStateException if the operand count is less than two
      */
     public void swap() {
         // TODO
@@ -101,6 +107,8 @@ public class Calc {
 
     /**
      * Duplicates the top operand.
+     *
+     * @throws IllegalStateException if the operand stack is empty
      */
     public void dup() {
         // TODO
diff --git a/javafx-template/src/test/java/app/CalcTest.java b/javafx-template/src/test/java/app/CalcTest.java
index 9a7c0df..28064cf 100644
--- a/javafx-template/src/test/java/app/CalcTest.java
+++ b/javafx-template/src/test/java/app/CalcTest.java
@@ -28,6 +28,21 @@ public class CalcTest {
         checkCalc(calc, 3.14, 1.0);
     }
 
+    @Test
+    public void testPeekOperand() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.peekOperand());
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new Calc().peekOperand());
+    }
+
+    @Test
+    public void testPeekOperandN() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.peekOperand(0));
+        Assertions.assertEquals(1.0, calc.peekOperand(1));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> calc.peekOperand(2));
+    }
+
     @Test
     public void testPopOperand() {
         Calc calc = new Calc(1.0, 3.14);
@@ -36,4 +51,64 @@ public class CalcTest {
         Assertions.assertEquals(1.0, calc.popOperand());
         checkCalc(calc);
     }
+
+    @Test
+    public void testPopOperand_emptyStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().popOperand());
+    }
+
+    @Test
+    public void testPerformOperation1() {
+        Calc calc = new Calc(1.0);
+        Assertions.assertEquals(-1.0, calc.performOperation(n -> -n));
+        checkCalc(calc, -1.0);
+    }
+
+    @Test
+    public void testPerformOperation1_emptyOperandStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().performOperation(n -> -n));
+    }
+
+
+    @Test
+    public void testPerformOperation2() {
+        Calc calc = new Calc(1.0, 3.0);
+        Assertions.assertEquals(-2.0, calc.performOperation((n1, n2) -> n1 - n2));
+        checkCalc(calc, -2.0);
+    }
+
+    @Test
+    public void testPerformOperation2_lessThanTwoOperands() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc(1.0).performOperation((n1, n2) -> n1 - n2));
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().performOperation((n1, n2) -> n1 - n2));
+    }
+
+    @Test
+    public void testSwap() {
+        Calc calc = new Calc(1.0, 3.14);
+        calc.swap();
+        checkCalc(calc, 3.14, 1.0);
+        calc.swap();
+        checkCalc(calc, 1.0, 3.14);
+    }
+
+    @Test
+    public void testSwap_lessThanTwoOperands() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc(1.0).swap());
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().swap());
+    }
+
+    @Test
+    public void testDup() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.popOperand());
+        checkCalc(calc, 1.0);
+        Assertions.assertEquals(1.0, calc.popOperand());
+        checkCalc(calc);
+    }
+
+    @Test
+    public void testDup_emptyOperandStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().dup());
+    }
 }
diff --git a/modules-template/.gitignore b/modules-template/.gitignore
new file mode 100644
index 0000000..2131639
--- /dev/null
+++ b/modules-template/.gitignore
@@ -0,0 +1,2 @@
+# ignore maven build folder
+target/
diff --git a/modules-template/core/pom.xml b/modules-template/core/pom.xml
new file mode 100644
index 0000000..abaca3e
--- /dev/null
+++ b/modules-template/core/pom.xml
@@ -0,0 +1,43 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>it1901</groupId>
+    <artifactId>modules-core</artifactId>
+
+    <parent>
+        <groupId>it1901</groupId>
+        <artifactId>modules-template</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>..</relativePath>
+    </parent>
+
+    <dependencies>
+
+        <!-- junit testing with jupiter -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+        		<artifactId>maven-surefire-plugin</artifactId>
+	        </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/modules-template/core/src/main/java/core/Calc.java b/modules-template/core/src/main/java/core/Calc.java
new file mode 100644
index 0000000..f50679d
--- /dev/null
+++ b/modules-template/core/src/main/java/core/Calc.java
@@ -0,0 +1,108 @@
+package core;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+
+public class Calc {
+    
+    private final List<Double> operandStack;
+
+    public Calc(double... operands) {
+        operandStack = new ArrayList<>(operands.length + 2);
+        for (var d : operands) {
+            operandStack.add(d);
+        }
+    }
+
+    /**
+     * @return the number of operands on the stack
+     */
+    public int getOperandCount() {
+        return operandStack.size();
+    }
+
+    /**
+     * Pushes a new operand onto top of the stack.
+     * @param d the new operand
+     */
+    public void pushOperand(double d) {
+        operandStack.add(d);
+    }
+
+    /**
+     * @param n the place (from the top) to peek
+     * @return the n'th operand from the top
+     * @throws IllegalArgumentException if n is larger than the operand count
+     */
+    public double peekOperand(int n) {
+        if (n > getOperandCount()) {
+            throw new IllegalArgumentException("Cannot peek at position " + n + " when the operand count is " + getOperandCount());
+        }
+        return operandStack.get(getOperandCount() - n - 1);
+    }
+
+    /**
+     * @return the top operand
+     */
+    public double peekOperand() {
+        return peekOperand(0);
+    }
+
+    /**
+     * Removes and returns the top operand.
+     * @return the top operand
+     * @throws IllegalStateException if the stack is empty
+     */
+    public double popOperand() {
+        if (getOperandCount() == 0) {
+            throw new IllegalStateException("Cannot pop from an empty stack");
+        }
+        return operandStack.remove(operandStack.size() - 1);
+    }
+    
+    /**
+     * Performs the provided operation in the top operand, and
+     * replaces it with the result.
+     * @param op the operation to perform
+     * @return the result of performing the operation
+     * @throws IllegalStateException if the operand stack is empty
+     */
+    public double performOperation(UnaryOperator<Double> op) throws IllegalStateException {
+        // TODO
+        return 0.0;
+    }
+
+    /**
+     * Performs the provided operation in the two topmost operands, and
+     * replaces them with the result.
+     * @param op the operation to perform
+     * @return the result of performing the operation
+     * @throws IllegalStateException if the operand count is less than two
+     */
+    public double performOperation(BinaryOperator<Double> op) throws IllegalStateException {
+        if (getOperandCount() < 2) {
+            throw new IllegalStateException("Too few operands (" + getOperandCount() + ") on the stack");
+        }
+        var op1 = popOperand();
+        var op2 = popOperand();
+        var result = op.apply(op1, op2);
+        pushOperand(result);
+        return result;
+    }
+
+    /**
+     * Swaps the two topmost operands.
+     */
+    public void swap() {
+        // TODO
+    }
+
+    /**
+     * Duplicates the top operand.
+     */
+    public void dup() {
+        // TODO
+    }
+}
\ No newline at end of file
diff --git a/modules-template/core/src/test/java/core/CalcTest.java b/modules-template/core/src/test/java/core/CalcTest.java
new file mode 100644
index 0000000..0207cd5
--- /dev/null
+++ b/modules-template/core/src/test/java/core/CalcTest.java
@@ -0,0 +1,114 @@
+package core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class CalcTest {
+
+    private static void checkCalc(Calc calc, double... operands) {
+        Assertions.assertEquals(operands.length, calc.getOperandCount(), "Wrong operand count");
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], calc.peekOperand(i), "Wrong value at #" + i + " of operand stack");
+        }
+    }
+
+    @Test
+    public void testCalc() {
+        checkCalc(new Calc());
+        checkCalc(new Calc(1.0), 1.0);
+        checkCalc(new Calc(3.14, 1.0), 1.0, 3.14);
+    }
+
+    @Test
+    public void testPushOperand() {
+        Calc calc = new Calc();
+        calc.pushOperand(1.0);
+        checkCalc(calc, 1.0);
+        calc.pushOperand(3.14);
+        checkCalc(calc, 3.14, 1.0);
+    }
+
+    @Test
+    public void testPeekOperand() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.peekOperand());
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new Calc().peekOperand());
+    }
+
+    @Test
+    public void testPeekOperandN() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.peekOperand(0));
+        Assertions.assertEquals(1.0, calc.peekOperand(1));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> calc.peekOperand(2));
+    }
+
+    @Test
+    public void testPopOperand() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.popOperand());
+        checkCalc(calc, 1.0);
+        Assertions.assertEquals(1.0, calc.popOperand());
+        checkCalc(calc);
+    }
+
+    @Test
+    public void testPopOperand_emptyStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().popOperand());
+    }
+
+    @Test
+    public void testPerformOperation1() {
+        Calc calc = new Calc(1.0);
+        Assertions.assertEquals(-1.0, calc.performOperation(n -> -n));
+        checkCalc(calc, -1.0);
+    }
+
+    @Test
+    public void testPerformOperation1_emptyOperandStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().performOperation(n -> -n));
+    }
+
+
+    @Test
+    public void testPerformOperation2() {
+        Calc calc = new Calc(1.0, 3.0);
+        Assertions.assertEquals(-2.0, calc.performOperation((n1, n2) -> n1 - n2));
+        checkCalc(calc, -2.0);
+    }
+
+    @Test
+    public void testPerformOperation2_lessThanTwoOperands() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc(1.0).performOperation((n1, n2) -> n1 - n2));
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().performOperation((n1, n2) -> n1 - n2));
+    }
+
+    @Test
+    public void testSwap() {
+        Calc calc = new Calc(1.0, 3.14);
+        calc.swap();
+        checkCalc(calc, 3.14, 1.0);
+        calc.swap();
+        checkCalc(calc, 1.0, 3.14);
+    }
+
+    @Test
+    public void testSwap_lessThanTwoOperands() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc(1.0).swap());
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().swap());
+    }
+
+    @Test
+    public void testDup() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.popOperand());
+        checkCalc(calc, 1.0);
+        Assertions.assertEquals(1.0, calc.popOperand());
+        checkCalc(calc);
+    }
+
+    @Test
+    public void testDup_emptyOperandStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().dup());
+    }
+}
diff --git a/modules-template/pom.xml b/modules-template/pom.xml
new file mode 100644
index 0000000..f0e8d58
--- /dev/null
+++ b/modules-template/pom.xml
@@ -0,0 +1,62 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>it1901</groupId>
+    <artifactId>modules-template</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <packaging>pom</packaging>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <maven.compiler.source>16</maven.compiler.source>
+        <maven.compiler.target>16</maven.compiler.target>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.junit.jupiter</groupId>
+                <artifactId>junit-jupiter-api</artifactId>
+                <version>5.7.2</version>
+                <scope>test</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.junit.jupiter</groupId>
+                <artifactId>junit-jupiter-engine</artifactId>
+                <version>5.7.2</version>
+                <scope>test</scope>
+            </dependency>
+            <dependency>
+                <groupId>org.junit.jupiter</groupId>
+                <artifactId>junit-jupiter-params</artifactId>
+                <version>5.7.2</version>
+                <scope>test</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <build>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-compiler-plugin</artifactId>
+                    <version>3.8.1</version>
+                    <configuration>
+                        <release>16</release>
+                    </configuration>
+                </plugin>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-surefire-plugin</artifactId>
+                    <version>3.0.0-M5</version>
+                </plugin>
+            </plugins>
+        </pluginManagement>
+    </build>
+
+    <modules>
+        <module>core</module>
+        <module>ui</module>
+    </modules>
+</project>
diff --git a/modules-template/src/main/java/core/Calc.java b/modules-template/src/main/java/core/Calc.java
new file mode 100644
index 0000000..f50679d
--- /dev/null
+++ b/modules-template/src/main/java/core/Calc.java
@@ -0,0 +1,108 @@
+package core;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+
+public class Calc {
+    
+    private final List<Double> operandStack;
+
+    public Calc(double... operands) {
+        operandStack = new ArrayList<>(operands.length + 2);
+        for (var d : operands) {
+            operandStack.add(d);
+        }
+    }
+
+    /**
+     * @return the number of operands on the stack
+     */
+    public int getOperandCount() {
+        return operandStack.size();
+    }
+
+    /**
+     * Pushes a new operand onto top of the stack.
+     * @param d the new operand
+     */
+    public void pushOperand(double d) {
+        operandStack.add(d);
+    }
+
+    /**
+     * @param n the place (from the top) to peek
+     * @return the n'th operand from the top
+     * @throws IllegalArgumentException if n is larger than the operand count
+     */
+    public double peekOperand(int n) {
+        if (n > getOperandCount()) {
+            throw new IllegalArgumentException("Cannot peek at position " + n + " when the operand count is " + getOperandCount());
+        }
+        return operandStack.get(getOperandCount() - n - 1);
+    }
+
+    /**
+     * @return the top operand
+     */
+    public double peekOperand() {
+        return peekOperand(0);
+    }
+
+    /**
+     * Removes and returns the top operand.
+     * @return the top operand
+     * @throws IllegalStateException if the stack is empty
+     */
+    public double popOperand() {
+        if (getOperandCount() == 0) {
+            throw new IllegalStateException("Cannot pop from an empty stack");
+        }
+        return operandStack.remove(operandStack.size() - 1);
+    }
+    
+    /**
+     * Performs the provided operation in the top operand, and
+     * replaces it with the result.
+     * @param op the operation to perform
+     * @return the result of performing the operation
+     * @throws IllegalStateException if the operand stack is empty
+     */
+    public double performOperation(UnaryOperator<Double> op) throws IllegalStateException {
+        // TODO
+        return 0.0;
+    }
+
+    /**
+     * Performs the provided operation in the two topmost operands, and
+     * replaces them with the result.
+     * @param op the operation to perform
+     * @return the result of performing the operation
+     * @throws IllegalStateException if the operand count is less than two
+     */
+    public double performOperation(BinaryOperator<Double> op) throws IllegalStateException {
+        if (getOperandCount() < 2) {
+            throw new IllegalStateException("Too few operands (" + getOperandCount() + ") on the stack");
+        }
+        var op1 = popOperand();
+        var op2 = popOperand();
+        var result = op.apply(op1, op2);
+        pushOperand(result);
+        return result;
+    }
+
+    /**
+     * Swaps the two topmost operands.
+     */
+    public void swap() {
+        // TODO
+    }
+
+    /**
+     * Duplicates the top operand.
+     */
+    public void dup() {
+        // TODO
+    }
+}
\ No newline at end of file
diff --git a/modules-template/src/test/java/core/CalcTest.java b/modules-template/src/test/java/core/CalcTest.java
new file mode 100644
index 0000000..dc95b0d
--- /dev/null
+++ b/modules-template/src/test/java/core/CalcTest.java
@@ -0,0 +1,39 @@
+package core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class CalcTest {
+
+    private static void checkCalc(Calc calc, double... operands) {
+        Assertions.assertEquals(operands.length, calc.getOperandCount(), "Wrong operand count");
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], calc.peekOperand(i), "Wrong value at #" + i + " of operand stack");
+        }
+    }
+
+    @Test
+    public void testCalc() {
+        checkCalc(new Calc());
+        checkCalc(new Calc(1.0), 1.0);
+        checkCalc(new Calc(3.14, 1.0), 1.0, 3.14);
+    }
+
+    @Test
+    public void testPushOperand() {
+        Calc calc = new Calc();
+        calc.pushOperand(1.0);
+        checkCalc(calc, 1.0);
+        calc.pushOperand(3.14);
+        checkCalc(calc, 3.14, 1.0);
+    }
+
+    @Test
+    public void testPopOperand() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.popOperand());
+        checkCalc(calc, 1.0);
+        Assertions.assertEquals(1.0, calc.popOperand());
+        checkCalc(calc);
+    }
+}
diff --git a/modules-template/ui/pom.xml b/modules-template/ui/pom.xml
new file mode 100644
index 0000000..45f4dec
--- /dev/null
+++ b/modules-template/ui/pom.xml
@@ -0,0 +1,98 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>it1901</groupId>
+    <artifactId>modules-ui</artifactId>
+
+    <parent>
+        <groupId>it1901</groupId>
+        <artifactId>modules-template</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>..</relativePath>
+    </parent>
+
+    <dependencies>
+
+        <dependency>
+            <groupId>it1901</groupId>
+            <artifactId>modules-core</artifactId>
+            <version>0.0.1-SNAPSHOT</version>
+        </dependency>
+
+        <!-- javafx -->
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-controls</artifactId>
+            <version>16</version>
+        </dependency>
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-fxml</artifactId>
+            <version>16</version>
+        </dependency>
+
+        <!-- junit testing with jupiter -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+        </dependency>
+
+        <!-- test javafx with TextFX -->
+		<dependency>
+			<groupId>org.testfx</groupId>
+			<artifactId>testfx-core</artifactId>
+			<version>4.0.16-alpha</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.testfx</groupId>
+			<artifactId>testfx-junit5</artifactId>
+			<version>4.0.16-alpha</version>
+			<scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest</artifactId>
+            <version>2.2</version>
+            <scope>test</scope>
+        </dependency>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+        		<artifactId>maven-surefire-plugin</artifactId>
+	        </plugin>
+
+            <plugin>
+                <groupId>org.openjfx</groupId>
+                <artifactId>javafx-maven-plugin</artifactId>
+                <version>0.0.6</version>
+                <executions>
+                    <execution>
+                        <!-- Default configuration for running -->
+                        <!-- Usage: mvn clean javafx:run -->
+                        <id>default-cli</id>
+                        <configuration>
+                            <mainClass>ui.App</mainClass>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/modules-template/ui/src/main/java/ui/App.java b/modules-template/ui/src/main/java/ui/App.java
new file mode 100644
index 0000000..acead69
--- /dev/null
+++ b/modules-template/ui/src/main/java/ui/App.java
@@ -0,0 +1,27 @@
+package ui;
+
+import javafx.application.Application;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+
+import java.io.IOException;
+
+/**
+ * JavaFX App
+ */
+public class App extends Application {
+
+    @Override
+    public void start(Stage stage) throws IOException {
+        FXMLLoader fxmlLoader = new FXMLLoader(this.getClass().getResource("App.fxml"));
+        Parent parent = fxmlLoader.load();
+        stage.setScene(new Scene(parent));
+        stage.show();
+    }
+
+    public static void main(String[] args) {
+        launch();
+    }
+}
\ No newline at end of file
diff --git a/modules-template/ui/src/main/java/ui/AppController.java b/modules-template/ui/src/main/java/ui/AppController.java
new file mode 100644
index 0000000..2002d48
--- /dev/null
+++ b/modules-template/ui/src/main/java/ui/AppController.java
@@ -0,0 +1,135 @@
+package ui;
+
+import core.Calc;
+
+import java.util.List;
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+
+import javafx.event.ActionEvent;
+import javafx.fxml.FXML;
+import javafx.scene.control.Label;
+import javafx.scene.control.Labeled;
+import javafx.scene.control.ListView;
+
+public class AppController {
+
+    private Calc calc;
+
+    public AppController() {
+        calc = new Calc(0.0, 0.0, 0.0);
+    }
+
+    public Calc getCalc() {
+        return calc;
+    }
+
+    public void setCalc(Calc calc) {
+        this.calc = calc;
+        updateOperandsView();
+    }
+
+    @FXML
+    private ListView<Double> operandsView;
+
+    @FXML
+    private Label operandView;
+
+    @FXML
+    void initialize() {
+        updateOperandsView();
+    }
+
+    private void updateOperandsView() {
+        List<Double> operands = operandsView.getItems();
+        operands.clear();
+        int elementCount = Math.min(calc.getOperandCount(), 3);
+        for (int i = 0; i < elementCount; i++) {
+            operands.add(calc.peekOperand(elementCount - i - 1));
+        }
+    }
+
+    private String getOperandString() {
+        return operandView.getText();
+    }
+
+    private boolean hasOperand() {
+        return ! getOperandString().isBlank();
+    }
+
+    private double getOperand() {
+        return Double.valueOf(operandView.getText());
+    }
+    
+    private void setOperand(String operandString) {
+        operandView.setText(operandString);
+    }
+
+    @FXML
+    void handleEnter() {
+        if (hasOperand()) {
+            calc.pushOperand(getOperand());
+        } else {
+            calc.dup();
+        }
+        setOperand("");
+        updateOperandsView();
+    }
+
+    private void appendToOperand(String s) {
+        // TODO
+    }
+
+    @FXML
+    void handleDigit(ActionEvent ae) {
+        if (ae.getSource() instanceof Labeled l) {
+            // TODO append button label to operand
+        }
+    }
+
+    @FXML
+    void handlePoint() {
+        var operandString = getOperandString();
+        if (operandString.contains(".")) {
+            // TODO remove characters after point
+        } else {
+            // TODO append point
+        }
+    }
+
+    @FXML
+    void handleClear() {
+        // TODO clear operand
+    }
+
+    @FXML
+    void handleSwap() {
+        // TODO clear operand
+    }
+
+    private void performOperation(UnaryOperator<Double> op) {
+        // TODO
+    }
+
+    private void performOperation(boolean swap, BinaryOperator<Double> op) {
+        if (hasOperand()) {
+            // TODO push operand first
+        }
+        // TODO perform operation, but swap first if needed
+    }
+
+    @FXML
+    void handleOpAdd() {
+        // TODO
+    }
+
+    @FXML
+    void handleOpSub() {
+        // TODO
+    }
+
+    @FXML
+    void handleOpMult() {
+        // TODO
+    }
+}
diff --git a/modules-template/ui/src/main/resources/ui/App.fxml b/modules-template/ui/src/main/resources/ui/App.fxml
new file mode 100644
index 0000000..ff280af
--- /dev/null
+++ b/modules-template/ui/src/main/resources/ui/App.fxml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import javafx.scene.layout.GridPane?>
+<?import javafx.scene.control.Label?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ListView?>
+
+<GridPane xmlns="http://javafx.com/javafx/8.0.171" xmlns:fx="http://javafx.com/fxml/1" fx:controller="ui.AppController"
+         alignment="CENTER" hgap="10.0" vgap="10.0" >
+
+   <ListView fx:id="operandsView" prefHeight="80.0"
+      GridPane.rowIndex="0" GridPane.columnIndex="0" GridPane.columnSpan="4"/>
+   <Label text="" fx:id="operandView"
+      GridPane.rowIndex="1" GridPane.columnIndex="0" GridPane.columnSpan="4"/>
+
+   <!-- multi-line button label with XML entity for newline -->
+   <Button text="E&#10;n&#10;t&#10;e&#10;r" onAction="#handleEnter"
+      GridPane.rowIndex="2" GridPane.columnIndex="3" GridPane.rowSpan="3"/>
+
+   <Button text="7" onAction="#handleDigit"
+      GridPane.rowIndex="2" GridPane.columnIndex="0"/>
+   <Button text="8" onAction="#handleDigit"
+      GridPane.rowIndex="2" GridPane.columnIndex="1"/>
+   <Button text="9" onAction="#handleDigit"
+      GridPane.rowIndex="2" GridPane.columnIndex="2"/>
+   
+   <Button text="4" onAction="#handleDigit"
+      GridPane.rowIndex="3" GridPane.columnIndex="0"/>
+   <Button text="5" onAction="#handleDigit"
+      GridPane.rowIndex="3" GridPane.columnIndex="1"/>
+   <Button text="6" onAction="#handleDigit"
+      GridPane.rowIndex="3" GridPane.columnIndex="2"/>
+   
+   <Button text="1" onAction="#handleDigit"
+      GridPane.rowIndex="4" GridPane.columnIndex="0"/>
+   <Button text="2" onAction="#handleDigit"
+      GridPane.rowIndex="4" GridPane.columnIndex="1"/>
+   <Button text="3" onAction="#handleDigit"
+      GridPane.rowIndex="4" GridPane.columnIndex="2"/>
+   
+   <Button text="0" onAction="#handleDigit"
+      GridPane.rowIndex="5" GridPane.columnIndex="0"/>
+   <Button text="." onAction="#handlePoint"
+      GridPane.rowIndex="5" GridPane.columnIndex="1"/>
+   <Button text="C" onAction="#handleClear"
+      GridPane.rowIndex="5" GridPane.columnIndex="2"/>
+   <Button text="~" onAction="#handleSwap"
+      GridPane.rowIndex="5" GridPane.columnIndex="3"/>
+
+   <Button text="+" onAction="#handleOpAdd"
+      GridPane.rowIndex="6" GridPane.columnIndex="0"/>
+   <Button text="-" onAction="#handleOpSub"
+      GridPane.rowIndex="6" GridPane.columnIndex="1"/>
+   <Button text="*" onAction="#handleOpMult"
+      GridPane.rowIndex="6" GridPane.columnIndex="2"/>
+
+   <!-- TODO -->
+   <Button text="/"
+      GridPane.rowIndex="6" GridPane.columnIndex="3"/>
+   <Button text="√"
+      GridPane.rowIndex="7" GridPane.columnIndex="0"/>
+   <Button text="π"
+      GridPane.rowIndex="7" GridPane.columnIndex="1"/>
+</GridPane>
diff --git a/modules-template/ui/src/test/java/ui/AppTest.java b/modules-template/ui/src/test/java/ui/AppTest.java
new file mode 100644
index 0000000..3c6e991
--- /dev/null
+++ b/modules-template/ui/src/test/java/ui/AppTest.java
@@ -0,0 +1,130 @@
+package ui;
+
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.stage.Stage;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.testfx.framework.junit5.ApplicationTest;
+import org.testfx.matcher.control.LabeledMatchers;
+
+/**
+ * TestFX App test
+ */
+public class AppTest extends ApplicationTest {
+
+    private AppController controller;
+    private Parent root;
+
+    @Override
+    public void start(Stage stage) throws IOException {
+        FXMLLoader fxmlLoader = new FXMLLoader(this.getClass().getResource("App.fxml"));
+        root = fxmlLoader.load();
+        controller = fxmlLoader.getController();
+        stage.setScene(new Scene(root));
+        stage.show();
+    }
+
+    public Parent getRootNode() {
+        return root;
+    }
+
+    private String enterLabel = """
+        E
+        n
+        t
+        e
+        r
+        """.stripTrailing();
+
+    private void click(String... labels) {
+        for (var label : labels) {
+            clickOn(LabeledMatchers.hasText(label));
+        }
+    }
+
+    private String getOperandString() {
+        return ((Label) getRootNode().lookup("#operandView")).getText();
+    }
+
+    private ListView<Double> getOperandsView() {
+        return (ListView<Double>) getRootNode().lookup("#operandsView");
+    }
+
+    private void checkView(double... operands) {
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], controller.getCalc().peekOperand(i), "Wrong value at #" + i + " of operand stack");
+        }
+        List<Double> viewItems = getOperandsView().getItems();
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], viewItems.get(viewItems.size() - i - 1), "Wrong value at #" + i + " of operands view");
+        }
+    }
+
+    private void checkView(String operandString, double... operands) {
+        Assertions.assertEquals(operandString, getOperandString());
+        checkView(operands);
+    }
+
+    // see https://www.baeldung.com/parameterized-tests-junit-5
+    // about @ParameterizedTest
+
+    @ParameterizedTest
+    @MethodSource
+    public void testClicksOperand(String labels, String operandString) {
+        for (var label : labels.split(" ")) {
+            click(label);
+        }
+        checkView(operandString);
+    }
+
+    private static Stream<Arguments> testClicksOperand() {
+        return Stream.of(
+            Arguments.of("2 7", "27"),
+            Arguments.of("2 7 .", "27."),
+            Arguments.of("2 7 . 5", "27.5"),
+            Arguments.of("2 7 . 5 .", "27.")
+        );
+    }
+    
+    @ParameterizedTest
+    @MethodSource
+    public void testClicksOperands(String labels, String operandsString) {
+        for (var label : labels.split(" ")) {
+            click(label.equals("\n") ? enterLabel : label);
+        }
+        checkView("", Stream.of(operandsString.split(" ")).mapToDouble(Double::valueOf).toArray());
+    }
+
+    private static Stream<Arguments> testClicksOperands() {
+        return Stream.of(
+            Arguments.of("2 7 . 5 \n", "27.5"),
+            Arguments.of("2 7 \n", "27.0"),
+            Arguments.of("2 \n 7 \n 5 \n", "5.0", "7.0", "2.0"),
+            Arguments.of("2 7 . \n", "27.0"),
+            Arguments.of("2 7 . 5 \n", "27.5"),
+            Arguments.of("2 \n 7 +", "9.0"),
+            Arguments.of("2 \n 7 -", "-5.0"),
+            Arguments.of("2 \n 7 *", "14.0"),
+            Arguments.of("6 \n 3 /", "2.0"),
+            Arguments.of("2 5 \n √", "5.0")
+        );
+    }
+
+    @Test
+    public void testPi() {
+        click("π");
+        checkView("", Math.PI);
+    }
+}
diff --git a/modules-template/ui/src/test/java/ui/README.md b/modules-template/ui/src/test/java/ui/README.md
new file mode 100644
index 0000000..0cc5980
--- /dev/null
+++ b/modules-template/ui/src/test/java/ui/README.md
@@ -0,0 +1,38 @@
+# Tests for the RPN calculator
+
+This folder/package contains tests based on TestFX for the RPN Calculator (currently only one test class).
+
+As can be seen when launching, the app contains a list (top) showing the operands
+(topmost operand at the bottom), a text field (below list, initially empty) for a new operand and
+the buttons for digits, enter, decimal point, operations etc.
+
+## What is tested
+
+The tests simulate clicks on the buttons and checks that the underlying Calc object,
+the list (a view of the Calc object's operand stack) and the text field are updated as expected.
+E.g. if you click buttons `2 3 . 5` the string `23.5` should be shown,
+while the list is not affected. If you then click `enter`, the text field should be emptied, the operand stack should have `23.5` at the top and the list should have `23.5` at the bottom 
+(logically the top of the operand stack).
+
+Below are the specific cases that are tested.
+
+buttons to click `=>` text field content:
+
+- `2 7` => `27`
+- `2 7 .` => `27.`
+- `2 7 . 5` => `27.5`
+- `2 7 . 5 .` => `27.` (cut at decimal point)
+
+buttons to click `=>` operand stack/list content (from the bottom):
+
+- `2 7 . 5 enter"` => `27.5`
+- `2 7 enter` => `27.0"`
+- `2 enter 7 enter 5 enter` => `5.0 7.0 2.0` 
+- `2 7 . enter` => `27.0` 
+- `2 7 . 5 enter` => `27.5` 
+- `2 enter 7 +` => `9.0` 
+- `2 enter 7 -` => `-5.0` 
+- `2 enter 7 *` => `14.0` 
+- `6 enter 3 /` => `2.0` 
+- `2 5 enter √` => `5.0` 
+- `π` => `3.1415...` (the value of the `Math.PI` constant)
diff --git a/packages-template/.gitignore b/packages-template/.gitignore
new file mode 100644
index 0000000..2131639
--- /dev/null
+++ b/packages-template/.gitignore
@@ -0,0 +1,2 @@
+# ignore maven build folder
+target/
diff --git a/packages-template/pom.xml b/packages-template/pom.xml
new file mode 100644
index 0000000..ccbf72d
--- /dev/null
+++ b/packages-template/pom.xml
@@ -0,0 +1,103 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>it1901</groupId>
+    <artifactId>packages-template</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <maven.compiler.source>16</maven.compiler.source>
+        <maven.compiler.target>16</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+
+        <!-- javafx -->
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-controls</artifactId>
+            <version>16</version>
+        </dependency>
+        <dependency>
+            <groupId>org.openjfx</groupId>
+            <artifactId>javafx-fxml</artifactId>
+            <version>16</version>
+        </dependency>
+
+        <!-- junit testing with jupiter -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <version>5.7.2</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+            <version>5.7.2</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+            <version>5.7.2</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- test javafx with TextFX -->
+		<dependency>
+			<groupId>org.testfx</groupId>
+			<artifactId>testfx-core</artifactId>
+			<version>4.0.16-alpha</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.testfx</groupId>
+			<artifactId>testfx-junit5</artifactId>
+			<version>4.0.16-alpha</version>
+			<scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest</artifactId>
+            <version>2.2</version>
+            <scope>test</scope>
+        </dependency>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.8.1</version>
+                <configuration>
+                    <release>16</release>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+        		<artifactId>maven-surefire-plugin</artifactId>
+		        <version>3.0.0-M5</version>
+	        </plugin>
+
+            <plugin>
+                <groupId>org.openjfx</groupId>
+                <artifactId>javafx-maven-plugin</artifactId>
+                <version>0.0.6</version>
+                <executions>
+                    <execution>
+                        <!-- Default configuration for running -->
+                        <!-- Usage: mvn clean javafx:run -->
+                        <id>default-cli</id>
+                        <configuration>
+                            <mainClass>ui.App</mainClass>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/packages-template/src/main/java/core/Calc.java b/packages-template/src/main/java/core/Calc.java
new file mode 100644
index 0000000..f50679d
--- /dev/null
+++ b/packages-template/src/main/java/core/Calc.java
@@ -0,0 +1,108 @@
+package core;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+
+public class Calc {
+    
+    private final List<Double> operandStack;
+
+    public Calc(double... operands) {
+        operandStack = new ArrayList<>(operands.length + 2);
+        for (var d : operands) {
+            operandStack.add(d);
+        }
+    }
+
+    /**
+     * @return the number of operands on the stack
+     */
+    public int getOperandCount() {
+        return operandStack.size();
+    }
+
+    /**
+     * Pushes a new operand onto top of the stack.
+     * @param d the new operand
+     */
+    public void pushOperand(double d) {
+        operandStack.add(d);
+    }
+
+    /**
+     * @param n the place (from the top) to peek
+     * @return the n'th operand from the top
+     * @throws IllegalArgumentException if n is larger than the operand count
+     */
+    public double peekOperand(int n) {
+        if (n > getOperandCount()) {
+            throw new IllegalArgumentException("Cannot peek at position " + n + " when the operand count is " + getOperandCount());
+        }
+        return operandStack.get(getOperandCount() - n - 1);
+    }
+
+    /**
+     * @return the top operand
+     */
+    public double peekOperand() {
+        return peekOperand(0);
+    }
+
+    /**
+     * Removes and returns the top operand.
+     * @return the top operand
+     * @throws IllegalStateException if the stack is empty
+     */
+    public double popOperand() {
+        if (getOperandCount() == 0) {
+            throw new IllegalStateException("Cannot pop from an empty stack");
+        }
+        return operandStack.remove(operandStack.size() - 1);
+    }
+    
+    /**
+     * Performs the provided operation in the top operand, and
+     * replaces it with the result.
+     * @param op the operation to perform
+     * @return the result of performing the operation
+     * @throws IllegalStateException if the operand stack is empty
+     */
+    public double performOperation(UnaryOperator<Double> op) throws IllegalStateException {
+        // TODO
+        return 0.0;
+    }
+
+    /**
+     * Performs the provided operation in the two topmost operands, and
+     * replaces them with the result.
+     * @param op the operation to perform
+     * @return the result of performing the operation
+     * @throws IllegalStateException if the operand count is less than two
+     */
+    public double performOperation(BinaryOperator<Double> op) throws IllegalStateException {
+        if (getOperandCount() < 2) {
+            throw new IllegalStateException("Too few operands (" + getOperandCount() + ") on the stack");
+        }
+        var op1 = popOperand();
+        var op2 = popOperand();
+        var result = op.apply(op1, op2);
+        pushOperand(result);
+        return result;
+    }
+
+    /**
+     * Swaps the two topmost operands.
+     */
+    public void swap() {
+        // TODO
+    }
+
+    /**
+     * Duplicates the top operand.
+     */
+    public void dup() {
+        // TODO
+    }
+}
\ No newline at end of file
diff --git a/packages-template/src/main/java/ui/App.java b/packages-template/src/main/java/ui/App.java
new file mode 100644
index 0000000..acead69
--- /dev/null
+++ b/packages-template/src/main/java/ui/App.java
@@ -0,0 +1,27 @@
+package ui;
+
+import javafx.application.Application;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+
+import java.io.IOException;
+
+/**
+ * JavaFX App
+ */
+public class App extends Application {
+
+    @Override
+    public void start(Stage stage) throws IOException {
+        FXMLLoader fxmlLoader = new FXMLLoader(this.getClass().getResource("App.fxml"));
+        Parent parent = fxmlLoader.load();
+        stage.setScene(new Scene(parent));
+        stage.show();
+    }
+
+    public static void main(String[] args) {
+        launch();
+    }
+}
\ No newline at end of file
diff --git a/packages-template/src/main/java/ui/AppController.java b/packages-template/src/main/java/ui/AppController.java
new file mode 100644
index 0000000..2002d48
--- /dev/null
+++ b/packages-template/src/main/java/ui/AppController.java
@@ -0,0 +1,135 @@
+package ui;
+
+import core.Calc;
+
+import java.util.List;
+import java.util.function.BinaryOperator;
+import java.util.function.UnaryOperator;
+
+import javafx.event.ActionEvent;
+import javafx.fxml.FXML;
+import javafx.scene.control.Label;
+import javafx.scene.control.Labeled;
+import javafx.scene.control.ListView;
+
+public class AppController {
+
+    private Calc calc;
+
+    public AppController() {
+        calc = new Calc(0.0, 0.0, 0.0);
+    }
+
+    public Calc getCalc() {
+        return calc;
+    }
+
+    public void setCalc(Calc calc) {
+        this.calc = calc;
+        updateOperandsView();
+    }
+
+    @FXML
+    private ListView<Double> operandsView;
+
+    @FXML
+    private Label operandView;
+
+    @FXML
+    void initialize() {
+        updateOperandsView();
+    }
+
+    private void updateOperandsView() {
+        List<Double> operands = operandsView.getItems();
+        operands.clear();
+        int elementCount = Math.min(calc.getOperandCount(), 3);
+        for (int i = 0; i < elementCount; i++) {
+            operands.add(calc.peekOperand(elementCount - i - 1));
+        }
+    }
+
+    private String getOperandString() {
+        return operandView.getText();
+    }
+
+    private boolean hasOperand() {
+        return ! getOperandString().isBlank();
+    }
+
+    private double getOperand() {
+        return Double.valueOf(operandView.getText());
+    }
+    
+    private void setOperand(String operandString) {
+        operandView.setText(operandString);
+    }
+
+    @FXML
+    void handleEnter() {
+        if (hasOperand()) {
+            calc.pushOperand(getOperand());
+        } else {
+            calc.dup();
+        }
+        setOperand("");
+        updateOperandsView();
+    }
+
+    private void appendToOperand(String s) {
+        // TODO
+    }
+
+    @FXML
+    void handleDigit(ActionEvent ae) {
+        if (ae.getSource() instanceof Labeled l) {
+            // TODO append button label to operand
+        }
+    }
+
+    @FXML
+    void handlePoint() {
+        var operandString = getOperandString();
+        if (operandString.contains(".")) {
+            // TODO remove characters after point
+        } else {
+            // TODO append point
+        }
+    }
+
+    @FXML
+    void handleClear() {
+        // TODO clear operand
+    }
+
+    @FXML
+    void handleSwap() {
+        // TODO clear operand
+    }
+
+    private void performOperation(UnaryOperator<Double> op) {
+        // TODO
+    }
+
+    private void performOperation(boolean swap, BinaryOperator<Double> op) {
+        if (hasOperand()) {
+            // TODO push operand first
+        }
+        // TODO perform operation, but swap first if needed
+    }
+
+    @FXML
+    void handleOpAdd() {
+        // TODO
+    }
+
+    @FXML
+    void handleOpSub() {
+        // TODO
+    }
+
+    @FXML
+    void handleOpMult() {
+        // TODO
+    }
+}
diff --git a/packages-template/src/main/resources/ui/App.fxml b/packages-template/src/main/resources/ui/App.fxml
new file mode 100644
index 0000000..ff280af
--- /dev/null
+++ b/packages-template/src/main/resources/ui/App.fxml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import javafx.scene.layout.GridPane?>
+<?import javafx.scene.control.Label?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ListView?>
+
+<GridPane xmlns="http://javafx.com/javafx/8.0.171" xmlns:fx="http://javafx.com/fxml/1" fx:controller="ui.AppController"
+         alignment="CENTER" hgap="10.0" vgap="10.0" >
+
+   <ListView fx:id="operandsView" prefHeight="80.0"
+      GridPane.rowIndex="0" GridPane.columnIndex="0" GridPane.columnSpan="4"/>
+   <Label text="" fx:id="operandView"
+      GridPane.rowIndex="1" GridPane.columnIndex="0" GridPane.columnSpan="4"/>
+
+   <!-- multi-line button label with XML entity for newline -->
+   <Button text="E&#10;n&#10;t&#10;e&#10;r" onAction="#handleEnter"
+      GridPane.rowIndex="2" GridPane.columnIndex="3" GridPane.rowSpan="3"/>
+
+   <Button text="7" onAction="#handleDigit"
+      GridPane.rowIndex="2" GridPane.columnIndex="0"/>
+   <Button text="8" onAction="#handleDigit"
+      GridPane.rowIndex="2" GridPane.columnIndex="1"/>
+   <Button text="9" onAction="#handleDigit"
+      GridPane.rowIndex="2" GridPane.columnIndex="2"/>
+   
+   <Button text="4" onAction="#handleDigit"
+      GridPane.rowIndex="3" GridPane.columnIndex="0"/>
+   <Button text="5" onAction="#handleDigit"
+      GridPane.rowIndex="3" GridPane.columnIndex="1"/>
+   <Button text="6" onAction="#handleDigit"
+      GridPane.rowIndex="3" GridPane.columnIndex="2"/>
+   
+   <Button text="1" onAction="#handleDigit"
+      GridPane.rowIndex="4" GridPane.columnIndex="0"/>
+   <Button text="2" onAction="#handleDigit"
+      GridPane.rowIndex="4" GridPane.columnIndex="1"/>
+   <Button text="3" onAction="#handleDigit"
+      GridPane.rowIndex="4" GridPane.columnIndex="2"/>
+   
+   <Button text="0" onAction="#handleDigit"
+      GridPane.rowIndex="5" GridPane.columnIndex="0"/>
+   <Button text="." onAction="#handlePoint"
+      GridPane.rowIndex="5" GridPane.columnIndex="1"/>
+   <Button text="C" onAction="#handleClear"
+      GridPane.rowIndex="5" GridPane.columnIndex="2"/>
+   <Button text="~" onAction="#handleSwap"
+      GridPane.rowIndex="5" GridPane.columnIndex="3"/>
+
+   <Button text="+" onAction="#handleOpAdd"
+      GridPane.rowIndex="6" GridPane.columnIndex="0"/>
+   <Button text="-" onAction="#handleOpSub"
+      GridPane.rowIndex="6" GridPane.columnIndex="1"/>
+   <Button text="*" onAction="#handleOpMult"
+      GridPane.rowIndex="6" GridPane.columnIndex="2"/>
+
+   <!-- TODO -->
+   <Button text="/"
+      GridPane.rowIndex="6" GridPane.columnIndex="3"/>
+   <Button text="√"
+      GridPane.rowIndex="7" GridPane.columnIndex="0"/>
+   <Button text="π"
+      GridPane.rowIndex="7" GridPane.columnIndex="1"/>
+</GridPane>
diff --git a/packages-template/src/test/java/core/CalcTest.java b/packages-template/src/test/java/core/CalcTest.java
new file mode 100644
index 0000000..0207cd5
--- /dev/null
+++ b/packages-template/src/test/java/core/CalcTest.java
@@ -0,0 +1,114 @@
+package core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class CalcTest {
+
+    private static void checkCalc(Calc calc, double... operands) {
+        Assertions.assertEquals(operands.length, calc.getOperandCount(), "Wrong operand count");
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], calc.peekOperand(i), "Wrong value at #" + i + " of operand stack");
+        }
+    }
+
+    @Test
+    public void testCalc() {
+        checkCalc(new Calc());
+        checkCalc(new Calc(1.0), 1.0);
+        checkCalc(new Calc(3.14, 1.0), 1.0, 3.14);
+    }
+
+    @Test
+    public void testPushOperand() {
+        Calc calc = new Calc();
+        calc.pushOperand(1.0);
+        checkCalc(calc, 1.0);
+        calc.pushOperand(3.14);
+        checkCalc(calc, 3.14, 1.0);
+    }
+
+    @Test
+    public void testPeekOperand() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.peekOperand());
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new Calc().peekOperand());
+    }
+
+    @Test
+    public void testPeekOperandN() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.peekOperand(0));
+        Assertions.assertEquals(1.0, calc.peekOperand(1));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> calc.peekOperand(2));
+    }
+
+    @Test
+    public void testPopOperand() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.popOperand());
+        checkCalc(calc, 1.0);
+        Assertions.assertEquals(1.0, calc.popOperand());
+        checkCalc(calc);
+    }
+
+    @Test
+    public void testPopOperand_emptyStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().popOperand());
+    }
+
+    @Test
+    public void testPerformOperation1() {
+        Calc calc = new Calc(1.0);
+        Assertions.assertEquals(-1.0, calc.performOperation(n -> -n));
+        checkCalc(calc, -1.0);
+    }
+
+    @Test
+    public void testPerformOperation1_emptyOperandStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().performOperation(n -> -n));
+    }
+
+
+    @Test
+    public void testPerformOperation2() {
+        Calc calc = new Calc(1.0, 3.0);
+        Assertions.assertEquals(-2.0, calc.performOperation((n1, n2) -> n1 - n2));
+        checkCalc(calc, -2.0);
+    }
+
+    @Test
+    public void testPerformOperation2_lessThanTwoOperands() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc(1.0).performOperation((n1, n2) -> n1 - n2));
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().performOperation((n1, n2) -> n1 - n2));
+    }
+
+    @Test
+    public void testSwap() {
+        Calc calc = new Calc(1.0, 3.14);
+        calc.swap();
+        checkCalc(calc, 3.14, 1.0);
+        calc.swap();
+        checkCalc(calc, 1.0, 3.14);
+    }
+
+    @Test
+    public void testSwap_lessThanTwoOperands() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc(1.0).swap());
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().swap());
+    }
+
+    @Test
+    public void testDup() {
+        Calc calc = new Calc(1.0, 3.14);
+        Assertions.assertEquals(3.14, calc.popOperand());
+        checkCalc(calc, 1.0);
+        Assertions.assertEquals(1.0, calc.popOperand());
+        checkCalc(calc);
+    }
+
+    @Test
+    public void testDup_emptyOperandStack() {
+        Assertions.assertThrows(IllegalStateException.class, () -> new Calc().dup());
+    }
+}
diff --git a/packages-template/src/test/java/ui/AppTest.java b/packages-template/src/test/java/ui/AppTest.java
new file mode 100644
index 0000000..3c6e991
--- /dev/null
+++ b/packages-template/src/test/java/ui/AppTest.java
@@ -0,0 +1,130 @@
+package ui;
+
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.scene.Scene;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.stage.Stage;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.testfx.framework.junit5.ApplicationTest;
+import org.testfx.matcher.control.LabeledMatchers;
+
+/**
+ * TestFX App test
+ */
+public class AppTest extends ApplicationTest {
+
+    private AppController controller;
+    private Parent root;
+
+    @Override
+    public void start(Stage stage) throws IOException {
+        FXMLLoader fxmlLoader = new FXMLLoader(this.getClass().getResource("App.fxml"));
+        root = fxmlLoader.load();
+        controller = fxmlLoader.getController();
+        stage.setScene(new Scene(root));
+        stage.show();
+    }
+
+    public Parent getRootNode() {
+        return root;
+    }
+
+    private String enterLabel = """
+        E
+        n
+        t
+        e
+        r
+        """.stripTrailing();
+
+    private void click(String... labels) {
+        for (var label : labels) {
+            clickOn(LabeledMatchers.hasText(label));
+        }
+    }
+
+    private String getOperandString() {
+        return ((Label) getRootNode().lookup("#operandView")).getText();
+    }
+
+    private ListView<Double> getOperandsView() {
+        return (ListView<Double>) getRootNode().lookup("#operandsView");
+    }
+
+    private void checkView(double... operands) {
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], controller.getCalc().peekOperand(i), "Wrong value at #" + i + " of operand stack");
+        }
+        List<Double> viewItems = getOperandsView().getItems();
+        for (int i = 0; i < operands.length; i++) {
+            Assertions.assertEquals(operands[i], viewItems.get(viewItems.size() - i - 1), "Wrong value at #" + i + " of operands view");
+        }
+    }
+
+    private void checkView(String operandString, double... operands) {
+        Assertions.assertEquals(operandString, getOperandString());
+        checkView(operands);
+    }
+
+    // see https://www.baeldung.com/parameterized-tests-junit-5
+    // about @ParameterizedTest
+
+    @ParameterizedTest
+    @MethodSource
+    public void testClicksOperand(String labels, String operandString) {
+        for (var label : labels.split(" ")) {
+            click(label);
+        }
+        checkView(operandString);
+    }
+
+    private static Stream<Arguments> testClicksOperand() {
+        return Stream.of(
+            Arguments.of("2 7", "27"),
+            Arguments.of("2 7 .", "27."),
+            Arguments.of("2 7 . 5", "27.5"),
+            Arguments.of("2 7 . 5 .", "27.")
+        );
+    }
+    
+    @ParameterizedTest
+    @MethodSource
+    public void testClicksOperands(String labels, String operandsString) {
+        for (var label : labels.split(" ")) {
+            click(label.equals("\n") ? enterLabel : label);
+        }
+        checkView("", Stream.of(operandsString.split(" ")).mapToDouble(Double::valueOf).toArray());
+    }
+
+    private static Stream<Arguments> testClicksOperands() {
+        return Stream.of(
+            Arguments.of("2 7 . 5 \n", "27.5"),
+            Arguments.of("2 7 \n", "27.0"),
+            Arguments.of("2 \n 7 \n 5 \n", "5.0", "7.0", "2.0"),
+            Arguments.of("2 7 . \n", "27.0"),
+            Arguments.of("2 7 . 5 \n", "27.5"),
+            Arguments.of("2 \n 7 +", "9.0"),
+            Arguments.of("2 \n 7 -", "-5.0"),
+            Arguments.of("2 \n 7 *", "14.0"),
+            Arguments.of("6 \n 3 /", "2.0"),
+            Arguments.of("2 5 \n √", "5.0")
+        );
+    }
+
+    @Test
+    public void testPi() {
+        click("π");
+        checkView("", Math.PI);
+    }
+}
diff --git a/packages-template/src/test/java/ui/README.md b/packages-template/src/test/java/ui/README.md
new file mode 100644
index 0000000..0cc5980
--- /dev/null
+++ b/packages-template/src/test/java/ui/README.md
@@ -0,0 +1,38 @@
+# Tests for the RPN calculator
+
+This folder/package contains tests based on TestFX for the RPN Calculator (currently only one test class).
+
+As can be seen when launching, the app contains a list (top) showing the operands
+(topmost operand at the bottom), a text field (below list, initially empty) for a new operand and
+the buttons for digits, enter, decimal point, operations etc.
+
+## What is tested
+
+The tests simulate clicks on the buttons and checks that the underlying Calc object,
+the list (a view of the Calc object's operand stack) and the text field are updated as expected.
+E.g. if you click buttons `2 3 . 5` the string `23.5` should be shown,
+while the list is not affected. If you then click `enter`, the text field should be emptied, the operand stack should have `23.5` at the top and the list should have `23.5` at the bottom 
+(logically the top of the operand stack).
+
+Below are the specific cases that are tested.
+
+buttons to click `=>` text field content:
+
+- `2 7` => `27`
+- `2 7 .` => `27.`
+- `2 7 . 5` => `27.5`
+- `2 7 . 5 .` => `27.` (cut at decimal point)
+
+buttons to click `=>` operand stack/list content (from the bottom):
+
+- `2 7 . 5 enter"` => `27.5`
+- `2 7 enter` => `27.0"`
+- `2 enter 7 enter 5 enter` => `5.0 7.0 2.0` 
+- `2 7 . enter` => `27.0` 
+- `2 7 . 5 enter` => `27.5` 
+- `2 enter 7 +` => `9.0` 
+- `2 enter 7 -` => `-5.0` 
+- `2 enter 7 *` => `14.0` 
+- `6 enter 3 /` => `2.0` 
+- `2 5 enter √` => `5.0` 
+- `π` => `3.1415...` (the value of the `Math.PI` constant)
-- 
GitLab