importer chnges
This commit is contained in:
@@ -295,7 +295,28 @@ struct SongDragReference: Codable, Sendable {
|
||||
func createPlaylist(_ name: String) { perform { try library?.addEntity("playlists",name:name); try refresh() } }
|
||||
func addToPlaylist(_ playlist: Int64, song: Int64) { perform { try library?.addToPlaylist(playlist,song:song); try refresh() } }
|
||||
func setFavourite(_ song: Int64, singer: Int64, included: Bool) { perform { try library?.setFavourite(song:song,singer:singer,included:included);try refresh() } }
|
||||
func tuningForControls(_ song:Int64) -> SongPreference {
|
||||
var value = preference(song)
|
||||
if player.currentSongID == song,player.duration > 0 { value.pitch = player.pitch;value.tempo = player.tempo }
|
||||
return value
|
||||
}
|
||||
var liveSingerName: String { playbackSinger.flatMap { id in singers.first{$0.id == id}?.name } ?? "No singer · Temporary" }
|
||||
func updateLiveTuning(pitch:Double? = nil,tempo:Double? = nil) {
|
||||
perform {
|
||||
guard let song = player.currentSongID,player.duration > 0,let library else { throw LibraryError.message("Load a song before adjusting live key or tempo") }
|
||||
let nextPitch = pitch ?? player.pitch,nextTempo = tempo ?? player.tempo
|
||||
guard nextPitch.isFinite,nextTempo.isFinite,(-12...12).contains(nextPitch),(0.5...1.5).contains(nextTempo) else { throw LibraryError.message("Use a key from −12 to +12 and tempo from 50% to 150%") }
|
||||
if let singer = playbackSinger {
|
||||
var saved = try library.preference(song:song,singer:singer)
|
||||
saved.pitch = nextPitch;saved.tempo = nextTempo
|
||||
try library.savePreference(song:song,singer:singer,value:saved)
|
||||
if selectedSinger == singer { tuningCache[song] = saved }
|
||||
}
|
||||
player.pitch = nextPitch;player.tempo = nextTempo;objectWillChange.send()
|
||||
}
|
||||
}
|
||||
func updateTuning(_ song: Int64, pitch: Double? = nil, tempo: Double? = nil) {
|
||||
if player.currentSongID == song,player.duration > 0 { updateLiveTuning(pitch:pitch,tempo:tempo);return }
|
||||
perform {
|
||||
guard let library else { throw LibraryError.message("Library unavailable") }
|
||||
var value = preference(song)
|
||||
@@ -677,14 +698,21 @@ struct LibraryView: View {
|
||||
@State private var focusedLocation: Int64?
|
||||
@State private var editingSong: Song?
|
||||
@State private var libraryRows = [Song]()
|
||||
@State private var page = 0
|
||||
@State private var tableSort = [KeyPathComparator(\Song.title)]
|
||||
@State private var librarySort = "Song Name"
|
||||
@State private var sortAscending = true
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
private var visibleSongs: [Song] { libraryRows }
|
||||
private func updateLibraryRows() {
|
||||
private var pagination: LibraryPage { LibraryPage(total:libraryRows.count,requested:page) }
|
||||
private var visibleSongs: [Song] { Array(libraryRows[pagination.range]) }
|
||||
private func updateLibraryRows(resetPage:Bool = false) {
|
||||
let filtered = model.songs.map { source -> Song in var song = source;if model.selectedSinger != nil { song.rating = model.displayedRating(source) };return song }.filter { SongSearch.matches($0,query:search) }
|
||||
libraryRows = filtered.sorted(using:tableSort)
|
||||
page = LibraryPage(total:libraryRows.count,requested:resetPage ? 0 : page).number
|
||||
if source == "Library" {
|
||||
let ids = Set(visibleSongs.map(\.id));tableSelection.formIntersection(ids)
|
||||
if let selected,!ids.contains(selected) { self.selected = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedSong: Song? { model.songs.first { $0.id == selected } }
|
||||
@@ -692,7 +720,7 @@ struct LibraryView: View {
|
||||
HSplitView {
|
||||
sidebar.frame(minWidth:220,idealWidth:240,maxWidth:260)
|
||||
VStack(spacing:0) {
|
||||
TransportView(player:model.player,search:$search,hasSelection:selectedSong != nil,
|
||||
TransportView(player:model.player,model:model,search:$search,hasSelection:selectedSong != nil,
|
||||
play:playSelected,next:playNext,display:{ openWindow(id:"lyrics") })
|
||||
.padding(20)
|
||||
Divider()
|
||||
@@ -756,7 +784,7 @@ struct LibraryView: View {
|
||||
.onAppear { updateLibraryRows() }
|
||||
.onChange(of:model.songs) { _ in updateLibraryRows() }
|
||||
.onChange(of:model.tuningCache) { _ in updateLibraryRows() }
|
||||
.onChange(of:search) { _ in updateLibraryRows() }
|
||||
.onChange(of:search) { _ in updateLibraryRows(resetPage:true) }
|
||||
.sheet(item: $model.creating) { kind in
|
||||
AddEntitySheet(model: model, kind: kind) { id in
|
||||
if kind == .singer { expandedSingers.insert(id);expandedSongLists.insert(id);singersExpanded = true;showSinger(id,section:"All Songs") } else { model.chooseVenue(id) }
|
||||
@@ -800,7 +828,7 @@ struct LibraryView: View {
|
||||
} message: { Text(model.error ?? "") }
|
||||
}
|
||||
@ViewBuilder private var sourceContent: some View {
|
||||
if source == "Library" { songTable }
|
||||
if source == "Library" { VStack(spacing:0) { songTable;Divider();libraryPagination } }
|
||||
else if source == "Queue" { queuePane }
|
||||
else if source == "Settings" { StudioSettingsPane(model:model) }
|
||||
else if source == "Rotation" { RotationPane(model:model) }
|
||||
@@ -947,8 +975,20 @@ struct LibraryView: View {
|
||||
} else { Text("Choose a singer from the sidebar.").foregroundStyle(.secondary);Spacer() }
|
||||
}.padding(14).frame(maxWidth:.infinity,maxHeight:.infinity).background(StudioTheme.navy).overlay(Rectangle().stroke(StudioTheme.line))
|
||||
}
|
||||
private var libraryPagination: some View {
|
||||
HStack(spacing:12) {
|
||||
Text(pagination.total == 0 ? "No songs" : "Showing \(pagination.range.lowerBound+1)–\(pagination.range.upperBound) of \(pagination.total) songs").foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button { changePage(0) } label: { Image(systemName:"backward.end") }.disabled(pagination.number == 0).help("First page").accessibilityLabel("First library page")
|
||||
Button { changePage(pagination.number-1) } label: { Image(systemName:"chevron.left") }.disabled(pagination.number == 0).help("Previous page").accessibilityLabel("Previous library page")
|
||||
Text("Page \(pagination.number+1) of \(pagination.count)").monospacedDigit().frame(minWidth:95)
|
||||
Button { changePage(pagination.number+1) } label: { Image(systemName:"chevron.right") }.disabled(pagination.number+1 >= pagination.count).help("Next page").accessibilityLabel("Next library page").accessibilityIdentifier("library.page.next")
|
||||
Button { changePage(pagination.count-1) } label: { Image(systemName:"forward.end") }.disabled(pagination.number+1 >= pagination.count).help("Last page").accessibilityLabel("Last library page")
|
||||
}.font(.caption).controlSize(.small).buttonStyle(.borderless).padding(.horizontal,16).padding(.vertical,8)
|
||||
}
|
||||
private func changePage(_ requested:Int) { page = LibraryPage(total:libraryRows.count,requested:requested).number;tableSelection = [];selected = nil }
|
||||
private var songTable: some View {
|
||||
Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows()})) {
|
||||
Table(visibleSongs,selection:$tableSelection,sortOrder:Binding(get:{tableSort},set:{tableSort = $0;updateLibraryRows(resetPage:true)})) {
|
||||
TableColumn("ID",value:\Song.id) { song in
|
||||
HStack(spacing:5) { NativeSongDragHandle(reference:model.dragReference(song.id),title:song.title).frame(width:14,height:24);Text("\(song.id)").foregroundStyle(.tertiary).monospacedDigit() }
|
||||
}.width(min:48,ideal:55,max:75)
|
||||
@@ -965,12 +1005,12 @@ struct LibraryView: View {
|
||||
TableColumn("Time",value:\Song.duration) { song in Text(durationLabel(song.duration)).monospacedDigit().foregroundStyle(.secondary) }.width(50)
|
||||
TableColumn("Key") { song in
|
||||
HStack(spacing:2) {
|
||||
Text(String(format:"%+.0f",model.preference(song.id).pitch)).monospacedDigit().frame(width:24)
|
||||
Stepper("Key",value:Binding(get:{model.preference(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini)
|
||||
}.help("Saved key for this singer or song")
|
||||
Text(String(format:"%+.0f",model.tuningForControls(song.id).pitch)).monospacedDigit().frame(width:24)
|
||||
Stepper("Key",value:Binding(get:{model.tuningForControls(song.id).pitch},set:{model.updateTuning(song.id,pitch:$0)}),in:-12...12,step:1).labelsHidden().controlSize(.mini)
|
||||
}.help(model.player.currentSongID == song.id ? "Live key · \(model.liveSingerName)" : "Saved key for the selected singer or song")
|
||||
}.width(65)
|
||||
TableColumn("Tempo") { song in
|
||||
Picker("Tempo",selection:Binding(get:{model.preference(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) {
|
||||
Picker("Tempo",selection:Binding(get:{model.tuningForControls(song.id).tempo},set:{model.updateTuning(song.id,tempo:$0)})) {
|
||||
ForEach(10..<31) { Text("\($0*5)%").tag(Double($0)/20) }
|
||||
}.labelsHidden().controlSize(.small)
|
||||
}.width(78)
|
||||
@@ -1167,7 +1207,7 @@ struct LibraryView: View {
|
||||
private var footer: some View {
|
||||
HStack(spacing:8) {
|
||||
Circle().fill(Color.green.opacity(0.85)).frame(width:5,height:5)
|
||||
Text("\(visibleSongs.count) songs").font(.caption).foregroundStyle(.secondary)
|
||||
Text("\(libraryRows.count) songs").font(.caption).foregroundStyle(.secondary)
|
||||
if !model.tagsStatus.isEmpty { Text(model.tagsStatus).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||||
Spacer()
|
||||
Menu { waveformActions } label: { Label("Waveforms",systemImage:"waveform") }.controlSize(.small).disabled(model.generatingWaveforms || model.scanningSource != nil)
|
||||
@@ -1194,9 +1234,10 @@ struct LibraryView: View {
|
||||
model.perform { try model.play(song) }
|
||||
}
|
||||
private func playNext() {
|
||||
guard !visibleSongs.isEmpty else { return }
|
||||
let index = visibleSongs.firstIndex { $0.id == selected } ?? -1
|
||||
selected = visibleSongs[(index + 1) % visibleSongs.count].id; playSelected()
|
||||
guard !libraryRows.isEmpty else { return }
|
||||
let index = libraryRows.firstIndex { $0.id == selected } ?? -1
|
||||
let next = (index+1)%libraryRows.count
|
||||
page = next/LibraryPage.size;selected = libraryRows[next].id;tableSelection = [libraryRows[next].id];playSelected()
|
||||
}
|
||||
private func addEntity() {
|
||||
model.perform {
|
||||
@@ -1567,8 +1608,27 @@ private struct AddEntitySheet: View {
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
private struct LiveTuningControls: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@ObservedObject var player: Player
|
||||
var body: some View {
|
||||
VStack(alignment:.leading,spacing:5) {
|
||||
Text(model.liveSingerName).font(.caption2).foregroundStyle(.secondary).lineLimit(1).help("Live changes save to the performing singer and this song")
|
||||
HStack {
|
||||
Text("Key").frame(width:40,alignment:.leading)
|
||||
Stepper(value:Binding(get:{player.pitch},set:{model.updateLiveTuning(pitch:$0)}),in:-12...12,step:1) { Text(String(format:"%+.0f",player.pitch)).monospacedDigit().frame(width:30) }.accessibilityLabel("Live key for performing singer")
|
||||
}
|
||||
HStack {
|
||||
Text("Tempo").frame(width:40,alignment:.leading)
|
||||
Stepper(value:Binding(get:{player.tempo},set:{model.updateLiveTuning(tempo:$0)}),in:0.5...1.5,step:0.01) { Text(String(format:"%.0f%%",player.tempo*100)).monospacedDigit().frame(width:40) }.accessibilityLabel("Live tempo for performing singer")
|
||||
Button { model.updateLiveTuning(pitch:0,tempo:1) } label: { Image(systemName:"arrow.counterclockwise") }.buttonStyle(.borderless).help("Reset this singer’s key and tempo")
|
||||
}
|
||||
}.font(.caption).frame(width:190).disabled(player.currentSongID == nil || player.duration <= 0)
|
||||
}
|
||||
}
|
||||
private struct TransportView: View {
|
||||
@ObservedObject var player: Player
|
||||
@ObservedObject var model: AppModel
|
||||
@Binding var search: String
|
||||
let hasSelection: Bool
|
||||
let play: () -> Void
|
||||
@@ -1576,7 +1636,7 @@ private struct TransportView: View {
|
||||
let display: () -> Void
|
||||
var body: some View {
|
||||
HStack(spacing:24) {
|
||||
VStack(spacing:18) {
|
||||
VStack(spacing:10) {
|
||||
HStack(spacing:8) {
|
||||
TransportButton(symbol:"gobackward.10",label:"Back 10 seconds",size:28,enabled:player.duration > 0) { player.seek(player.time-10) }
|
||||
TransportButton(symbol:player.playing ? "pause.fill" : "play.fill",label:player.playing ? "Pause" : "Play",size:48,enabled:hasSelection || player.duration > 0) {
|
||||
@@ -1591,6 +1651,7 @@ private struct TransportView: View {
|
||||
Slider(value:$player.volume,in:0...1).controlSize(.small).accessibilityLabel("Global master volume")
|
||||
Image(systemName:"speaker.wave.3.fill").font(.caption2).foregroundStyle(.secondary)
|
||||
}.frame(width:172)
|
||||
LiveTuningControls(model:model,player:player)
|
||||
}.frame(width:200)
|
||||
NowPlayingWaveform(player:player)
|
||||
}.frame(height:174)
|
||||
@@ -1819,8 +1880,9 @@ private struct PlaybackSettings: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let selectedSong: Int64?
|
||||
private var target: Int64? { selectedSong ?? player.currentSongID }
|
||||
private var pitch: Binding<Double> { Binding(get:{target.map{model.preference($0).pitch} ?? 0},set:{value in if let target { model.updateTuning(target,pitch:value) }}) }
|
||||
private var tempo: Binding<Double> { Binding(get:{target.map{model.preference($0).tempo} ?? 1},set:{value in if let target { model.updateTuning(target,tempo:value) }}) }
|
||||
private var live: Bool { player.currentSongID != nil && player.duration > 0 }
|
||||
private var pitch: Binding<Double> { Binding(get:{live ? player.pitch : target.map{model.preference($0).pitch} ?? 0},set:{value in if live { model.updateLiveTuning(pitch:value) } else if let target { model.updateTuning(target,pitch:value) }}) }
|
||||
private var tempo: Binding<Double> { Binding(get:{live ? player.tempo : target.map{model.preference($0).tempo} ?? 1},set:{value in if live { model.updateLiveTuning(tempo:value) } else if let target { model.updateTuning(target,tempo:value) }}) }
|
||||
private var rating: Binding<Int> { Binding(get:{target.flatMap { id in model.songs.first{$0.id == id} }.map{model.displayedRating($0)} ?? 0},set:{value in if let song = model.songs.first(where:{$0.id == target}) { model.rateSong(song,value) }}) }
|
||||
var body: some View {
|
||||
HStack(spacing:10) {
|
||||
@@ -1828,13 +1890,13 @@ private struct PlaybackSettings: View {
|
||||
Picker("Key",selection:pitch) { ForEach(-12..<13) { Text(String(format:"%+d",$0)).tag(Double($0)) } }.labelsHidden().frame(width:70)
|
||||
Text("Tempo");Slider(value:tempo,in:0.5...1.5,step:0.05).frame(width:85)
|
||||
Text(String(format:"%.0f%%",tempo.wrappedValue*100)).monospacedDigit().frame(width:40)
|
||||
Text("Rating");GoldRating(rating:rating).frame(width:88)
|
||||
Text(model.selectedSinger == nil ? "Song defaults · Saved" : "Singer settings · Saved").font(.caption).foregroundStyle(.secondary)
|
||||
Text("Selected rating");GoldRating(rating:rating).frame(width:88)
|
||||
Text(live ? "Live: \(model.liveSingerName)" : model.selectedSinger == nil ? "Song defaults · Saved" : "Singer settings · Saved").font(.caption).foregroundStyle(.secondary)
|
||||
Menu {
|
||||
Button("Reset key / tempo") { pitch.wrappedValue = 0;tempo.wrappedValue = 1 }
|
||||
ForEach([-1.0,-0.5,0,0.5,1.0],id:\.self) { offset in Button(String(format:"Graphics offset %+.1fs",offset)) { player.graphicsOffset = offset } }
|
||||
} label: { Image(systemName:"slider.horizontal.3") }.menuStyle(.borderlessButton).frame(width:22)
|
||||
}.font(.system(size:11)).disabled(target == nil)
|
||||
}.font(.system(size:11)).disabled(target == nil && !live)
|
||||
}
|
||||
}
|
||||
private struct SeekBar: View {
|
||||
|
||||
@@ -627,3 +627,14 @@ extension Library {
|
||||
try execute("UPDATE songs SET title=?,artist=?,album=?,track_number=?,rendition=?,metadata_edited=? WHERE id=?",[value.title,value.artist,value.album,value.track,value.rendition,String(edited),String(id)])
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryPage {
|
||||
static let size = 20
|
||||
let total: Int
|
||||
let count: Int
|
||||
let number: Int
|
||||
var range: Range<Int> { let start = number*Self.size;return start..<min(total,start+Self.size) }
|
||||
init(total:Int,requested:Int) {
|
||||
self.total = max(0,total);count = max(1,(max(0,total)+Self.size-1)/Self.size);number = min(count-1,max(0,requested))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +406,56 @@ final class KJamsCloneTests: XCTestCase {
|
||||
XCTAssertEqual(model.songs.first?.title,"Title");XCTAssertEqual(model.songs.first?.artist,"Artist");XCTAssertEqual(model.songs.first?.trackNumber,"01");XCTAssertTrue(model.canUndoNames)
|
||||
try await Task.sleep(nanoseconds:100_000_000)
|
||||
}
|
||||
@MainActor func testLiveTuningFollowsPerformingSingerAndPreservesOtherPreferences() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
|
||||
let model = AppModel(storageURL:root);defer { model.player.stop() }
|
||||
_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"Alex"),b = try model.createEntity(.singer,name:"Sam"),db = try XCTUnwrap(model.library)
|
||||
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
let id = try db.add(title:"Demo",audio:audio.path,cdg:nil),other = try db.add(title:"Other",audio:"/other.mp3",cdg:nil)
|
||||
try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.05))
|
||||
try db.savePreference(song:id,singer:a,value:.init(pitch:2,tempo:1.1,rating:5));try db.savePreference(song:id,singer:b,value:.init(pitch:-4,tempo:0.8,rating:2))
|
||||
try model.refresh();try model.play(try XCTUnwrap(model.songs.first{$0.id == id}),singer:a)
|
||||
model.selectedSinger = b
|
||||
try await Task.sleep(nanoseconds:100_000_000);let previousTime = model.player.time
|
||||
model.updateLiveTuning(pitch:3,tempo:0.95)
|
||||
XCTAssertTrue(model.player.playing);XCTAssertEqual(model.playbackSinger,a);XCTAssertEqual(model.player.pitch,3);XCTAssertEqual(model.player.tempo,0.95)
|
||||
XCTAssertEqual(try db.preference(song:id,singer:a),.init(pitch:3,tempo:0.95,rating:5))
|
||||
XCTAssertEqual(try db.preference(song:id,singer:b),.init(pitch:-4,tempo:0.8,rating:2));XCTAssertEqual(try db.defaultPreference(song:id).pitch,1)
|
||||
XCTAssertEqual(model.preference(id).pitch,-4);XCTAssertEqual(model.tuningForControls(id).pitch,3)
|
||||
model.updateTuning(id,tempo:1.02)
|
||||
XCTAssertEqual(try db.preference(song:id,singer:a).tempo,1.02);XCTAssertEqual(try db.preference(song:id,singer:b).tempo,0.8)
|
||||
try await Task.sleep(nanoseconds:100_000_000);XCTAssertGreaterThanOrEqual(model.player.time,previousTime)
|
||||
XCTAssertEqual(try db.preference(song:other,singer:a).pitch,0)
|
||||
let reopened = try Library(url:root.appendingPathComponent("Library.sqlite"));XCTAssertEqual(try reopened.preference(song:id,singer:a).pitch,3)
|
||||
}
|
||||
@MainActor func testLiveTuningWithoutPerformingSingerIsTemporary() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
|
||||
let model = AppModel(storageURL:root);defer { model.player.stop() }
|
||||
let db = try XCTUnwrap(model.library),audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
|
||||
let id = try db.add(title:"Demo",audio:audio.path,cdg:nil);try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.1));try model.refresh()
|
||||
try model.play(try XCTUnwrap(model.songs.first));XCTAssertNil(model.playbackSinger)
|
||||
let singer = try model.createEntity(.singer,name:"Browsing Singer");model.selectedSinger = singer
|
||||
model.updateTuning(id,pitch:4,tempo:0.9)
|
||||
XCTAssertEqual(model.player.pitch,4);XCTAssertEqual(model.player.tempo,0.9)
|
||||
XCTAssertEqual(try db.defaultPreference(song:id).pitch,1);XCTAssertEqual(try db.defaultPreference(song:id).tempo,1.1)
|
||||
XCTAssertEqual(try db.preference(song:id,singer:singer).pitch,1)
|
||||
}
|
||||
@MainActor func testInvalidLiveTuningDoesNotChangePlaybackOrSavedSettings() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
|
||||
let model = AppModel(storageURL:root);defer { model.player.stop() }
|
||||
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),db = try XCTUnwrap(model.library)
|
||||
let id = try db.add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();try model.play(try XCTUnwrap(model.songs.first))
|
||||
model.updateLiveTuning(pitch:.nan,tempo:3)
|
||||
XCTAssertNotNil(model.error);XCTAssertEqual(model.player.pitch,0);XCTAssertEqual(model.player.tempo,1);XCTAssertTrue(model.player.playing);XCTAssertEqual(try db.defaultPreference(song:id).pitch,0)
|
||||
}
|
||||
func testLibraryPaginationUsesTwentyRowsAndClampsAfterFiltering() {
|
||||
let rows = Array(1...43)
|
||||
let first = LibraryPage(total:rows.count,requested:0),middle = LibraryPage(total:rows.count,requested:1),last = LibraryPage(total:rows.count,requested:99)
|
||||
XCTAssertEqual(first.count,3);XCTAssertEqual(Array(rows[first.range]),Array(1...20));XCTAssertEqual(Array(rows[middle.range]),Array(21...40));XCTAssertEqual(Array(rows[last.range]),[41,42,43])
|
||||
let filtered = LibraryPage(total:7,requested:2);XCTAssertEqual(filtered.number,0);XCTAssertEqual(filtered.range,0..<7)
|
||||
let empty = LibraryPage(total:0,requested:9);XCTAssertEqual(empty.number,0);XCTAssertTrue(empty.range.isEmpty)
|
||||
XCTAssertEqual(LibraryPage(total:20,requested:1).count,1);XCTAssertEqual(LibraryPage(total:40,requested:1).range,20..<40)
|
||||
}
|
||||
func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] }
|
||||
func testPresetTileXORAndInvalidCoordinates() {
|
||||
var d = CDGDecoder()
|
||||
|
||||
Reference in New Issue
Block a user