diff --git a/Tests/PlayDate/MockPlaydate.swift b/Tests/PlayDate/MockPlaydate.swift new file mode 100644 index 0000000..cc6112b --- /dev/null +++ b/Tests/PlayDate/MockPlaydate.swift @@ -0,0 +1,346 @@ +// +// MockPlaydate.swift +// A fake PlaydateAPI for host testing: real C structs whose function +// pointers are Swift stubs that record their arguments. Only the fields +// the tests exercise are populated; calling an unpopulated field traps. +// +// Recording state is global (C function pointers cannot capture), so all +// tests using this harness must run in a `.serialized` suite. +// + +import CPlaydate +@testable import PlayDate + +enum Mock { + // MARK: - Stable API allocations + + nonisolated(unsafe) static let sysAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let displayAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let gfxAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let spriteAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let soundAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let channelAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer.allocate(capacity: 1) + nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer.allocate(capacity: 1) + + // MARK: - Recordings + + /// Chronological log of stub invocations, formatted per stub. + nonisolated(unsafe) static var events: [String] = [] + nonisolated(unsafe) static var buttonState: (current: UInt32, pushed: UInt32, released: UInt32) = (0, 0, 0) + /// The 16 bytes behind the last pattern `LCDColor` seen by a stub. + nonisolated(unsafe) static var patternBytes: [UInt8] = [] + /// Userdata stored per sprite / menu item, as the OS would keep it. + nonisolated(unsafe) static var spriteUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:] + nonisolated(unsafe) static var menuUserdata: [OpaquePointer: UnsafeMutableRawPointer] = [:] + /// Callbacks handed to the C API, for re-invocation by tests. + nonisolated(unsafe) static var menuCallback: (@convention(c) (UnsafeMutableRawPointer?) -> Void)? + nonisolated(unsafe) static var spriteUpdateCallback: (@convention(c) (OpaquePointer?) -> Void)? + nonisolated(unsafe) static var audioCallback: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?, UnsafeMutablePointer?, Int32) -> Int32)? + nonisolated(unsafe) static var audioContext: UnsafeMutableRawPointer? + + static func record(_ event: String) { + events.append(event) + } + + static func eventCount(_ event: String) -> Int { + events.filter { $0 == event }.count + } + + // MARK: - Lifecycle + + /// Distinct fake object pointers, so identity-keyed recordings work. + nonisolated(unsafe) private static var pointerSeed: UInt = 0x1000 + + static func fakePointer() -> OpaquePointer { + pointerSeed += 16 + return OpaquePointer(bitPattern: pointerSeed)! + } + + /// Installs the mock API exactly once per process. + static let ready: Void = install() + + static func resetRecordings() { + _ = ready + events = [] + buttonState = (0, 0, 0) + patternBytes = [] + spriteUserdata = [:] + menuUserdata = [:] + menuCallback = nil + spriteUpdateCallback = nil + audioCallback = nil + audioContext = nil + } + + private static func install() { + installSystem() + installDisplay() + installGraphics() + installSprite() + installSound() + installFile() + installJSON() + apiStruct.initialize(to: PlaydateAPI( + system: UnsafePointer(sysAPI), + file: UnsafePointer(fileAPI), + graphics: UnsafePointer(gfxAPI), + sprite: UnsafePointer(spriteAPI), + display: UnsafePointer(displayAPI), + sound: UnsafePointer(soundAPI), + lua: nil, + json: UnsafePointer(jsonAPI), + scoreboards: nil, + network: nil)) + Playdate.initialize(with: UnsafeMutableRawPointer(apiStruct)) + } + + // MARK: - System + + private static func installSystem() { + sysAPI.initialize(to: playdate_sys()) + + // Backed by the host allocator, so wrapper-freed OS memory balances. + sysAPI.pointee.realloc = { pointer, size in + if size == 0 { + if let pointer { + Mock.record("sysFree") + free(pointer) + } + return nil + } + return realloc(pointer, size) + } + + sysAPI.pointee.getButtonState = { current, pushed, released in + current?.pointee = PDButtons(Mock.buttonState.current) + pushed?.pointee = PDButtons(Mock.buttonState.pushed) + released?.pointee = PDButtons(Mock.buttonState.released) + } + + sysAPI.pointee.addMenuItem = { title, callback, _ in + Mock.record("addMenuItem(\(String(cString: title!)))") + Mock.menuCallback = callback + return Mock.fakePointer() + } + sysAPI.pointee.setMenuItemUserdata = { item, userdata in + Mock.menuUserdata[item!] = userdata + } + sysAPI.pointee.getMenuItemUserdata = { item in + Mock.menuUserdata[item!] + } + sysAPI.pointee.removeMenuItem = { _ in + Mock.record("removeMenuItem") + } + sysAPI.pointee.removeAllMenuItems = { + Mock.record("removeAllMenuItems") + } + } + + // MARK: - Display + + private static func installDisplay() { + displayAPI.initialize(to: playdate_display()) + displayAPI.pointee.getWidth = { 400 } + displayAPI.pointee.getHeight = { 240 } + } + + // MARK: - Graphics + + private static func installGraphics() { + gfxAPI.initialize(to: playdate_graphics()) + + gfxAPI.pointee.fillRect = { x, y, width, height, color in + if color > 3, let pattern = UnsafeRawPointer(bitPattern: color) { + Mock.patternBytes = Array(UnsafeRawBufferPointer(start: pattern, count: 16)) + Mock.record("fillRect(\(x),\(y),\(width),\(height),pattern)") + } else { + Mock.record("fillRect(\(x),\(y),\(width),\(height),\(color))") + } + } + + gfxAPI.pointee.drawText = { text, length, encoding, x, y in + let bytes = UnsafeRawBufferPointer(start: text, count: length) + let string = String(decoding: bytes, as: UTF8.self) + Mock.record("drawText(\(string),enc:\(encoding.rawValue),\(x),\(y))") + return Int32(length) + } + + gfxAPI.pointee.newBitmap = { width, height, _ in + Mock.record("newBitmap(\(width)x\(height))") + return Mock.fakePointer() + } + gfxAPI.pointee.freeBitmap = { _ in + Mock.record("freeBitmap") + } + + gfxAPI.pointee.newBitmapTable = { count, width, height in + Mock.record("newBitmapTable(\(count))") + return Mock.fakePointer() + } + gfxAPI.pointee.freeBitmapTable = { _ in + Mock.record("freeBitmapTable") + } + gfxAPI.pointee.getTableBitmap = { _, index in + Mock.record("getTableBitmap(\(index))") + return index == 0 ? Mock.fakePointer() : nil + } + } + + // MARK: - Sprite + + private static func installSprite() { + spriteAPI.initialize(to: playdate_sprite()) + + spriteAPI.pointee.newSprite = { + Mock.record("newSprite") + return Mock.fakePointer() + } + spriteAPI.pointee.freeSprite = { _ in + Mock.record("freeSprite") + } + spriteAPI.pointee.setUserdata = { sprite, userdata in + Mock.spriteUserdata[sprite!] = userdata + } + spriteAPI.pointee.getUserdata = { sprite in + Mock.spriteUserdata[sprite!] + } + spriteAPI.pointee.moveTo = { _, x, y in + Mock.record("moveTo(\(x),\(y))") + } + spriteAPI.pointee.addSprite = { _ in + Mock.record("addSprite") + } + spriteAPI.pointee.removeSprite = { _ in + Mock.record("removeSprite") + } + spriteAPI.pointee.setUpdateFunction = { _, function in + Mock.spriteUpdateCallback = function + } + + spriteAPI.pointee.moveWithCollisions = { sprite, goalX, goalY, actualX, actualY, length in + actualX?.pointee = goalX - 1 + actualY?.pointee = goalY - 2 + length?.pointee = 1 + // The wrapper frees this with the system allocator. + let info = malloc(MemoryLayout.stride)! + .assumingMemoryBound(to: SpriteCollisionInfo.self) + info.pointee = SpriteCollisionInfo( + sprite: sprite, + other: sprite, + responseType: kCollisionTypeBounce, + overlaps: 1, + ti: 0.5, + move: CollisionPoint(x: 1, y: 2), + normal: CollisionVector(x: 0, y: -1), + touch: CollisionPoint(x: 3, y: 4), + spriteRect: PDRect(x: 0, y: 0, width: 8, height: 8), + otherRect: PDRect(x: 8, y: 8, width: 8, height: 8)) + return info + } + } + + // MARK: - Sound + + private static func installSound() { + soundAPI.initialize(to: playdate_sound()) + soundAPI.pointee.channel = UnsafePointer(channelAPI) + + soundAPI.pointee.addSource = { callback, context, _ in + Mock.record("addSource") + Mock.audioCallback = callback + Mock.audioContext = context + return Mock.fakePointer() + } + soundAPI.pointee.removeSource = { _ in + Mock.record("removeSource") + return 1 + } + + channelAPI.initialize(to: playdate_sound_channel()) + channelAPI.pointee.newChannel = { + Mock.record("newChannel") + return Mock.fakePointer() + } + channelAPI.pointee.freeChannel = { _ in + Mock.record("freeChannel") + } + channelAPI.pointee.addCallbackSource = { _, callback, context, _ in + Mock.record("addCallbackSource") + Mock.audioCallback = callback + Mock.audioContext = context + return Mock.fakePointer() + } + channelAPI.pointee.removeSource = { _, _ in + Mock.record("channelRemoveSource") + return 1 + } + } + + // MARK: - File + + private static func installFile() { + fileAPI.initialize(to: playdate_file()) + + fileAPI.pointee.geterr = { nil } + fileAPI.pointee.open = { path, mode in + Mock.record("open(\(String(cString: path!)),\(mode.rawValue))") + return malloc(1) + } + fileAPI.pointee.close = { file in + Mock.record("close") + free(file) + return 0 + } + fileAPI.pointee.read = { _, buffer, length in + memset(buffer, 0xAB, Int(length)) + Mock.record("read(\(length))") + return Int32(length) + } + fileAPI.pointee.write = { _, _, length in + Mock.record("write(\(length))") + return Int32(length) + } + } + + // MARK: - JSON + + /// Simulates the OS parser's callback sequence for the document + /// `{"level": 3, "name": "up", "list": [1, true]}` regardless of input. + private static func installJSON() { + jsonAPI.initialize(to: playdate_json()) + + jsonAPI.pointee.decodeString = { decoder, _, outval in + guard let decoder else { return 0 } + + func intValue(_ value: Int32) -> json_value { + json_value(type: CChar(kJSONInteger.rawValue), data: .init(intval: value)) + } + + decoder.pointee.willDecodeSublist?(decoder, "_root", kJSONTable) + decoder.pointee.didDecodeTableValue?(decoder, "level", intValue(3)) + + "up".withCString { name in + decoder.pointee.didDecodeTableValue?( + decoder, "name", + json_value(type: CChar(kJSONString.rawValue), + data: .init(stringval: UnsafeMutablePointer(mutating: name)))) + } + + decoder.pointee.willDecodeSublist?(decoder, "list", kJSONArray) + decoder.pointee.didDecodeArrayValue?(decoder, 1, intValue(1)) + decoder.pointee.didDecodeArrayValue?( + decoder, 2, json_value(type: CChar(kJSONTrue.rawValue), data: .init(intval: 0))) + let list = decoder.pointee.didDecodeSublist?(decoder, "list", kJSONArray) + decoder.pointee.didDecodeTableValue?( + decoder, "list", + json_value(type: CChar(kJSONArray.rawValue), data: .init(arrayval: list))) + + let root = decoder.pointee.didDecodeSublist?(decoder, "_root", kJSONTable) + outval?.pointee = json_value(type: CChar(kJSONTable.rawValue), + data: .init(tableval: root)) + return 1 + } + } +} diff --git a/Tests/PlayDate/WrapperTests.swift b/Tests/PlayDate/WrapperTests.swift new file mode 100644 index 0000000..7dae25d --- /dev/null +++ b/Tests/PlayDate/WrapperTests.swift @@ -0,0 +1,217 @@ +// +// WrapperTests.swift +// Exercises the wrapper plumbing against the mock PlaydateAPI: argument +// forwarding, type conversions, callback trampolines, ownership, and +// registry lifecycles. +// +// Serialized because the mock's recording state is global (C function +// pointers cannot capture context). +// + +import CPlaydate +import Testing +@testable import PlayDate + +@Suite(.serialized) +struct WrapperTests { + init() { + Mock.resetRecordings() + } + + // MARK: System + + @Test func buttonStateConvertsMasks() { + Mock.buttonState = (current: kButtonA.rawValue | kButtonUp.rawValue, + pushed: kButtonB.rawValue, + released: kButtonLeft.rawValue) + + let (current, pushed, released) = System.buttonState + #expect(current == [.a, .up]) + #expect(pushed == .b) + #expect(released == .left) + } + + @Test func menuItemCallbackDispatchesToClosure() { + var selections = 0 + let item = System.addMenuItem(title: "reset") { _ in selections += 1 } + #expect(item != nil) + #expect(Mock.events.contains("addMenuItem(reset)")) + + // Simulate the user selecting the item: the OS invokes the recorded + // trampoline with the item's stored userdata. + let userdata = Mock.menuUserdata[item!.pointer] + #expect(userdata != nil) + Mock.menuCallback?(userdata) + Mock.menuCallback?(userdata) + #expect(selections == 2) + + System.removeAllMenuItems() + #expect(Mock.events.contains("removeAllMenuItems")) + } + + // MARK: Graphics + + @Test func fillRectForwardsCoordinatesAndSolidColor() { + Graphics.fillRect(x: 10, y: 20, width: 30, height: 40, color: .black) + #expect(Mock.events == ["fillRect(10,20,30,40,\(kColorBlack.rawValue))"]) + } + + @Test func fillRectMaterializesPatternBytes() { + let pattern = Graphics.Pattern(rows: (1, 2, 3, 4, 5, 6, 7, 8)) + Graphics.fillRect(x: 0, y: 0, width: 8, height: 8, color: .pattern(pattern)) + #expect(Mock.events == ["fillRect(0,0,8,8,pattern)"]) + #expect(Mock.patternBytes == [1, 2, 3, 4, 5, 6, 7, 8, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) + } + + @Test func drawTextSendsUTF8BytesAndLength() { + let width = Graphics.drawText("Hëllo", x: 4, y: 6) + #expect(Mock.events == ["drawText(Hëllo,enc:\(kUTF8Encoding.rawValue),4,6)"]) + #expect(width == "Hëllo".utf8.count) + } + + @Test func ownedBitmapIsFreedExactlyOnceOnDeinit() { + do { + let bitmap = Graphics.Bitmap(width: 32, height: 16) + _ = bitmap + } + #expect(Mock.eventCount("freeBitmap") == 1) + } + + @Test func tableVendedBitmapIsNotFreedButTableIs() { + do { + let table = Graphics.BitmapTable(count: 4, width: 8, height: 8) + let bitmap = table.bitmap(at: 0) + #expect(bitmap != nil) + #expect(table.bitmap(at: 3) == nil) // mock returns nil past index 0 + } + #expect(Mock.eventCount("freeBitmap") == 0) + #expect(Mock.eventCount("freeBitmapTable") == 1) + } + + // MARK: Sprite + + @Test func spriteUserdataRecoversWrapperInCallbacks() { + var updated: [ObjectIdentifier] = [] + let sprite = Sprite() + sprite.setUpdateFunction { updated.append(ObjectIdentifier($0)) } + + // Simulate the OS driving the sprite's update. + Mock.spriteUpdateCallback?(sprite.pointer) + #expect(updated == [ObjectIdentifier(sprite)]) + } + + @Test func spriteIsFreedOnDeinitAndNotWhileReferenced() { + var sprite: Sprite? = Sprite() + _ = sprite + #expect(Mock.eventCount("freeSprite") == 0) + sprite = nil + #expect(Mock.eventCount("freeSprite") == 1) + } + + @Test func moveWithCollisionsParsesInfoAndFreesTheCArray() { + let sprite = Sprite() + let (actual, collisions) = sprite.moveWithCollisions(goalX: 10, goalY: 20) + + #expect(actual.x == 9 && actual.y == 18) + #expect(collisions.count == 1) + let collision = try! #require(collisions.first) + #expect(collision.sprite === sprite) // recovered through userdata + #expect(collision.other === sprite) + #expect(collision.response == .bounce) + #expect(collision.overlaps) + #expect(collision.ti == 0.5) + #expect(collision.normal == (0, -1)) + #expect(collision.spriteRect.width == 8) + + // The C info array must be returned to the system allocator. + #expect(Mock.eventCount("sysFree") == 1) + } + + // MARK: Sound (regression tests for the CallbackSource lifecycle) + + @Test func callbackSourceIsRetainedUntilRemovedAndDispatches() { + let baseline = Sound.CallbackSource.live.count + + var produced = 0 + let source = Sound.addSource(stereo: false) { left, right in + produced += left.count + #expect(right == nil) + return true + } + #expect(Sound.CallbackSource.live.count == baseline + 1) + + // Simulate the audio engine pulling samples through the trampoline. + var samples = [Int16](repeating: 0, count: 64) + let result = samples.withUnsafeMutableBufferPointer { buffer in + Mock.audioCallback?(Mock.audioContext, buffer.baseAddress, nil, Int32(buffer.count)) + } + #expect(result == 1) + #expect(produced == 64) + + Sound.removeSource(source) + #expect(Sound.CallbackSource.live.count == baseline) + } + + @Test func channelCallbackSourceIsReleasedWhenChannelIsFreed() { + let baseline = Sound.CallbackSource.live.count + do { + let channel = Sound.Channel() + _ = channel.addCallbackSource(stereo: true) { _, _ in false } + #expect(Sound.CallbackSource.live.count == baseline + 1) + } + #expect(Mock.eventCount("freeChannel") == 1) + #expect(Sound.CallbackSource.live.count == baseline) + } + + @Test func channelRemoveSourceReleasesCallbackSource() { + let baseline = Sound.CallbackSource.live.count + let channel = Sound.Channel() + let source = channel.addCallbackSource(stereo: false) { _, _ in false } + channel.removeSource(source) + #expect(Sound.CallbackSource.live.count == baseline) + } + + // MARK: File + + @Test func fileHandleReadsWritesAndClosesExactlyOnce() throws { + do { + let handle = try File.Handle(path: "save.dat", mode: [.read, .readData]) + #expect(Mock.events.first == "open(save.dat,\(kFileRead.rawValue | kFileReadData.rawValue))") + + let bytes = try handle.read(length: 8) + #expect(bytes == [UInt8](repeating: 0xAB, count: 8)) + + let written = try handle.write([1, 2, 3]) + #expect(written == 3) + + try handle.close() + } + // deinit after an explicit close must not close again. + #expect(Mock.eventCount("close") == 1) + } + + // MARK: JSON + + @Test func jsonDecodeBuildsTheValueTree() throws { + let value = try JSON.decode("(input is ignored by the mock)") + + guard case .table(let entries) = value else { + Issue.record("expected a table at the root, got \(value)") + return + } + guard case .int(3)? = entries["level"] else { + Issue.record("expected level == 3") + return + } + guard case .string("up")? = entries["name"] else { + Issue.record("expected name == up") + return + } + guard case .array(let list)? = entries["list"], list.count == 2, + case .int(1) = list[0], case .bool(true) = list[1] else { + Issue.record("expected list == [1, true]") + return + } + } +}