From 22975ec7b1c27b18572e867c29d1026ef1a67bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 05:39:05 +0000 Subject: [PATCH] ShellIT: always append exit to generated command files (#32) Integration tests are defined by .in files. Until now each .in file had to end with an explicit exit, or the generated command file (and thus the test run) would not terminate. ShellIT now always appends an exit to the generated command file, so test authors no longer need to add one. The appended exit is echoed by USE and is therefore added to the expected output, except when: - the test loads an invalid model and USE exits by itself before the command file is read. Such tests are now marked with a "#expected exit" comment (t053, t073, t085, t088, t098, t104). - the .in file already terminates with its own exit/quit/q, which runs first so the appended exit is never executed. Because the appended exit terminates USE before its quiet-mode end-of-input "check", the valid-model tests that relied on that implicit check now run "check" explicitly (t126-t132). Adds unit tests for createCommandFile covering the appended exit, the "#expected exit" marker, and exit/quit de-duplication. https://claude.ai/code/session_01A85dAvTAvi2oWkGZH6REEq --- .../java/org/tzi/use/main/shell/ShellIT.java | 162 ++++++++++++++++-- .../src/it/resources/testfiles/shell/t053.in | 1 + .../src/it/resources/testfiles/shell/t073.in | 1 + .../src/it/resources/testfiles/shell/t085.in | 1 + .../src/it/resources/testfiles/shell/t088.in | 1 + .../src/it/resources/testfiles/shell/t098.in | 1 + .../src/it/resources/testfiles/shell/t104.in | 1 + .../src/it/resources/testfiles/shell/t126.in | 3 +- .../src/it/resources/testfiles/shell/t127.in | 1 + .../src/it/resources/testfiles/shell/t128.in | 3 +- .../src/it/resources/testfiles/shell/t129.in | 3 +- .../src/it/resources/testfiles/shell/t130.in | 3 +- .../src/it/resources/testfiles/shell/t131.in | 3 +- .../src/it/resources/testfiles/shell/t132.in | 3 +- 14 files changed, 164 insertions(+), 23 deletions(-) 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.