Skip to content

Commit 710a69d

Browse files
committed
Add P0/P1 logic tests, unlocking helper, and AGENTS guide
1 parent e37fecd commit 710a69d

8 files changed

Lines changed: 256 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# AGENTS.md - SOPA iOS
2+
3+
## Scope
4+
Diese Datei gilt fuer dieses Repo (`sopa-ios`).
5+
6+
## Projektziel
7+
SOPA iOS soll in der **Spiellogik** Android-Verhalten erreichen (UI darf abweichen).
8+
9+
## Repo-Setup
10+
- iOS-Projekt: `SOPA.xcodeproj`
11+
- Scheme: `SOPA`
12+
- Test-Target: `SOPATests`
13+
- Deployment Target: iOS 18.0
14+
15+
## Build und Test
16+
- Build:
17+
- `xcodebuild -project SOPA.xcodeproj -scheme SOPA -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' build`
18+
- Voller Testlauf:
19+
- `xcodebuild -project SOPA.xcodeproj -scheme SOPA -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' test`
20+
21+
## Wichtige Dateien
22+
- Produktplan: `IOS_PRODUCT_COMPLETION_PLAN.md`
23+
- Navigation/Flow: `SOPA/manager/StoryServiceImpl.swift`
24+
- JustPlay-Logik: `SOPA/JustPlay/JustPlayGameScene.swift`, `SOPA/model/game/*JustPlay*`
25+
- Level/Unlocking: `SOPA/helper/LevelServiceImp.swift`
26+
- Level-Content: `SOPA/levels/`
27+
28+
## Bekannte Stolpersteine
29+
- **Tests in Xcode-Projekt eintragen**: neue Testdateien muessen in `SOPA.xcodeproj/project.pbxproj` als FileReference + BuildFile + Sources-Phase enthalten sein.
30+
- **CoreData Klassenkonflikt im Testhost**: `LevelInfoMO`/`JustPlayResults` werden in App + Test-Bundle geladen. Tests sollten CoreData-kritische Pfade moeglichst vermeiden oder als reine Logiktests gebaut werden.
31+
- **Asset-Warnung**: Doppelte Symbolauflosung fuer `restart` in Assets (`GeneratedAssetSymbols.swift` Warnung).
32+
33+
## Aktueller Teststatus (Stand)
34+
- Erweiterte Tests vorhanden fuer:
35+
- JustPlay Score/Timer-Basislogik
36+
- JustPlay Highscore-Persistenz
37+
- Unlocking-Regel (highest solved + 1) als pure Logik
38+
- StarCalculator Grenzwerte
39+
- GameService-Verhalten bei bereits geloestem Puzzle
40+
- Letzter gesamter Lauf: `19 tests, 0 failures`.
41+
42+
## Arbeitsregeln fuer Aenderungen
43+
- Keine unbeabsichtigten Aenderungen ausserhalb dieses iOS-Projekts.
44+
- Vor groesseren Refactorings immer erst `xcodebuild test` laufen lassen.
45+
- Bei UI-Flows immer Ruecknavigation pruefen (Startmenu <-> Mode <-> Level/JustPlay).
46+
- Unlocking-Regel beibehalten: freigeschaltet = geloest + 1.
47+
48+
## Offene Schwerpunkte (naechste sinnvolle Schritte)
49+
- Settings (mind. Audio-Mute)
50+
- Credits
51+
- Tutorial/Onboarding
52+
- Optional Loading-Transition
53+
- Release-Haertung (Dev-Flags, Info.plist-Aufraeumen, Signing/Versionierung)

