diff --git a/use-gui/src/it/java/org/tzi/use/main/shell/ShellIT.java b/use-gui/src/it/java/org/tzi/use/main/shell/ShellIT.java index 95f96f193..a4f39ecf4 100644 --- a/use-gui/src/it/java/org/tzi/use/main/shell/ShellIT.java +++ b/use-gui/src/it/java/org/tzi/use/main/shell/ShellIT.java @@ -3,7 +3,9 @@ import com.github.difflib.text.DiffRow; import com.github.difflib.text.DiffRowGenerator; import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.io.TempDir; import org.tzi.use.config.Options; import org.tzi.use.main.gui.Main; import org.tzi.use.util.USEWriter; @@ -24,6 +26,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.fail; /** @@ -195,6 +199,99 @@ private void writeToFile(List data, Path file) { } } + /** + * Issue #32: the generated command file must always end with an + * {@code exit} command so USE terminates even if the {@code .in} file does + * not contain an explicit {@code exit}. + */ + @Test + public void generatedCommandFileAlwaysEndsWithExit(@TempDir Path dir) throws IOException { + Path inFile = dir.resolve("noexit.in"); + Files.writeString(inFile, "?1 + 1" + System.lineSeparator() + "*-> 2 : Integer" + System.lineSeparator()); + Path cmdFile = dir.resolve("noexit.in.cmd"); + + createCommandFile(inFile, cmdFile); + + List cmdLines = readNonBlankLines(cmdFile); + assertEquals("exit", cmdLines.get(cmdLines.size() - 1), + "The generated command file must always end with an exit command."); + } + + /** + * The appended {@code exit} is echoed by USE, so it must be part of the + * expected output for an {@code .in} file that did not terminate itself. + */ + @Test + public void appendedExitIsAddedToExpectedOutput(@TempDir Path dir) throws IOException { + Path inFile = dir.resolve("noexit.in"); + Files.writeString(inFile, "?1 + 1" + System.lineSeparator() + "*-> 2 : Integer" + System.lineSeparator()); + Path cmdFile = dir.resolve("noexit.in.cmd"); + + List expected = createCommandFile(inFile, cmdFile); + + assertEquals("exit", expected.get(expected.size() - 1), + "The appended exit must be part of the expected output."); + } + + /** + * Tests that load an invalid model make USE exit by itself before the + * command file is read. Such tests are marked with {@code #expected exit} + * and must not expect the (never executed) appended exit. + */ + @Test + public void selfExitingTestsDoNotExpectTheAppendedExit(@TempDir Path dir) throws IOException { + Path inFile = dir.resolve("selfexit.in"); + Files.writeString(inFile, "#expected exit" + System.lineSeparator() + + "?1 + 1" + System.lineSeparator() + "*-> 2 : Integer" + System.lineSeparator()); + Path cmdFile = dir.resolve("selfexit.in.cmd"); + + List expected = createCommandFile(inFile, cmdFile); + + assertFalse(expected.contains("exit"), + "A test marked with '#expected exit' must not expect the appended exit."); + } + + /** + * If the {@code .in} file already ends with an explicit {@code exit}, the + * appended exit is never executed, so {@code exit} must appear only once in + * the expected output. + */ + @Test + public void exitIsNotDuplicatedWhenInFileAlreadyEndsWithExit(@TempDir Path dir) throws IOException { + Path inFile = dir.resolve("withexit.in"); + Files.writeString(inFile, "?1 + 1" + System.lineSeparator() + "*-> 2 : Integer" + + System.lineSeparator() + "exit" + System.lineSeparator()); + Path cmdFile = dir.resolve("withexit.in.cmd"); + + List expected = createCommandFile(inFile, cmdFile); + + assertEquals(1, expected.stream().filter("exit"::equals).count(), + "exit must appear exactly once in the expected output."); + } + + /** + * The {@code quit} (and {@code q}) alias also terminates USE, so an .in file + * ending with it must not expect the (never executed) appended exit. + */ + @Test + public void quitTerminatedTestsDoNotExpectTheAppendedExit(@TempDir Path dir) throws IOException { + Path inFile = dir.resolve("withquit.in"); + Files.writeString(inFile, "?1 + 1" + System.lineSeparator() + "*-> 2 : Integer" + + System.lineSeparator() + "quit" + System.lineSeparator()); + Path cmdFile = dir.resolve("withquit.in.cmd"); + + List expected = createCommandFile(inFile, cmdFile); + + assertFalse(expected.contains("exit"), + "A test that terminates with quit must not expect the appended exit."); + } + + private List readNonBlankLines(Path file) throws IOException { + return Files.readAllLines(file, StandardCharsets.UTF_8).stream() + .filter(line -> !line.isBlank()) + .collect(Collectors.toList()); + } + /** * Creates a USE-command file at the position located by the path {@code cmdFile}. * The file contains all commands that are specified in the {@code inFile}. @@ -207,47 +304,78 @@ private void writeToFile(List data, Path file) { private List createCommandFile(Path inFile, Path cmdFile) { List expectedOutput = new LinkedList<>(); + // Whether USE is expected to terminate by itself before the command + // file is read (e.g. because the test loads an invalid model). Such + // tests are marked with a '#expected exit' comment. + boolean selfExits = false; + // The last command written to the command file. Used to avoid adding a + // duplicate exit to the expected output if the .in file already ends + // with an explicit exit. + String lastCommand = null; + // Build USE command file and build expected output try ( - Stream linesStream = Files.lines(inFile, StandardCharsets.UTF_8); FileWriter cmdWriter = new FileWriter(cmdFile.toFile(), StandardCharsets.UTF_8, false) ) { - - linesStream.forEach(inputLine -> { + for (String inputLine : Files.readAllLines(inFile, StandardCharsets.UTF_8)) { // Ignore empty lines in expected, since they are also suppressed in the actual output if (inputLine.isBlank()) - return; + continue; if ((inputLine.startsWith("*") || inputLine.startsWith("#")) && inputLine.substring(1).isBlank()) { - return; + continue; } if (inputLine.startsWith("*")) { // Input line minus prefix(*) is expected output expectedOutput.add(inputLine.substring(1).trim()); - } else if (!inputLine.startsWith("#")) { // Not a comment - try { - cmdWriter.write(inputLine); - cmdWriter.write(System.lineSeparator()); - - // Multi-line commands (backslash and dot) are ignored - if (!inputLine.matches("^[\\\\.]$")) { - expectedOutput.add(inputLine); - } - } catch (IOException e1) { - fail("Could not write USE command file for test!", e1); + } else if (inputLine.startsWith("#")) { + // Comment line. The '#expected exit' marker denotes tests in + // which USE exits by itself (e.g. on an invalid model). + if (inputLine.substring(1).trim().equals("expected exit")) { + selfExits = true; + } + } else { // A command + cmdWriter.write(inputLine); + cmdWriter.write(System.lineSeparator()); + + // Multi-line commands (backslash and dot) are ignored + if (!inputLine.matches("^[\\\\.]$")) { + expectedOutput.add(inputLine); } + lastCommand = inputLine.trim(); } - }); + } + + // Issue #32: always terminate the command file with an exit so USE + // does not keep running when an .in file forgot to add one. + cmdWriter.write("exit"); + cmdWriter.write(System.lineSeparator()); } catch (IOException e) { fail("Could not write USE command file for test!", e); } + // The appended exit is echoed by USE and therefore part of the output, + // but only when it is actually executed: not when USE exits by itself + // before reading the command file (#expected exit), and not when the + // .in file already terminated with its own exit/quit (which runs first). + if (!selfExits && !isExitCommand(lastCommand)) { + expectedOutput.add("exit"); + } + return expectedOutput; } + /** + * Whether the given command terminates USE, i.e., is one of {@code q}, + * {@code quit} or {@code exit} (see {@code Shell.processLine}). + */ + private static boolean isExitCommand(String command) { + return "exit".equals(command) || "quit".equals(command) || "q".equals(command); + } + /** * Executes USE with the given {@code useFile} as the model * and the {@code cmdFile} to execute commands. diff --git a/use-gui/src/it/resources/testfiles/shell/t053.in b/use-gui/src/it/resources/testfiles/shell/t053.in index 93ec4a673..6fc63a9f1 100644 --- a/use-gui/src/it/resources/testfiles/shell/t053.in +++ b/use-gui/src/it/resources/testfiles/shell/t053.in @@ -1 +1,2 @@ +#expected exit *t053.use:6:2: Missing return type for OCL query operation `firstSecond'. diff --git a/use-gui/src/it/resources/testfiles/shell/t073.in b/use-gui/src/it/resources/testfiles/shell/t073.in index 0f2cbb872..2d502d434 100644 --- a/use-gui/src/it/resources/testfiles/shell/t073.in +++ b/use-gui/src/it/resources/testfiles/shell/t073.in @@ -1 +1,2 @@ +#expected exit *t073.use:55:2: Missing return type for OCL query operation `numOfArgs'. diff --git a/use-gui/src/it/resources/testfiles/shell/t085.in b/use-gui/src/it/resources/testfiles/shell/t085.in index 106787033..8bc0ffa41 100644 --- a/use-gui/src/it/resources/testfiles/shell/t085.in +++ b/use-gui/src/it/resources/testfiles/shell/t085.in @@ -1,2 +1,3 @@ +#expected exit *t085.use:14:2: Missing return type for OCL query operation `connectedPlusAux'. *t085.use:12:4: Undefined operation named `connectedPlusAux' in expression `Town.connectedPlusAux(Set(Town))'. diff --git a/use-gui/src/it/resources/testfiles/shell/t088.in b/use-gui/src/it/resources/testfiles/shell/t088.in index b8a66afb2..faf2f60ef 100644 --- a/use-gui/src/it/resources/testfiles/shell/t088.in +++ b/use-gui/src/it/resources/testfiles/shell/t088.in @@ -1,2 +1,3 @@ +#expected exit *t088.use:14:2: Missing return type for OCL query operation `connectedPlusAux'. *t088.use:12:4: Undefined operation named `connectedPlusAux' in expression `Town.connectedPlusAux(Set(Town))'. diff --git a/use-gui/src/it/resources/testfiles/shell/t098.in b/use-gui/src/it/resources/testfiles/shell/t098.in index 3f15f3740..abc2404f3 100644 --- a/use-gui/src/it/resources/testfiles/shell/t098.in +++ b/use-gui/src/it/resources/testfiles/shell/t098.in @@ -1,3 +1,4 @@ +#expected exit *t098.use:88:6: Class `LoyaltyAccount' already contains an operation named `isEmpty'. *t098.use:19:16: Expression `(result = self.partners->collect($e : ProgramPartner | $e.deliveredServices))' can never evaluate to true because `Set(Service)' and `Bag(Service)' are unrelated. *You can change this check using the -extendedTypeSystemChecks switch. diff --git a/use-gui/src/it/resources/testfiles/shell/t104.in b/use-gui/src/it/resources/testfiles/shell/t104.in index a291fdace..13eafb8d1 100644 --- a/use-gui/src/it/resources/testfiles/shell/t104.in +++ b/use-gui/src/it/resources/testfiles/shell/t104.in @@ -1 +1,2 @@ +#expected exit *t104.use:9:12: In association `A': Model already contains a class `A'. \ No newline at end of file diff --git a/use-gui/src/it/resources/testfiles/shell/t126.in b/use-gui/src/it/resources/testfiles/shell/t126.in index 2508568f2..2a0cc1911 100644 --- a/use-gui/src/it/resources/testfiles/shell/t126.in +++ b/use-gui/src/it/resources/testfiles/shell/t126.in @@ -3,6 +3,7 @@ !create a : A !create b : B !insert (a, b) into C +check *checking structure... *checking invariants... -*checked 0 invariants, 0 failures. \ No newline at end of file +*checked 0 invariants, 0 failures. diff --git a/use-gui/src/it/resources/testfiles/shell/t127.in b/use-gui/src/it/resources/testfiles/shell/t127.in index 85fbfbf84..872311378 100644 --- a/use-gui/src/it/resources/testfiles/shell/t127.in +++ b/use-gui/src/it/resources/testfiles/shell/t127.in @@ -2,6 +2,7 @@ !create a : A !create b : B +check *checking structure... *checking invariants... *checking invariant (1) `A::inv1': OK. diff --git a/use-gui/src/it/resources/testfiles/shell/t128.in b/use-gui/src/it/resources/testfiles/shell/t128.in index 205e546ba..a6b74804d 100644 --- a/use-gui/src/it/resources/testfiles/shell/t128.in +++ b/use-gui/src/it/resources/testfiles/shell/t128.in @@ -6,6 +6,7 @@ !set b.b := C(1,1) ? a.a = b.b *-> true : Boolean +check *checking structure... *checking invariants... -*checked 0 invariants, 0 failures. \ No newline at end of file +*checked 0 invariants, 0 failures. diff --git a/use-gui/src/it/resources/testfiles/shell/t129.in b/use-gui/src/it/resources/testfiles/shell/t129.in index 8f8fc88d2..1f1e4af1c 100644 --- a/use-gui/src/it/resources/testfiles/shell/t129.in +++ b/use-gui/src/it/resources/testfiles/shell/t129.in @@ -3,6 +3,7 @@ !create b : t129_import#B ? b *-> b : B +check *checking structure... *checking invariants... -*checked 0 invariants, 0 failures. \ No newline at end of file +*checked 0 invariants, 0 failures. diff --git a/use-gui/src/it/resources/testfiles/shell/t130.in b/use-gui/src/it/resources/testfiles/shell/t130.in index d12fa2fc2..629492d7f 100644 --- a/use-gui/src/it/resources/testfiles/shell/t130.in +++ b/use-gui/src/it/resources/testfiles/shell/t130.in @@ -4,6 +4,7 @@ !set b.b := t130_import2#C(1,1) ? b.b *-> C{x=1, y=1} : C +check *checking structure... *checking invariants... -*checked 0 invariants, 0 failures. \ No newline at end of file +*checked 0 invariants, 0 failures. diff --git a/use-gui/src/it/resources/testfiles/shell/t131.in b/use-gui/src/it/resources/testfiles/shell/t131.in index 888a5f82c..16f7ff1ee 100644 --- a/use-gui/src/it/resources/testfiles/shell/t131.in +++ b/use-gui/src/it/resources/testfiles/shell/t131.in @@ -7,6 +7,7 @@ !set a.c := t131_import#C::H ? a.c *-> C::H : C +check *checking structure... *checking invariants... -*checked 0 invariants, 0 failures. \ No newline at end of file +*checked 0 invariants, 0 failures. diff --git a/use-gui/src/it/resources/testfiles/shell/t132.in b/use-gui/src/it/resources/testfiles/shell/t132.in index e27c85ece..7ac6c6007 100644 --- a/use-gui/src/it/resources/testfiles/shell/t132.in +++ b/use-gui/src/it/resources/testfiles/shell/t132.in @@ -4,6 +4,7 @@ !set a.b := B(1,1) *:1:0: generation of expression `B(1,1)' failed, with following error: *:1:11: Undefined operation `B'. +check *checking structure... *checking invariants... -*checked 0 invariants, 0 failures. \ No newline at end of file +*checked 0 invariants, 0 failures.