483 lines
19 KiB
Swift
483 lines
19 KiB
Swift
//
|
|
// 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 PlaydateKit
|
|
|
|
enum Mock {
|
|
// MARK: - Stable API allocations
|
|
|
|
nonisolated(unsafe) static let sysAPI = UnsafeMutablePointer<playdate_sys>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let displayAPI = UnsafeMutablePointer<playdate_display>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let gfxAPI = UnsafeMutablePointer<playdate_graphics>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let spriteAPI = UnsafeMutablePointer<playdate_sprite>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let soundAPI = UnsafeMutablePointer<playdate_sound>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let channelAPI = UnsafeMutablePointer<playdate_sound_channel>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let synthAPI = UnsafeMutablePointer<playdate_sound_synth>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let soundEffectAPI = UnsafeMutablePointer<playdate_sound_effect>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let lfoAPI = UnsafeMutablePointer<playdate_sound_lfo>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let delayLineAPI = UnsafeMutablePointer<playdate_sound_effect_delayline>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let fileAPI = UnsafeMutablePointer<playdate_file>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let jsonAPI = UnsafeMutablePointer<playdate_json>.allocate(capacity: 1)
|
|
nonisolated(unsafe) static let apiStruct = UnsafeMutablePointer<PlaydateAPI>.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<Int16>?, UnsafeMutablePointer<Int16>?, Int32) -> Int32)?
|
|
nonisolated(unsafe) static var audioContext: UnsafeMutableRawPointer?
|
|
/// Generator registrations per synth. The OS deallocs a generator's
|
|
/// userdata when the generator is replaced or its synth is freed; the
|
|
/// mock mirrors that contract.
|
|
struct SynthGenerator {
|
|
var render: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer<Int32>?, UnsafeMutablePointer<Int32>?, Int32, UInt32, Int32) -> Int32)?
|
|
var dealloc: (@convention(c) (UnsafeMutableRawPointer?) -> Void)?
|
|
var userdata: UnsafeMutableRawPointer?
|
|
}
|
|
nonisolated(unsafe) static var synthGenerators: [OpaquePointer: SynthGenerator] = [:]
|
|
/// The last custom-effect proc handed to `newEffect`, plus each
|
|
/// effect's userdata, for re-invocation by tests.
|
|
nonisolated(unsafe) static var effectProc: (@convention(c) (OpaquePointer?, UnsafeMutablePointer<Int32>?, UnsafeMutablePointer<Int32>?, Int32, Int32) -> Int32)?
|
|
nonisolated(unsafe) static var effectUserdata: [OpaquePointer: 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
|
|
synthGenerators = [:]
|
|
effectProc = nil
|
|
effectUserdata = [:]
|
|
}
|
|
|
|
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
|
|
}
|
|
gfxAPI.pointee.getBitmapTableInfo = { _, count, cellsWide in
|
|
Mock.record("getBitmapTableInfo")
|
|
// One bitmap, matching getTableBitmap vending index 0 only.
|
|
count?.pointee = 1
|
|
cellsWide?.pointee = 1
|
|
}
|
|
}
|
|
|
|
// 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<SpriteCollisionInfo>.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
|
|
}
|
|
channelAPI.pointee.addSource = { _, _ in
|
|
Mock.record("channelAddSource")
|
|
return 1
|
|
}
|
|
|
|
soundAPI.pointee.synth = UnsafePointer(synthAPI)
|
|
synthAPI.initialize(to: playdate_sound_synth())
|
|
synthAPI.pointee.newSynth = {
|
|
Mock.record("newSynth")
|
|
return Mock.fakePointer()
|
|
}
|
|
synthAPI.pointee.freeSynth = { synth in
|
|
Mock.record("freeSynth")
|
|
// Freeing a synth deallocs its generator's userdata.
|
|
if let synth, let generator = Mock.synthGenerators.removeValue(forKey: synth) {
|
|
generator.dealloc?(generator.userdata)
|
|
}
|
|
}
|
|
synthAPI.pointee.setGenerator = { synth, _, render, _, _, _, dealloc, _, userdata in
|
|
Mock.record("setGenerator")
|
|
guard let synth else { return }
|
|
// Replacing a generator deallocs the previous one first.
|
|
if let previous = Mock.synthGenerators[synth] {
|
|
previous.dealloc?(previous.userdata)
|
|
}
|
|
Mock.synthGenerators[synth] = SynthGenerator(render: render, dealloc: dealloc,
|
|
userdata: userdata)
|
|
}
|
|
synthAPI.pointee.setFrequencyModulator = { _, _ in
|
|
Mock.record("setFrequencyModulator")
|
|
}
|
|
|
|
soundAPI.pointee.lfo = UnsafePointer(lfoAPI)
|
|
lfoAPI.initialize(to: playdate_sound_lfo())
|
|
lfoAPI.pointee.newLFO = { _ in
|
|
Mock.record("newLFO")
|
|
return Mock.fakePointer()
|
|
}
|
|
lfoAPI.pointee.freeLFO = { _ in
|
|
Mock.record("freeLFO")
|
|
}
|
|
|
|
soundAPI.pointee.effect = UnsafePointer(soundEffectAPI)
|
|
soundEffectAPI.initialize(to: playdate_sound_effect())
|
|
soundEffectAPI.pointee.newEffect = { proc, userdata in
|
|
Mock.record("newEffect")
|
|
let pointer = Mock.fakePointer()
|
|
Mock.effectProc = proc
|
|
if let userdata {
|
|
Mock.effectUserdata[pointer] = userdata
|
|
}
|
|
return pointer
|
|
}
|
|
soundEffectAPI.pointee.freeEffect = { effect in
|
|
Mock.record("freeEffect")
|
|
if let effect {
|
|
Mock.effectUserdata[effect] = nil
|
|
}
|
|
}
|
|
soundEffectAPI.pointee.getUserdata = { effect in
|
|
guard let effect else { return nil }
|
|
return Mock.effectUserdata[effect]
|
|
}
|
|
|
|
soundEffectAPI.pointee.delayline = UnsafePointer(delayLineAPI)
|
|
delayLineAPI.initialize(to: playdate_sound_effect_delayline())
|
|
delayLineAPI.pointee.newDelayLine = { _, _ in
|
|
Mock.record("newDelayLine")
|
|
return Mock.fakePointer()
|
|
}
|
|
delayLineAPI.pointee.freeDelayLine = { _ in
|
|
Mock.record("freeDelayLine")
|
|
}
|
|
delayLineAPI.pointee.addTap = { _, _ in
|
|
Mock.record("addTap")
|
|
return Mock.fakePointer()
|
|
}
|
|
delayLineAPI.pointee.freeTap = { _ in
|
|
Mock.record("freeTap")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// A simplified encoder: each call emits its JSON fragment verbatim,
|
|
// without the real OS's separator and pretty-printing logic.
|
|
jsonAPI.pointee.initEncoder = { encoder, write, userdata, _ in
|
|
Mock.record("initEncoder")
|
|
guard let encoder else { return }
|
|
encoder.pointee.writeStringFunc = write
|
|
encoder.pointee.userdata = userdata
|
|
encoder.pointee.startTable = { Mock.emitJSON($0, "{") }
|
|
encoder.pointee.endTable = { Mock.emitJSON($0, "}") }
|
|
encoder.pointee.startArray = { Mock.emitJSON($0, "[") }
|
|
encoder.pointee.endArray = { Mock.emitJSON($0, "]") }
|
|
encoder.pointee.addTableMember = { encoder, name, length in
|
|
guard let name else { return }
|
|
let bytes = UnsafeRawBufferPointer(start: name, count: Int(length))
|
|
Mock.emitJSON(encoder, "\"\(String(decoding: bytes, as: UTF8.self))\":")
|
|
}
|
|
encoder.pointee.addArrayMember = { Mock.emitJSON($0, ",") }
|
|
encoder.pointee.writeInt = { Mock.emitJSON($0, "\($1)") }
|
|
encoder.pointee.writeString = { encoder, string, length in
|
|
guard let string else { return }
|
|
let bytes = UnsafeRawBufferPointer(start: string, count: Int(length))
|
|
Mock.emitJSON(encoder, "\"\(String(decoding: bytes, as: UTF8.self))\"")
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func emitJSON(_ encoder: UnsafeMutablePointer<json_encoder>?, _ text: String) {
|
|
guard let encoder, let write = encoder.pointee.writeStringFunc else { return }
|
|
text.withCString { write(encoder.pointee.userdata, $0, Int32(text.utf8.count)) }
|
|
}
|
|
}
|