Skip to content

Commit 3f9a3e2

Browse files
Merge pull request #8 from code4teaching/core-tests-and-ci
Add VisuStructCore tests and CI workflow
2 parents bec9c92 + dedda13 commit 3f9a3e2

7 files changed

Lines changed: 286 additions & 2 deletions

File tree

‎.github/workflows/core-tests.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: VisuStructCore tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
swift-test:
10+
runs-on: macos-15
11+
defaults:
12+
run:
13+
working-directory: VisuStruct-iOS/VisuStructCore
14+
steps:
15+
- uses: actions/checkout@v4
16+
- name: Swift version
17+
run: swift --version
18+
- name: Build and test
19+
run: swift test

‎VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/CodeGeneration/VisuStructCodeGenerator+JavaClassFile.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ extension VisuStructCodeGenerator {
3030
}
3131
result = result.trimmingCharacters(in: CharacterSet(charactersIn: "_"))
3232
if result.isEmpty { return "Main" }
33+
// Das Trimmen oben entfernt auch den `_` vor einer führenden Ziffer.
34+
if let first = result.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
35+
result = "_" + result
36+
}
3337
let reserved: Set<String> = [
3438
"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const",
3539
"continue", "default", "do", "double", "else", "enum", "extends", "final", "finally",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import XCTest
2+
import VisuStructCore
3+
4+
final class CodeGeneratorTests: XCTestCase {
5+
/// Die mitgelieferten `.java`-Dateien sind der erwartete Export der zugehörigen Diagramme.
6+
func testJavaClassFileMatchesExamples() throws {
7+
for name in ["Collatz", "Fibonacci"] {
8+
let document = try ExampleFiles.document(named: "\(name).visustruct")
9+
let generated = VisuStructCodeGenerator.generateJavaClassFile(document: document, className: name)
10+
XCTAssertEqual(generated, try ExampleFiles.text(named: "\(name).java"), name)
11+
}
12+
}
13+
14+
func testJavaClassFileWithInputImportsScannerOnce() throws {
15+
let document = try ExampleFiles.document(named: "2026-04-23-NumberEven.visustruct")
16+
let generated = VisuStructCodeGenerator.generateJavaClassFile(document: document, className: "NumberEven")
17+
XCTAssertTrue(generated.hasPrefix("import java.util.Scanner;\n\npublic class NumberEven {\n"))
18+
XCTAssertEqual(generated.components(separatedBy: "new Scanner(System.in)").count - 1, 1)
19+
XCTAssertTrue(generated.contains("if (number % 2 == 0) {"))
20+
}
21+
22+
func testCollatzInPythonAndJavaScript() throws {
23+
let document = try ExampleFiles.document(named: "Collatz.visustruct")
24+
25+
let python = VisuStructCodeGenerator.generate(document: document, target: .python)
26+
XCTAssertTrue(python.contains("while number != 1:"), python)
27+
XCTAssertTrue(python.contains("print(number)"), python)
28+
XCTAssertFalse(python.contains("{"), python)
29+
30+
let javaScript = VisuStructCodeGenerator.generate(document: document, target: .javascript)
31+
XCTAssertTrue(javaScript.hasPrefix("\"use strict\";"), javaScript)
32+
XCTAssertTrue(javaScript.contains("while (number != 1) {"), javaScript)
33+
XCTAssertTrue(javaScript.contains("console.log(number);"), javaScript)
34+
}
35+
36+
func testSanitizedJavaClassName() {
37+
XCTAssertEqual(VisuStructCodeGenerator.sanitizedJavaClassName("Fibonacci"), "Fibonacci")
38+
let sanitized = VisuStructCodeGenerator.sanitizedJavaClassName("2 Summe bilden!")
39+
XCTAssertFalse(sanitized.isEmpty)
40+
XCTAssertFalse(sanitized.contains(" "))
41+
XCTAssertFalse(sanitized.first?.isNumber ?? true)
42+
}
43+
44+
func testEmptyDocumentProducesNonEmptyPlaceholder() {
45+
for target in [VisuStructCodeGenerator.Target.java, .python, .javascript] {
46+
let code = VisuStructCodeGenerator.generate(document: VisuStructDocument(), target: target)
47+
XCTAssertFalse(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, target.rawValue)
48+
}
49+
}
50+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import XCTest
2+
import VisuStructCore
3+
4+
/// Jede Beispieldatei muss sich laden, verlustfrei speichern und in alle Zielsprachen übersetzen lassen.
5+
final class ExampleFilesTests: XCTestCase {
6+
func testExampleDirectoryContainsDiagrams() throws {
7+
XCTAssertFalse(try ExampleFiles.allDiagramURLs().isEmpty, "Keine Beispieldateien gefunden in \(ExampleFiles.directory.path)")
8+
}
9+
10+
func testAllExamplesDecodeAndRoundTrip() throws {
11+
for url in try ExampleFiles.allDiagramURLs() {
12+
let name = url.lastPathComponent
13+
let decoded = try VisuStructXMLCodec.decode(url: url)
14+
XCTAssertFalse(decoded.elements.isEmpty, "\(name): keine Elemente")
15+
16+
let reencoded = VisuStructXMLCodec.encode(decoded)
17+
let decodedAgain = try VisuStructXMLCodec.decode(data: reencoded)
18+
XCTAssertEqual(decodedAgain, decoded, "\(name): Speichern und erneutes Laden verändert das Diagramm")
19+
}
20+
}
21+
22+
func testAllExamplesGenerateCodeForEveryTarget() throws {
23+
let targets: [VisuStructCodeGenerator.Target] = [.java, .python, .javascript]
24+
for url in try ExampleFiles.allDiagramURLs() {
25+
let name = url.lastPathComponent
26+
let document = try VisuStructXMLCodec.decode(url: url)
27+
for target in targets {
28+
let code = VisuStructCodeGenerator.generate(document: document, target: target)
29+
XCTAssertFalse(
30+
code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
31+
"\(name): leerer Code für \(target.rawValue)"
32+
)
33+
}
34+
}
35+
}
36+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import XCTest
2+
import VisuStructCore
3+
4+
final class SimulationEngineTests: XCTestCase {
5+
func testCollatzExampleOutputsSequence() throws {
6+
let state = runSimulation(try ExampleFiles.document(named: "Collatz.visustruct"))
7+
XCTAssertEqual(
8+
state.outputLines,
9+
["58", "29", "88", "44", "22", "11", "34", "17", "52", "26", "13", "40", "20", "10", "5", "16", "8", "4", "2", "1"]
10+
)
11+
XCTAssertEqual(state.variables["number"], .int(1))
12+
}
13+
14+
func testFibonacciExampleOutputsFirstTenNumbers() throws {
15+
let state = runSimulation(try ExampleFiles.document(named: "Fibonacci.visustruct"))
16+
XCTAssertEqual(state.outputLines, ["1", "1", "2", "3", "5", "8", "13", "21", "34", "55"])
17+
}
18+
19+
func testNumberEvenExampleUsesInput() throws {
20+
let document = try ExampleFiles.document(named: "2026-04-23-NumberEven.visustruct")
21+
XCTAssertEqual(runSimulation(document, inputs: ["4"]).outputLines, ["number is even"])
22+
XCTAssertEqual(runSimulation(document, inputs: ["7"]).outputLines, ["number is odd"])
23+
}
24+
25+
func testInvalidIntegerInputIsRejectedAndCanBeRetried() {
26+
let document = VisuStructDocument(elements: [
27+
VisuStructElement(type: .anweisung, textLines: ["input: int n"]),
28+
VisuStructElement(type: .anweisung, textLines: ["print: n * 2"]),
29+
])
30+
var engine = VisuStructSimulationEngine(document: document)
31+
engine.step()
32+
XCTAssertEqual(engine.state.inputRequest?.variableName, "n")
33+
34+
engine.provideInput("abc")
35+
XCTAssertNotNil(engine.state.inputError)
36+
XCTAssertNotNil(engine.state.inputRequest)
37+
38+
engine.provideInput("21")
39+
XCTAssertNil(engine.state.inputRequest)
40+
engine.step()
41+
XCTAssertEqual(engine.state.outputLines, ["42"])
42+
}
43+
44+
func testCountingLoops() {
45+
let forLoop = VisuStructDocument(elements: [
46+
VisuStructElement(type: .anweisung, textLines: ["int sum = 0"]),
47+
VisuStructElement(
48+
type: .forSchleife,
49+
textLines: ["int i = 1", "i <= 4", "i++"],
50+
loopBody: [VisuStructElement(type: .anweisung, textLines: ["sum += i"])]
51+
),
52+
])
53+
XCTAssertEqual(runSimulation(forLoop).variables["sum"], .int(10))
54+
55+
let doUntil = VisuStructDocument(elements: [
56+
VisuStructElement(type: .anweisung, textLines: ["int k = 0"]),
57+
VisuStructElement(
58+
type: .doUntilSchleife,
59+
textLines: ["k < 3"],
60+
loopBody: [VisuStructElement(type: .anweisung, textLines: ["k++"])]
61+
),
62+
])
63+
XCTAssertEqual(runSimulation(doUntil).variables["k"], .int(3))
64+
}
65+
66+
func testSwitchFallsBackToDefaultCase() {
67+
func document(day: Int) -> VisuStructDocument {
68+
VisuStructDocument(elements: [
69+
VisuStructElement(type: .anweisung, textLines: ["int day = \(day)"]),
70+
VisuStructElement(
71+
type: .fallauswahl,
72+
textLines: ["day"],
73+
cases: [
74+
VisuStructCase(name: "1", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"Montag\""])]),
75+
VisuStructCase(name: "2", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"Dienstag\""])]),
76+
VisuStructCase(name: "default", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"anderer Tag\""])]),
77+
]
78+
),
79+
])
80+
}
81+
XCTAssertEqual(runSimulation(document(day: 2)).outputLines, ["Dienstag"])
82+
XCTAssertEqual(runSimulation(document(day: 5)).outputLines, ["anderer Tag"])
83+
}
84+
85+
func testBooleanOperatorsInConditions() {
86+
func document(_ condition: String) -> VisuStructDocument {
87+
VisuStructDocument(elements: [
88+
VisuStructElement(type: .anweisung, textLines: ["int a = 5"]),
89+
VisuStructElement(
90+
type: .verzweigung,
91+
textLines: [condition],
92+
cases: [
93+
VisuStructCase(name: "true", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"ja\""])]),
94+
VisuStructCase(name: "false", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"nein\""])]),
95+
]
96+
),
97+
])
98+
}
99+
XCTAssertEqual(runSimulation(document("a > 3 && a < 10")).outputLines, ["ja"])
100+
XCTAssertEqual(runSimulation(document("a < 3 || a == 5")).outputLines, ["ja"])
101+
XCTAssertEqual(runSimulation(document("a != 5")).outputLines, ["nein"])
102+
}
103+
104+
func testDivisionByZeroReportsError() {
105+
let document = VisuStructDocument(elements: [
106+
VisuStructElement(type: .anweisung, textLines: ["int x = 1 / 0"]),
107+
])
108+
var engine = VisuStructSimulationEngine(document: document)
109+
engine.step()
110+
XCTAssertEqual(engine.state.message, "Division by zero.")
111+
XCTAssertNil(engine.state.variables["x"])
112+
}
113+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Foundation
2+
import XCTest
3+
import VisuStructCore
4+
5+
/// Beispieldiagramme im Repo-Ordner `VisuStruct-Dateien` (relativ zu dieser Datei aufgelöst).
6+
enum ExampleFiles {
7+
static let directory: URL = URL(fileURLWithPath: #filePath)
8+
.deletingLastPathComponent() // VisuStructCoreTests
9+
.deletingLastPathComponent() // Tests
10+
.deletingLastPathComponent() // VisuStructCore
11+
.deletingLastPathComponent() // VisuStruct-iOS
12+
.deletingLastPathComponent() // Repo-Wurzel
13+
.appendingPathComponent("VisuStruct-Dateien", isDirectory: true)
14+
15+
/// Alle `.visustruct`-Dateien, rekursiv, sortiert.
16+
static func allDiagramURLs() throws -> [URL] {
17+
guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else {
18+
return []
19+
}
20+
return enumerator
21+
.compactMap { $0 as? URL }
22+
.filter { $0.pathExtension == "visustruct" }
23+
.sorted { $0.path < $1.path }
24+
}
25+
26+
static func document(named name: String) throws -> VisuStructDocument {
27+
try VisuStructXMLCodec.decode(url: directory.appendingPathComponent(name))
28+
}
29+
30+
static func text(named name: String) throws -> String {
31+
try String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8)
32+
}
33+
}
34+
35+
/// Lässt die Simulation bis zum Ende laufen und beantwortet `input:`-Anfragen der Reihe nach.
36+
func runSimulation(
37+
_ document: VisuStructDocument,
38+
inputs: [String] = [],
39+
maxSteps: Int = 10_000,
40+
file: StaticString = #filePath,
41+
line: UInt = #line
42+
) -> VisuStructSimulationState {
43+
var engine = VisuStructSimulationEngine(document: document)
44+
var remainingInputs = inputs[...]
45+
var stepsTaken = 0
46+
while stepsTaken < maxSteps {
47+
if engine.state.inputRequest != nil {
48+
guard let next = remainingInputs.popFirst() else {
49+
XCTFail("Simulation wartet auf Eingabe, aber es sind keine Eingaben mehr übrig.", file: file, line: line)
50+
return engine.state
51+
}
52+
engine.provideInput(next)
53+
continue
54+
}
55+
guard engine.canStep else { break }
56+
engine.step()
57+
stepsTaken += 1
58+
}
59+
XCTAssertLessThan(stepsTaken, maxSteps, "Simulation endet nicht.", file: file, line: line)
60+
return engine.state
61+
}

‎VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/VisuStructXMLCodecTests.swift‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import XCTest
22
import VisuStructCore
33

44
final class VisuStructXMLCodecTests: XCTestCase {
5+
/// Texte stehen wie im Java-Editor als `;`-getrennte Unicode-Codepoints im XML.
56
func testDecodesLegacyStruktogrammeditorXML() throws {
67
let xml = """
78
<?xml version="1.0" encoding="UTF-8"?>
8-
<struktogramm caption="Beispiel">
9+
<struktogramm caption="66;101;105;115;112;105;101;108;">
910
<strelem typ="0" zx="0" zy="0">
10-
<text>x = 1</text>
11+
<text>120;32;61;32;49;</text>
1112
</strelem>
1213
</struktogramm>
1314
"""

0 commit comments

Comments
 (0)