diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml new file mode 100644 index 00000000..b9263bb7 --- /dev/null +++ b/.github/workflows/core-tests.yml @@ -0,0 +1,19 @@ +name: VisuStructCore tests + +on: + push: + branches: [main] + pull_request: + +jobs: + swift-test: + runs-on: macos-15 + defaults: + run: + working-directory: VisuStruct-iOS/VisuStructCore + steps: + - uses: actions/checkout@v4 + - name: Swift version + run: swift --version + - name: Build and test + run: swift test diff --git a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/CodeGeneration/VisuStructCodeGenerator+JavaClassFile.swift b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/CodeGeneration/VisuStructCodeGenerator+JavaClassFile.swift index 40d72a84..72f2c96c 100644 --- a/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/CodeGeneration/VisuStructCodeGenerator+JavaClassFile.swift +++ b/VisuStruct-iOS/VisuStructCore/Sources/VisuStructCore/CodeGeneration/VisuStructCodeGenerator+JavaClassFile.swift @@ -30,6 +30,10 @@ extension VisuStructCodeGenerator { } result = result.trimmingCharacters(in: CharacterSet(charactersIn: "_")) if result.isEmpty { return "Main" } + // Das Trimmen oben entfernt auch den `_` vor einer führenden Ziffer. + if let first = result.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) { + result = "_" + result + } let reserved: Set = [ "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", "default", "do", "double", "else", "enum", "extends", "final", "finally", diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/CodeGeneratorTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/CodeGeneratorTests.swift new file mode 100644 index 00000000..579a7a20 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/CodeGeneratorTests.swift @@ -0,0 +1,50 @@ +import XCTest +import VisuStructCore + +final class CodeGeneratorTests: XCTestCase { + /// Die mitgelieferten `.java`-Dateien sind der erwartete Export der zugehörigen Diagramme. + func testJavaClassFileMatchesExamples() throws { + for name in ["Collatz", "Fibonacci"] { + let document = try ExampleFiles.document(named: "\(name).visustruct") + let generated = VisuStructCodeGenerator.generateJavaClassFile(document: document, className: name) + XCTAssertEqual(generated, try ExampleFiles.text(named: "\(name).java"), name) + } + } + + func testJavaClassFileWithInputImportsScannerOnce() throws { + let document = try ExampleFiles.document(named: "2026-04-23-NumberEven.visustruct") + let generated = VisuStructCodeGenerator.generateJavaClassFile(document: document, className: "NumberEven") + XCTAssertTrue(generated.hasPrefix("import java.util.Scanner;\n\npublic class NumberEven {\n")) + XCTAssertEqual(generated.components(separatedBy: "new Scanner(System.in)").count - 1, 1) + XCTAssertTrue(generated.contains("if (number % 2 == 0) {")) + } + + func testCollatzInPythonAndJavaScript() throws { + let document = try ExampleFiles.document(named: "Collatz.visustruct") + + let python = VisuStructCodeGenerator.generate(document: document, target: .python) + XCTAssertTrue(python.contains("while number != 1:"), python) + XCTAssertTrue(python.contains("print(number)"), python) + XCTAssertFalse(python.contains("{"), python) + + let javaScript = VisuStructCodeGenerator.generate(document: document, target: .javascript) + XCTAssertTrue(javaScript.hasPrefix("\"use strict\";"), javaScript) + XCTAssertTrue(javaScript.contains("while (number != 1) {"), javaScript) + XCTAssertTrue(javaScript.contains("console.log(number);"), javaScript) + } + + func testSanitizedJavaClassName() { + XCTAssertEqual(VisuStructCodeGenerator.sanitizedJavaClassName("Fibonacci"), "Fibonacci") + let sanitized = VisuStructCodeGenerator.sanitizedJavaClassName("2 Summe bilden!") + XCTAssertFalse(sanitized.isEmpty) + XCTAssertFalse(sanitized.contains(" ")) + XCTAssertFalse(sanitized.first?.isNumber ?? true) + } + + func testEmptyDocumentProducesNonEmptyPlaceholder() { + for target in [VisuStructCodeGenerator.Target.java, .python, .javascript] { + let code = VisuStructCodeGenerator.generate(document: VisuStructDocument(), target: target) + XCTAssertFalse(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, target.rawValue) + } + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/ExampleFilesTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/ExampleFilesTests.swift new file mode 100644 index 00000000..e028bde9 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/ExampleFilesTests.swift @@ -0,0 +1,36 @@ +import XCTest +import VisuStructCore + +/// Jede Beispieldatei muss sich laden, verlustfrei speichern und in alle Zielsprachen übersetzen lassen. +final class ExampleFilesTests: XCTestCase { + func testExampleDirectoryContainsDiagrams() throws { + XCTAssertFalse(try ExampleFiles.allDiagramURLs().isEmpty, "Keine Beispieldateien gefunden in \(ExampleFiles.directory.path)") + } + + func testAllExamplesDecodeAndRoundTrip() throws { + for url in try ExampleFiles.allDiagramURLs() { + let name = url.lastPathComponent + let decoded = try VisuStructXMLCodec.decode(url: url) + XCTAssertFalse(decoded.elements.isEmpty, "\(name): keine Elemente") + + let reencoded = VisuStructXMLCodec.encode(decoded) + let decodedAgain = try VisuStructXMLCodec.decode(data: reencoded) + XCTAssertEqual(decodedAgain, decoded, "\(name): Speichern und erneutes Laden verändert das Diagramm") + } + } + + func testAllExamplesGenerateCodeForEveryTarget() throws { + let targets: [VisuStructCodeGenerator.Target] = [.java, .python, .javascript] + for url in try ExampleFiles.allDiagramURLs() { + let name = url.lastPathComponent + let document = try VisuStructXMLCodec.decode(url: url) + for target in targets { + let code = VisuStructCodeGenerator.generate(document: document, target: target) + XCTAssertFalse( + code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "\(name): leerer Code für \(target.rawValue)" + ) + } + } + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/SimulationEngineTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/SimulationEngineTests.swift new file mode 100644 index 00000000..a1b4cd10 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/SimulationEngineTests.swift @@ -0,0 +1,113 @@ +import XCTest +import VisuStructCore + +final class SimulationEngineTests: XCTestCase { + func testCollatzExampleOutputsSequence() throws { + let state = runSimulation(try ExampleFiles.document(named: "Collatz.visustruct")) + XCTAssertEqual( + state.outputLines, + ["58", "29", "88", "44", "22", "11", "34", "17", "52", "26", "13", "40", "20", "10", "5", "16", "8", "4", "2", "1"] + ) + XCTAssertEqual(state.variables["number"], .int(1)) + } + + func testFibonacciExampleOutputsFirstTenNumbers() throws { + let state = runSimulation(try ExampleFiles.document(named: "Fibonacci.visustruct")) + XCTAssertEqual(state.outputLines, ["1", "1", "2", "3", "5", "8", "13", "21", "34", "55"]) + } + + func testNumberEvenExampleUsesInput() throws { + let document = try ExampleFiles.document(named: "2026-04-23-NumberEven.visustruct") + XCTAssertEqual(runSimulation(document, inputs: ["4"]).outputLines, ["number is even"]) + XCTAssertEqual(runSimulation(document, inputs: ["7"]).outputLines, ["number is odd"]) + } + + func testInvalidIntegerInputIsRejectedAndCanBeRetried() { + let document = VisuStructDocument(elements: [ + VisuStructElement(type: .anweisung, textLines: ["input: int n"]), + VisuStructElement(type: .anweisung, textLines: ["print: n * 2"]), + ]) + var engine = VisuStructSimulationEngine(document: document) + engine.step() + XCTAssertEqual(engine.state.inputRequest?.variableName, "n") + + engine.provideInput("abc") + XCTAssertNotNil(engine.state.inputError) + XCTAssertNotNil(engine.state.inputRequest) + + engine.provideInput("21") + XCTAssertNil(engine.state.inputRequest) + engine.step() + XCTAssertEqual(engine.state.outputLines, ["42"]) + } + + func testCountingLoops() { + let forLoop = VisuStructDocument(elements: [ + VisuStructElement(type: .anweisung, textLines: ["int sum = 0"]), + VisuStructElement( + type: .forSchleife, + textLines: ["int i = 1", "i <= 4", "i++"], + loopBody: [VisuStructElement(type: .anweisung, textLines: ["sum += i"])] + ), + ]) + XCTAssertEqual(runSimulation(forLoop).variables["sum"], .int(10)) + + let doUntil = VisuStructDocument(elements: [ + VisuStructElement(type: .anweisung, textLines: ["int k = 0"]), + VisuStructElement( + type: .doUntilSchleife, + textLines: ["k < 3"], + loopBody: [VisuStructElement(type: .anweisung, textLines: ["k++"])] + ), + ]) + XCTAssertEqual(runSimulation(doUntil).variables["k"], .int(3)) + } + + func testSwitchFallsBackToDefaultCase() { + func document(day: Int) -> VisuStructDocument { + VisuStructDocument(elements: [ + VisuStructElement(type: .anweisung, textLines: ["int day = \(day)"]), + VisuStructElement( + type: .fallauswahl, + textLines: ["day"], + cases: [ + VisuStructCase(name: "1", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"Montag\""])]), + VisuStructCase(name: "2", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"Dienstag\""])]), + VisuStructCase(name: "default", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"anderer Tag\""])]), + ] + ), + ]) + } + XCTAssertEqual(runSimulation(document(day: 2)).outputLines, ["Dienstag"]) + XCTAssertEqual(runSimulation(document(day: 5)).outputLines, ["anderer Tag"]) + } + + func testBooleanOperatorsInConditions() { + func document(_ condition: String) -> VisuStructDocument { + VisuStructDocument(elements: [ + VisuStructElement(type: .anweisung, textLines: ["int a = 5"]), + VisuStructElement( + type: .verzweigung, + textLines: [condition], + cases: [ + VisuStructCase(name: "true", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"ja\""])]), + VisuStructCase(name: "false", elements: [VisuStructElement(type: .anweisung, textLines: ["print: \"nein\""])]), + ] + ), + ]) + } + XCTAssertEqual(runSimulation(document("a > 3 && a < 10")).outputLines, ["ja"]) + XCTAssertEqual(runSimulation(document("a < 3 || a == 5")).outputLines, ["ja"]) + XCTAssertEqual(runSimulation(document("a != 5")).outputLines, ["nein"]) + } + + func testDivisionByZeroReportsError() { + let document = VisuStructDocument(elements: [ + VisuStructElement(type: .anweisung, textLines: ["int x = 1 / 0"]), + ]) + var engine = VisuStructSimulationEngine(document: document) + engine.step() + XCTAssertEqual(engine.state.message, "Division by zero.") + XCTAssertNil(engine.state.variables["x"]) + } +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/TestSupport.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/TestSupport.swift new file mode 100644 index 00000000..22d44a35 --- /dev/null +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/TestSupport.swift @@ -0,0 +1,61 @@ +import Foundation +import XCTest +import VisuStructCore + +/// Beispieldiagramme im Repo-Ordner `VisuStruct-Dateien` (relativ zu dieser Datei aufgelöst). +enum ExampleFiles { + static let directory: URL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // VisuStructCoreTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // VisuStructCore + .deletingLastPathComponent() // VisuStruct-iOS + .deletingLastPathComponent() // Repo-Wurzel + .appendingPathComponent("VisuStruct-Dateien", isDirectory: true) + + /// Alle `.visustruct`-Dateien, rekursiv, sortiert. + static func allDiagramURLs() throws -> [URL] { + guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil) else { + return [] + } + return enumerator + .compactMap { $0 as? URL } + .filter { $0.pathExtension == "visustruct" } + .sorted { $0.path < $1.path } + } + + static func document(named name: String) throws -> VisuStructDocument { + try VisuStructXMLCodec.decode(url: directory.appendingPathComponent(name)) + } + + static func text(named name: String) throws -> String { + try String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8) + } +} + +/// Lässt die Simulation bis zum Ende laufen und beantwortet `input:`-Anfragen der Reihe nach. +func runSimulation( + _ document: VisuStructDocument, + inputs: [String] = [], + maxSteps: Int = 10_000, + file: StaticString = #filePath, + line: UInt = #line +) -> VisuStructSimulationState { + var engine = VisuStructSimulationEngine(document: document) + var remainingInputs = inputs[...] + var stepsTaken = 0 + while stepsTaken < maxSteps { + if engine.state.inputRequest != nil { + guard let next = remainingInputs.popFirst() else { + XCTFail("Simulation wartet auf Eingabe, aber es sind keine Eingaben mehr übrig.", file: file, line: line) + return engine.state + } + engine.provideInput(next) + continue + } + guard engine.canStep else { break } + engine.step() + stepsTaken += 1 + } + XCTAssertLessThan(stepsTaken, maxSteps, "Simulation endet nicht.", file: file, line: line) + return engine.state +} diff --git a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/VisuStructXMLCodecTests.swift b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/VisuStructXMLCodecTests.swift index f59b3741..c3de3747 100644 --- a/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/VisuStructXMLCodecTests.swift +++ b/VisuStruct-iOS/VisuStructCore/Tests/VisuStructCoreTests/VisuStructXMLCodecTests.swift @@ -2,12 +2,13 @@ import XCTest import VisuStructCore final class VisuStructXMLCodecTests: XCTestCase { + /// Texte stehen wie im Java-Editor als `;`-getrennte Unicode-Codepoints im XML. func testDecodesLegacyStruktogrammeditorXML() throws { let xml = """ - + - x = 1 + 120;32;61;32;49; """