SOPA.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
63F6615E1F9B41B30043ABCF /* LevelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F6615D1F9B41B30043ABCF /* LevelTest.swift */; };
1616
63F661611F9B48250043ABCF /* GameFieldServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F661601F9B48250043ABCF /* GameFieldServiceTest.swift */; };
1717
63F661681F9B88440043ABCF /* LevelTranslatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F661671F9B88440043ABCF /* LevelTranslatorTest.swift */; };
18+
A1B2C3D424FA000100000001 /* JustPlayServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D424FA000100000011 /* JustPlayServiceTest.swift */; };
19+
A1B2C3D424FA000100000002 /* LevelServiceImplUnlockingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D424FA000100000012 /* LevelServiceImplUnlockingTest.swift */; };
20+
A1B2C3D424FA000100000003 /* JustPlayHighscorePersistenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D424FA000100000013 /* JustPlayHighscorePersistenceTest.swift */; };
21+
A1B2C3D424FB000100000001 /* GameServiceImplTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D424FB000100000011 /* GameServiceImplTest.swift */; };
22+
A1B2C3D424FB000100000002 /* StarCalculatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D424FB000100000012 /* StarCalculatorTest.swift */; };
1823
64182B122082604300CF639A /* TileSpriteNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64182B112082604300CF639A /* TileSpriteNode.swift */; };
1924
64182B142084BC7C00CF639A /* GameFieldNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64182B132084BC7C00CF639A /* GameFieldNode.swift */; };
2025
64182B16208771A600CF639A /* SpriteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64182B15208771A600CF639A /* SpriteButton.swift */; };
@@ -139,6 +144,11 @@
139144
63F6615D1F9B41B30043ABCF /* LevelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LevelTest.swift; sourceTree = "<group>"; };
140145
63F661601F9B48250043ABCF /* GameFieldServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameFieldServiceTest.swift; sourceTree = "<group>"; };
141146
63F661671F9B88440043ABCF /* LevelTranslatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LevelTranslatorTest.swift; sourceTree = "<group>"; };
147+
A1B2C3D424FA000100000011 /* JustPlayServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustPlayServiceTest.swift; sourceTree = "<group>"; };
148+
A1B2C3D424FA000100000012 /* LevelServiceImplUnlockingTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = helper/LevelServiceImplUnlockingTest.swift; sourceTree = "<group>"; };
149+
A1B2C3D424FA000100000013 /* JustPlayHighscorePersistenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = database/JustPlayHighscorePersistenceTest.swift; sourceTree = "<group>"; };
150+
A1B2C3D424FB000100000011 /* GameServiceImplTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameServiceImplTest.swift; sourceTree = "<group>"; };
151+
A1B2C3D424FB000100000012 /* StarCalculatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarCalculatorTest.swift; sourceTree = "<group>"; };
142152
64182B112082604300CF639A /* TileSpriteNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileSpriteNode.swift; sourceTree = "<group>"; };
143153
64182B132084BC7C00CF639A /* GameFieldNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameFieldNode.swift; sourceTree = "<group>"; };
144154
64182B15208771A600CF639A /* SpriteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpriteButton.swift; sourceTree = "<group>"; };
@@ -263,6 +273,8 @@
263273
isa = PBXGroup;
264274
children = (
265275
63F6615F1F9B48000043ABCF /* model */,
276+
A1B2C3D424FA000100000012 /* LevelServiceImplUnlockingTest.swift */,
277+
A1B2C3D424FA000100000013 /* JustPlayHighscorePersistenceTest.swift */,
266278
63F6613F1F9B346E0043ABCF /* Info.plist */,
267279
63F6615D1F9B41B30043ABCF /* LevelTest.swift */,
268280
);
@@ -323,8 +335,11 @@
323335
63F6616E1F9BA61D0043ABCF /* game */ = {
324336
isa = PBXGroup;
325337
children = (
338+
A1B2C3D424FB000100000011 /* GameServiceImplTest.swift */,
326339
63F661601F9B48250043ABCF /* GameFieldServiceTest.swift */,
340+
A1B2C3D424FA000100000011 /* JustPlayServiceTest.swift */,
327341
63F661671F9B88440043ABCF /* LevelTranslatorTest.swift */,
342+
A1B2C3D424FB000100000012 /* StarCalculatorTest.swift */,
328343
64CA8A6B1FA7C605009BDBC2 /* FileHandlerTest.swift */,
329344
);
330345
path = game;
@@ -582,8 +597,13 @@
582597
64182B1A208DCE0A00CF639A /* LevelModeGameScene.swift in Sources */,
583598
642E2B43213D257C002669E5 /* LevelButtonPositioner.swift in Sources */,
584599
63F661611F9B48250043ABCF /* GameFieldServiceTest.swift in Sources */,
600+
A1B2C3D424FB000100000001 /* GameServiceImplTest.swift in Sources */,
585601
646902C9207F6B7600283F71 /* PathState.swift in Sources */,
586602
63F6615E1F9B41B30043ABCF /* LevelTest.swift in Sources */,
603+
A1B2C3D424FA000100000001 /* JustPlayServiceTest.swift in Sources */,
604+
A1B2C3D424FA000100000002 /* LevelServiceImplUnlockingTest.swift in Sources */,
605+
A1B2C3D424FA000100000003 /* JustPlayHighscorePersistenceTest.swift in Sources */,
606+
A1B2C3D424FB000100000002 /* StarCalculatorTest.swift in Sources */,
587607
642EE5C3224254CF00680612 /* IPadProportionSet.swift in Sources */,
588608
646902D0207F6B7D00283F71 /* LevelTranslator.swift in Sources */,
589609
64AD42AE213FC45400B5A3FC /* StoryServiceImpl.swift in Sources */,

SOPA/helper/LevelServiceImp.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,10 @@ class LevelServiceImpl: LevelService {
5252
}
5353

5454
private func normalizeUnlockState(availableIds: [Int]) {
55-
guard let maxLevelId = availableIds.max() else {
55+
let levelInfos = levelInfoDataSource.getAllLevelInfos()
56+
guard let unlockedLimit = LevelServiceImpl.computeUnlockedLimit(availableIds: availableIds, levelInfos: levelInfos) else {
5657
return
5758
}
58-
let levelInfos = levelInfoDataSource.getAllLevelInfos()
59-
let highestSolvedLevel = levelInfos.filter { $0.fewestMoves >= 0 }.map { $0.levelId }.max() ?? 0
60-
let unlockedLimit = min(max(1, highestSolvedLevel + 1), maxLevelId)
6159

6260
for levelInfo in levelInfos {
6361
let shouldBeLocked = levelInfo.levelId > unlockedLimit
@@ -67,6 +65,14 @@ class LevelServiceImpl: LevelService {
6765
}
6866
}
6967
}
68+
69+
static func computeUnlockedLimit(availableIds: [Int], levelInfos: [LevelInfo]) -> Int? {
70+
guard let maxLevelId = availableIds.max() else {
71+
return nil
72+
}
73+
let highestSolvedLevel = levelInfos.filter { $0.fewestMoves >= 0 }.map { $0.levelId }.max() ?? 0
74+
return min(max(1, highestSolvedLevel + 1), maxLevelId)
75+
}
7076

7177
func calculateLevelResult(level: Level) -> LevelResult {
7278
let stars = starCalculator.getStars(neededMoves: level.movesCounter, minimumMoves: level.minimumMovesToSolve!)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import XCTest
2+
@testable import SOPA
3+
4+
final class JustPlayHighscorePersistenceTest: XCTestCase {
5+
private var sut: LevelServiceImpl!
6+
7+
override func setUp() {
8+
super.setUp()
9+
let appDelegate = AppDelegate()
10+
sut = LevelServiceImpl(appDelegate: appDelegate)
11+
}
12+
13+
func testHighscoreOnlyIncreases() {
14+
let currentBest = sut.getBestJustPlayScore()?.points ?? 0
15+
let base = max(currentBest + 1000, 1_000_000)
16+
17+
sut.submitJustPlayScore(score: JustPlayScore(points: base, solvedLevels: 4))
18+
XCTAssertEqual(sut.getBestJustPlayScore()?.points, base)
19+
20+
sut.submitJustPlayScore(score: JustPlayScore(points: base - 200, solvedLevels: 2))
21+
XCTAssertEqual(sut.getBestJustPlayScore()?.points, base)
22+
23+
sut.submitJustPlayScore(score: JustPlayScore(points: base + 350, solvedLevels: 9))
24+
XCTAssertEqual(sut.getBestJustPlayScore()?.points, base + 350)
25+
XCTAssertEqual(sut.getBestJustPlayScore()?.solvedLevels, 9)
26+
}
27+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import XCTest
2+
@testable import SOPA
3+
4+
final class LevelServiceImplUnlockingTest: XCTestCase {
5+
func testFreshStateOnlyLevelOneUnlockedLimit() {
6+
let availableIds = Array(1...100)
7+
let levelInfos = availableIds.map {
8+
LevelInfo(levelId: $0, locked: ($0 != 1), fewestMoves: -1, stars: 0, time: .nan)
9+
}
10+
let unlockedLimit = LevelServiceImpl.computeUnlockedLimit(availableIds: availableIds, levelInfos: levelInfos)
11+
12+
XCTAssertEqual(unlockedLimit, 1)
13+
}
14+
15+
func testUnlockedLimitIsHighestSolvedPlusOne() {
16+
let availableIds = Array(1...100)
17+
let levelInfos = availableIds.map {
18+
LevelInfo(levelId: $0, locked: false, fewestMoves: ($0 <= 3 ? 10 : -1), stars: 0, time: .nan)
19+
}
20+
let unlockedLimit = LevelServiceImpl.computeUnlockedLimit(availableIds: availableIds, levelInfos: levelInfos)
21+
22+
XCTAssertEqual(unlockedLimit, 4)
23+
}
24+
25+
func testUnlockedLimitDoesNotExceedMaxAvailableLevel() {
26+
let availableIds = Array(1...100)
27+
let levelInfos = availableIds.map {
28+
LevelInfo(levelId: $0, locked: false, fewestMoves: 1, stars: 3, time: 1.0)
29+
}
30+
let unlockedLimit = LevelServiceImpl.computeUnlockedLimit(availableIds: availableIds, levelInfos: levelInfos)
31+
32+
XCTAssertEqual(unlockedLimit, 100)
33+
}
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import XCTest
2+
@testable import SOPA
3+
4+
final class GameServiceImplTest: XCTestCase {
5+
private let levelTranslator = LevelTranslator()
6+
7+
private let solvedLevelStrings = [
8+
"21",
9+
"4",
10+
"4",
11+
"#",
12+
"nnnnnn",
13+
"noooon",
14+
"saaaaf",
15+
"noooon",
16+
"noooon",
17+
"nnnnnn"
18+
]
19+
20+
func testShiftLineDoesNothingWhenPuzzleIsAlreadySolved() {
21+
let level = levelTranslator.fromString(levelLines: solvedLevelStrings)
22+
let sut = GameServiceImpl(level: level)
23+
24+
let beforeMoveCount = level.movesCounter
25+
let beforeRow = level.tiles.map { $0[2].shortcut }
26+
27+
sut.shiftLine(horizontal: true, row: 1, steps: 1)
28+
29+
let afterRow = level.tiles.map { $0[2].shortcut }
30+
XCTAssertEqual(level.movesCounter, beforeMoveCount)
31+
XCTAssertEqual(afterRow, beforeRow)
32+
XCTAssertTrue(sut.solvedPuzzle())
33+
}
34+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import XCTest
2+
@testable import SOPA
3+
4+
final class JustPlayServiceTest: XCTestCase {
5+
func testScoreAndDifficultyProgressionAtBoundaries() {
6+
let service = JustPlayServiceImpl()
7+
8+
// Level 1 -> difficulty 0 (maxScore 5)
9+
var result = service.calculateResult(
10+
justPlayLevelResult: JustPlayLevelResult(leftTime: 10, moves: 1, minLevelMoves: 1)
11+
)
12+
XCTAssertEqual(result.lastScore, 0)
13+
XCTAssertEqual(result.score, 5)
14+
XCTAssertEqual(result.extraTime, 5)
15+
XCTAssertEqual(result.levelCount, 1)
16+
17+
// Level 2 -> difficulty 0 again
18+
result = service.calculateResult(
19+
justPlayLevelResult: JustPlayLevelResult(leftTime: 10, moves: 1, minLevelMoves: 1)
20+
)
21+
XCTAssertEqual(result.lastScore, 5)
22+
XCTAssertEqual(result.score, 10)
23+
XCTAssertEqual(result.levelCount, 2)
24+
25+
// Level 3 -> difficulty 1 (maxScore 10)
26+
result = service.calculateResult(
27+
justPlayLevelResult: JustPlayLevelResult(leftTime: 10, moves: 1, minLevelMoves: 1)
28+
)
29+
XCTAssertEqual(result.lastScore, 10)
30+
XCTAssertEqual(result.score, 20)
31+
XCTAssertEqual(result.levelCount, 3)
32+
}
33+
34+
func testLostRoundDoesNotIncreaseScore() {
35+
let service = JustPlayServiceImpl()
36+
37+
_ = service.calculateResult(
38+
justPlayLevelResult: JustPlayLevelResult(leftTime: 10, moves: 1, minLevelMoves: 1)
39+
)
40+
let resultAfterLoss = service.calculateResult(
41+
justPlayLevelResult: JustPlayLevelResult(leftTime: -1, moves: 99, minLevelMoves: 1)
42+
)
43+
44+
XCTAssertEqual(resultAfterLoss.lastScore, 5)
45+
XCTAssertEqual(resultAfterLoss.score, 5)
46+
}
47+
48+
func testExtraTimeIsCappedAtThirtyFive() {
49+
let service = JustPlayServiceImpl()
50+
51+
let result = service.calculateResult(
52+
justPlayLevelResult: JustPlayLevelResult(leftTime: 34, moves: 1, minLevelMoves: 1)
53+
)
54+
55+
XCTAssertEqual(result.extraTime, 1)
56+
XCTAssertEqual(result.leftTime, 34)
57+
}
58+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import XCTest
2+
@testable import SOPA
3+
4+
final class StarCalculatorTest: XCTestCase {
5+
private let sut = StarCalculator()
6+
7+
func testReturnsThreeStarsWhenNeededMovesAtOrBelowMinimum() {
8+
XCTAssertEqual(sut.getStars(neededMoves: 8, minimumMoves: 8), 3)
9+
XCTAssertEqual(sut.getStars(neededMoves: 7, minimumMoves: 8), 3)
10+
}
11+
12+
func testReturnsTwoStarsWhenRatioIsAboveSixtyPercent() {
13+
XCTAssertEqual(sut.getStars(neededMoves: 9, minimumMoves: 6), 2)
14+
}
15+
16+
func testReturnsOneStarWhenRatioIsSixtyPercentOrBelow() {
17+
XCTAssertEqual(sut.getStars(neededMoves: 10, minimumMoves: 6), 1)
18+
XCTAssertEqual(sut.getStars(neededMoves: 12, minimumMoves: 6), 1)
19+
}
20+
}

0 commit comments

Comments
 (0)