Skip to content
This repository was archived by the owner on Nov 7, 2026. It is now read-only.

Commit 4233b9b

Browse files
Merge pull request #10 from code4teaching/ci-app-build
CI: also build the app for the iOS Simulator
2 parents 5abe9cf + 1214587 commit 4233b9b

24 files changed

Lines changed: 2256 additions & 2107 deletions

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: VisuStructCore tests
1+
name: CI
22

33
on:
44
push:
@@ -17,3 +17,17 @@ jobs:
1717
run: swift --version
1818
- name: Build and test
1919
run: swift test
20+
21+
app-build:
22+
runs-on: macos-15
23+
steps:
24+
- uses: actions/checkout@v4
25+
- name: Xcode version
26+
run: xcodebuild -version
27+
- name: Build app for iOS Simulator
28+
run: |
29+
xcodebuild build \
30+
-project VisuStruct-iOS/VisuStructXcodeOpen/VisuStruct.xcodeproj \
31+
-scheme VisuStruct \
32+
-destination 'generic/platform=iOS Simulator' \
33+
CODE_SIGNING_ALLOWED=NO
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
3+
extension VisuStructDocumentEditing {
4+
/// Innerste **Fallauswahl** (`multiway`), die `path` enthält (oder selbst ist) — für „Fall hinzufügen/entfernen“.
5+
public static func multiwayAncestorPath(for path: [Int]?, in doc: VisuStructDocument) -> [Int]? {
6+
guard let path, !path.isEmpty else { return nil }
7+
for len in stride(from: path.count, through: 1, by: -1) {
8+
let prefix = Array(path.prefix(len))
9+
if let el = try? element(at: prefix, in: doc),
10+
el.type == .fallauswahl {
11+
return prefix
12+
}
13+
}
14+
return nil
15+
}
16+
17+
/// Spaltenindex der Fallauswahl, wenn die Auswahl **in** einer Spalte liegt (`[…, fall, spalte, …]`).
18+
public static func caseIndex(of selectedPath: [Int]?, inMultiwayAt multiwayPath: [Int]) -> Int? {
19+
guard let sp = selectedPath, sp.count > multiwayPath.count else { return nil }
20+
return sp[multiwayPath.count]
21+
}
22+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
extension VisuStructDocumentEditing {
4+
/// Auswahl nach Undo/Redo oder Löschen: leeres Dokument → keine Auswahl; fehlende oder ungültige Auswahl → erstes Element.
5+
public static func sanitizedSelection(_ path: [Int]?, in doc: VisuStructDocument) -> [Int]? {
6+
if doc.elements.isEmpty { return nil }
7+
guard let path else { return [0] }
8+
if (try? element(at: path, in: doc)) == nil { return [0] }
9+
return path
10+
}
11+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Foundation
2+
3+
/// Undo-/Redo-Verlauf eines Diagramms: Schnappschüsse des Dokuments vor jeder Änderung.
4+
public struct VisuStructEditHistory: Equatable, Sendable {
5+
public static let defaultMaxSteps = 80
6+
7+
public private(set) var undoStack: [VisuStructDocument] = []
8+
public private(set) var redoStack: [VisuStructDocument] = []
9+
public let maxSteps: Int
10+
11+
public init(maxSteps: Int = VisuStructEditHistory.defaultMaxSteps) {
12+
self.maxSteps = maxSteps
13+
}
14+
15+
public var canUndo: Bool { !undoStack.isEmpty }
16+
public var canRedo: Bool { !redoStack.isEmpty }
17+
18+
/// Vor einer Änderung: bisherigen Zustand ablegen, Redo verwerfen.
19+
public mutating func record(_ current: VisuStructDocument) {
20+
pushUndo(current)
21+
redoStack.removeAll()
22+
}
23+
24+
/// Liefert den vorherigen Zustand und legt `current` für Redo ab; `nil`, wenn nichts rückgängig zu machen ist.
25+
public mutating func undo(from current: VisuStructDocument) -> VisuStructDocument? {
26+
guard let previous = undoStack.popLast() else { return nil }
27+
redoStack.append(current)
28+
return previous
29+
}
30+
31+
/// Liefert den wiederhergestellten Zustand und legt `current` für Undo ab; `nil`, wenn nichts wiederherzustellen ist.
32+
public mutating func redo(from current: VisuStructDocument) -> VisuStructDocument? {
33+
guard let next = redoStack.popLast() else { return nil }
34+
pushUndo(current)
35+
return next
36+
}
37+
38+
public mutating func reset() {
39+
undoStack.removeAll()
40+
redoStack.removeAll()
41+
}
42+
43+
private mutating func pushUndo(_ document: VisuStructDocument) {
44+
undoStack.append(document)
45+
while undoStack.count > maxSteps {
46+
undoStack.removeFirst()
47+
}
48+
}
49+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import Foundation
2+
3+
/// Darstellung von Anweisung vs. Ein-/Ausgabe im Inspector (Modus-Picker).
4+
public enum VisuStructStatementIOMode: String, CaseIterable, Hashable, Sendable {
5+
case statement
6+
case input
7+
case output
8+
}
9+
10+
/// Textumformungen für den Zeileneditor im Inspector (ohne UI, daher testbar).
11+
public enum VisuStructLineEditorText {
12+
public static func ioMode(of text: String) -> VisuStructStatementIOMode {
13+
let t = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
14+
if t.hasPrefix("input:") { return .input }
15+
if t.hasPrefix("output:") { return .output }
16+
if t.hasPrefix("print:") { return .output }
17+
return .statement
18+
}
19+
20+
/// Entfernt `input:` / `output:` / `print:` und umgebende Leerzeichen; ohne Präfix bleibt der Text unverändert.
21+
public static func strippingIOPrefix(from s: String) -> String {
22+
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
23+
let lower = trimmed.lowercased()
24+
if lower.hasPrefix("input:") {
25+
return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines)
26+
}
27+
if lower.hasPrefix("output:") {
28+
return String(trimmed.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines)
29+
}
30+
if lower.hasPrefix("print:") {
31+
return String(trimmed.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines)
32+
}
33+
return s
34+
}
35+
36+
/// Text nach Wechsel im Modus-Picker: Präfix tauschen, Inhalt behalten.
37+
public static func applyingIOMode(_ mode: VisuStructStatementIOMode, to text: String) -> String {
38+
let stripped = strippingIOPrefix(from: text)
39+
switch mode {
40+
case .statement:
41+
return stripped
42+
case .input:
43+
return prefixing("input:", to: stripped)
44+
case .output:
45+
return prefixing("output:", to: stripped)
46+
}
47+
}
48+
49+
/// Wie `strippingIOPrefix` bzw. Schlüsselwort-Entfernung, aber Leerzeichen beim Tippen bleiben erhalten.
50+
public static func strippingPrefixForEditing(_ p: String, from s: String) -> String {
51+
if p == "input:" || p == "output:" {
52+
return strippingIOPrefixPreservingEditingWhitespace(from: s)
53+
}
54+
let stripped = strippingKeywordPrefixPreservingEditingWhitespace(p, from: s)
55+
if p == "while", stripped == s {
56+
let legacy = strippingKeywordPrefixPreservingEditingWhitespace("until", from: s)
57+
if legacy != s { return legacy }
58+
}
59+
return stripped
60+
}
61+
62+
public static func prefixingForEditing(_ p: String, to content: String) -> String {
63+
if content.isEmpty { return "\(p) " }
64+
return "\(p) \(content)"
65+
}
66+
67+
/// iOS/macOS ersetzen beim Tippen teils typografische Anführungszeichen; für Code immer gerade Quotes.
68+
public static func normalizingQuotes(_ s: String) -> String {
69+
s
70+
.replacingOccurrences(of: "“", with: "\"")
71+
.replacingOccurrences(of: "”", with: "\"")
72+
.replacingOccurrences(of: "„", with: "\"")
73+
.replacingOccurrences(of: "«", with: "\"")
74+
.replacingOccurrences(of: "»", with: "\"")
75+
.replacingOccurrences(of: "’", with: "'")
76+
.replacingOccurrences(of: "‘", with: "'")
77+
.replacingOccurrences(of: "‚", with: "'")
78+
.replacingOccurrences(of: "´", with: "'")
79+
}
80+
81+
/// Genau drei Felder (Start, Bedingung, Schritt) der Zählschleife.
82+
public static func paddedForLoopParts(_ lines: [String]) -> [String] {
83+
var a = lines.isEmpty ? [""] : lines
84+
while a.count < 3 { a.append("") }
85+
if a.count > 3 { return Array(a.prefix(3)) }
86+
return a
87+
}
88+
89+
/// Ein Feld der Zählschleife: gerade Quotes, ohne abschließende `;`.
90+
public static func normalizedForLoopPart(_ s: String) -> String {
91+
var x = normalizingQuotes(s)
92+
while true {
93+
let trimmedEnd = x.trimmingCharacters(in: .whitespacesAndNewlines)
94+
guard trimmedEnd.hasSuffix(";") else { return x }
95+
x = String(trimmedEnd.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
96+
}
97+
}
98+
99+
private static func prefixing(_ p: String, to content: String) -> String {
100+
let c = content.trimmingCharacters(in: .whitespacesAndNewlines)
101+
if c.isEmpty { return "\(p) " }
102+
return "\(p) \(c)"
103+
}
104+
105+
private static func strippingIOPrefixPreservingEditingWhitespace(from s: String) -> String {
106+
let leadingTrimmed = String(s.drop(while: { $0.isWhitespace }))
107+
let lower = leadingTrimmed.lowercased()
108+
if lower.hasPrefix("input:") {
109+
return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6)))
110+
}
111+
if lower.hasPrefix("output:") {
112+
return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(7)))
113+
}
114+
if lower.hasPrefix("print:") {
115+
return droppingOneLeadingWhitespace(String(leadingTrimmed.dropFirst(6)))
116+
}
117+
return s
118+
}
119+
120+
private static func strippingKeywordPrefixPreservingEditingWhitespace(_ keyword: String, from s: String) -> String {
121+
let leadingTrimmed = String(s.drop(while: { $0.isWhitespace }))
122+
let lower = leadingTrimmed.lowercased()
123+
let key = keyword.lowercased()
124+
guard lower.hasPrefix(key) else { return s }
125+
126+
let afterKeyword = leadingTrimmed.dropFirst(keyword.count)
127+
guard afterKeyword.isEmpty || afterKeyword.first?.isWhitespace == true || afterKeyword.first == "(" else {
128+
return s
129+
}
130+
131+
let content = droppingOneLeadingWhitespace(String(afterKeyword))
132+
if content.hasPrefix("("), content.hasSuffix(")"), content.count >= 2 {
133+
return String(content.dropFirst().dropLast())
134+
}
135+
return content
136+
}
137+
138+
private static func droppingOneLeadingWhitespace(_ s: String) -> String {
139+
guard let first = s.first, first.isWhitespace else { return s }
140+
return String(s.dropFirst())
141+
}
142+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Foundation
2+
3+
/// Texte, wie sie im Diagramm angezeigt werden (Schlüsselwörter ergänzt, Zählschleife einzeilig).
4+
public enum VisuStructDiagramText {
5+
/// Anzeigezeilen eines Elements; `nil`, wenn das Element keinen Text hat (dann zeigt der Canvas einen Platzhalter).
6+
public static func displayLines(for element: VisuStructElement) -> [String]? {
7+
let raw = element.textLines
8+
let isEffectivelyEmpty = raw.allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
9+
guard !isEffectivelyEmpty else { return nil }
10+
11+
switch element.type {
12+
case .forSchleife:
13+
if raw.count <= 1 { return raw }
14+
return [VisuStructForLoopDiagramDisplay.singleLine(from: raw)]
15+
case .verzweigung:
16+
return withLeadingKeyword("if", raw)
17+
case .whileSchleife:
18+
return withLeadingKeyword("while", raw)
19+
case .doUntilSchleife:
20+
return withLeadingKeyword("while", normalizedDoUntilConditionLines(raw))
21+
case .fallauswahl:
22+
return withLeadingKeyword("switch", raw)
23+
default:
24+
return raw
25+
}
26+
}
27+
28+
/// Entfernt ein führendes `until` aus gespeicherten Diagrammen, damit die Anzeige einheitlich `while …` nutzt (wie Java `do {} while`).
29+
public static func normalizedDoUntilConditionLines(_ raw: [String]) -> [String] {
30+
guard let first = raw.first else { return raw }
31+
let t = first.trimmingCharacters(in: .whitespacesAndNewlines)
32+
let lower = t.lowercased()
33+
if lower == "until" {
34+
var o = raw
35+
o[0] = ""
36+
return o
37+
}
38+
if lower.hasPrefix("until ") {
39+
var o = raw
40+
o[0] = String(t.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines)
41+
return o
42+
}
43+
if lower.hasPrefix("until(") {
44+
var o = raw
45+
o[0] = String(t.dropFirst(5))
46+
return o
47+
}
48+
return raw
49+
}
50+
51+
/// Teilt eine Anzeigezeile in fett gedrucktes Schlüsselwort und Rest (`while i < 3` → `while`, `i < 3`).
52+
public static func keywordSplit(_ line: String) -> (keyword: String, rest: String)? {
53+
let keywords: Set<String> = ["if", "for", "while", "until", "switch"]
54+
let colonKeywords: [String] = ["input:", "output:", "print:"]
55+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
56+
57+
let lower = trimmed.lowercased()
58+
for prefix in colonKeywords {
59+
if lower == prefix {
60+
return (trimmed, "")
61+
}
62+
if lower.hasPrefix(prefix + " ") {
63+
let rest = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
64+
let kw = String(trimmed.prefix(prefix.count))
65+
return (kw, rest)
66+
}
67+
}
68+
69+
guard let firstSpace = trimmed.firstIndex(of: " ") else { return nil }
70+
let head = String(trimmed[..<firstSpace])
71+
guard keywords.contains(head.lowercased()) else { return nil }
72+
let rest = String(trimmed[trimmed.index(after: firstSpace)...]).trimmingCharacters(in: .whitespacesAndNewlines)
73+
return (head, rest)
74+
}
75+
76+
private static func withLeadingKeyword(_ keyword: String, _ lines: [String]) -> [String] {
77+
guard let first = lines.first else { return lines }
78+
let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines)
79+
let lower = trimmed.lowercased()
80+
let k = keyword.lowercased()
81+
if lower == k || lower.hasPrefix(k + " ") { return lines }
82+
var out = lines
83+
out[0] = keyword + " " + trimmed
84+
return out
85+
}
86+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import XCTest
2+
import VisuStructCore
3+
4+
final class DiagramTextTests: XCTestCase {
5+
func testKeywordsAreAddedForControlStructures() {
6+
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .verzweigung, textLines: ["a > 3"])), ["if a > 3"])
7+
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .verzweigung, textLines: ["if a > 3"])), ["if a > 3"])
8+
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .whileSchleife, textLines: ["i < 3"])), ["while i < 3"])
9+
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .doUntilSchleife, textLines: ["until x == 1"])), ["while x == 1"])
10+
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .fallauswahl, textLines: ["day"])), ["switch day"])
11+
XCTAssertEqual(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .anweisung, textLines: ["x = 1"])), ["x = 1"])
12+
}
13+
14+
func testEmptyElementsHaveNoDisplayLines() {
15+
XCTAssertNil(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .anweisung, textLines: [" "])))
16+
XCTAssertNil(VisuStructDiagramText.displayLines(for: VisuStructElement(type: .forSchleife, textLines: ["", "", ""])))
17+
}
18+
19+
func testForLoopIsShownOnOneLine() {
20+
let lines = VisuStructDiagramText.displayLines(for: VisuStructElement(type: .forSchleife, textLines: ["int i = 0", "i < 3", "i++"]))
21+
XCTAssertEqual(lines?.count, 1)
22+
}
23+
24+
func testKeywordSplit() {
25+
XCTAssertEqual(VisuStructDiagramText.keywordSplit("while i < 3")?.keyword, "while")
26+
XCTAssertEqual(VisuStructDiagramText.keywordSplit("while i < 3")?.rest, "i < 3")
27+
XCTAssertEqual(VisuStructDiagramText.keywordSplit("print: x")?.keyword, "print:")
28+
XCTAssertEqual(VisuStructDiagramText.keywordSplit("print: x")?.rest, "x")
29+
XCTAssertNil(VisuStructDiagramText.keywordSplit("x = 1"))
30+
}
31+
}

0 commit comments

Comments
 (0)