Files
yattee/YatteeTests/PanelSettingsTests.swift
Arkadiusz Fal 1370f68c37 Rework details panel settings: pinned info, per-orientation visibility, section defaults
Pages list
- Info is pinned first and always enabled in every layout: normalized
  pages force it to the front and enabled (older presets self-correct),
  disabling it is a no-op, and any move that would displace it snaps
  back. The iOS/macOS list renders it outside the reorderable ForEach
  (no drag handle, nothing drops above it) and the editors hide its
  enabled toggle; tvOS also hides its move buttons. The "at least one
  page must stay enabled" guard and its strings are gone since Info
  always counts.
- iPhone never renders queue or comments as pages, so its Sections list
  hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes
  through moveVisiblePages, which keeps hidden entries in their stored
  slots. Comments get their own group on the Details Panel screen
  (pill mode + avatars) and the overlay hosts pass the avatars flag.

Default visibility
- "When Player Opens" is now configured per orientation
  (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy
  single value is still decoded and seeds both until set individually;
  presets that never set it default to portrait Visible, landscape
  Hidden. macOS shows one picker (no portrait layout).
- "Remember Last" no longer leaks across orientations: separate
  remembered flags, written by the portrait toggles and by the wide and
  corner layouts respectively, with the old shared flag read once as a
  fallback. The corner layout seeded from the portrait resolution
  before; it now uses landscape.

Section options (iOS inline-sections hosts)
- Chapters and Related editors gain "Expanded by Default" (on by
  default). The Info editor gains a Description group with "Expanded by
  Default" (off) and a "Collapsed Lines" stepper (1–10, default 3).
  Applied when the panel appears and on each video change.
- Expanding a collapsed section scrolls its header to the top of the
  panel: PanelPageScrollView can hand its scroll proxy to page content
  and each inline section is wrapped in one anchored container.
- The portrait panel's bottom fade sized its touch blocker from a stale
  "expanded comments pill" answer that ignored collapse on scroll, so a
  collapsed section header (the last item) sat under it and could not
  be tapped. The predicate now tracks the pill's actual collapse state.
2026-09-26 23:50:30 +02:00

296 lines
13 KiB
Swift

//
// PanelSettingsTests.swift
// YatteeTests
//
// Tests for details panel settings model (Codable compatibility).
//
import Foundation
import Testing
@testable import Yattee
@Suite("Panel Settings Tests")
struct PanelSettingsTests {
private func roundTrip(_ settings: PanelSettings) throws -> PanelSettings {
let encoded = try JSONEncoder().encode(settings)
return try JSONDecoder().decode(PanelSettings.self, from: encoded)
}
// MARK: - Round Trip
@Test("Default settings round-trip")
func defaultRoundTrip() throws {
let decoded = try roundTrip(.default)
#expect(decoded == PanelSettings.default)
}
@Test("Customized settings round-trip")
func customizedRoundTrip() throws {
var settings = PanelSettings.default
settings.layoutMode = .corner
settings.setDefaultVisibility(.remember, for: .landscape)
settings.showPageBarLabels = false
settings.setEnabled(false, for: .related)
settings.movePages(fromOffsets: IndexSet(integer: 1), toOffset: 0)
settings.updateSettings(
.info(InfoPageSettings(
showStatsRow: false,
showDescription: false,
descriptionCollapsedLines: 5,
descriptionExpandedByDefault: true
)),
for: .info
)
let decoded = try roundTrip(settings)
#expect(decoded == settings)
#expect(decoded.effectiveLayoutMode == .corner)
#expect(decoded.effectiveDefaultVisibility(for: .landscape) == .remember)
#expect(decoded.effectiveDefaultVisibility(for: .portrait) == .visible)
#expect(decoded.effectiveShowPageBarLabels == false)
#expect(!decoded.isPageEnabled(.related))
let info = decoded.configuration(for: .info)?.infoSettings
#expect(info?.effectiveShowStatsRow == false)
#expect(info?.effectiveShowDescription == false)
#expect(info?.effectiveShowChannelRow == true)
#expect(info?.effectiveDescriptionCollapsedLines == 5)
#expect(info?.effectiveDescriptionExpandedByDefault == true)
#expect(InfoPageSettings(descriptionCollapsedLines: 0).effectiveDescriptionCollapsedLines == 1)
#expect(InfoPageSettings().effectiveDescriptionCollapsedLines == 3)
}
@Test("Inline sections follow the configured page order and enablement")
func inlineSectionsFollowOrder() {
var settings = PanelSettings.default
// Default order: info, comments, related, chapters, queue
#expect(settings.inlineSections().map(\.pageType) == [.related, .chapters])
// Move chapters (index 3) above related (index 2)
settings.movePages(fromOffsets: IndexSet(integer: 3), toOffset: 2)
#expect(settings.inlineSections().map(\.pageType) == [.chapters, .related])
settings.setEnabled(false, for: .related)
#expect(settings.inlineSections().map(\.pageType) == [.chapters])
settings.updateSettings(.chapters(ChaptersPageSettings(showThumbnails: false)), for: .chapters)
#expect(settings.inlineSections().first?.chaptersSettings.effectiveShowThumbnails == false)
}
@Test("Per-page element settings round-trip for every page type")
func perPageSettingsRoundTrip() throws {
var settings = PanelSettings.default
settings.updateSettings(.comments(CommentsPageSettings(showAvatars: false)), for: .comments)
settings.updateSettings(.related(RelatedPageSettings(showThumbnails: false)), for: .related)
settings.updateSettings(.chapters(ChaptersPageSettings(showThumbnails: false, expandedByDefault: false)), for: .chapters)
settings.updateSettings(.queue(QueuePageSettings(showHistory: false)), for: .queue)
let decoded = try roundTrip(settings)
#expect(decoded == settings)
#expect(decoded.configuration(for: .comments)?.commentsSettings.effectiveShowAvatars == false)
#expect(decoded.configuration(for: .related)?.relatedSettings.effectiveShowThumbnails == false)
#expect(decoded.configuration(for: .chapters)?.chaptersSettings.effectiveShowThumbnails == false)
#expect(decoded.configuration(for: .chapters)?.chaptersSettings.effectiveExpandedByDefault == false)
#expect(decoded.configuration(for: .related)?.relatedSettings.effectiveExpandedByDefault == true)
#expect(decoded.configuration(for: .queue)?.queueSettings.effectiveShowHistory == false)
}
@Test("Typed accessors fall back to defaults for mismatched payloads")
func typedAccessorFallbacks() {
// An info payload read through the comments accessor yields defaults.
let page = PanelPageConfiguration(
pageType: .comments,
settings: .info(InfoPageSettings(showStatsRow: false))
)
#expect(page.commentsSettings.effectiveShowAvatars == true)
#expect(page.queueSettings.effectiveShowHistory == true)
}
// MARK: - Backward Compatibility
@Test("Layout without panelSettings decodes and falls back to default")
func layoutWithoutPanelSettings() throws {
let layout = PlayerControlsLayout.default
var json = try #require(
try JSONSerialization.jsonObject(with: JSONEncoder().encode(layout)) as? [String: Any]
)
json.removeValue(forKey: "panelSettings")
let data = try JSONSerialization.data(withJSONObject: json)
let decoded = try JSONDecoder().decode(PlayerControlsLayout.self, from: data)
#expect(decoded.panelSettings == nil)
#expect(decoded.effectivePanelSettings == .default)
}
@Test("Minimal page entries decode with defaults")
func minimalPageEntries() throws {
let json = """
{ "pages": [ { "pageType": "info" }, { "pageType": "comments" } ] }
"""
let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
#expect(decoded.pages.count == 2)
#expect(decoded.pages.allSatisfy { $0.isEnabled })
#expect(decoded.pages.allSatisfy { $0.region == .primary })
#expect(decoded.effectiveLayoutMode == .stacked)
#expect(decoded.effectiveDefaultVisibility(for: .portrait) == .visible)
#expect(decoded.effectiveDefaultVisibility(for: .landscape) == .hidden)
}
// MARK: - Forward Compatibility
@Test("Unknown page type from a future version is dropped")
func unknownPageTypeDropped() throws {
let json = """
{ "pages": [
{ "pageType": "info" },
{ "pageType": "futureHologram", "isEnabled": true },
{ "pageType": "queue" }
] }
"""
let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
#expect(decoded.pages.map(\.pageType) == [.info, .queue])
}
@Test("Unknown settings discriminator decodes to nil settings")
func unknownSettingsDiscriminator() throws {
let json = """
{ "pages": [
{ "pageType": "info", "settings": { "type": "futureKind", "futureKind": { "x": 1 } } }
] }
"""
let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
#expect(decoded.pages.count == 1)
#expect(decoded.pages.first?.settings == nil)
// nil settings fall back to defaults
#expect(decoded.pages.first?.infoSettings.effectiveShowDescription == true)
}
@Test("Legacy single default visibility seeds both orientations until set individually")
func legacyDefaultVisibilitySeedsBothOrientations() throws {
let json = """
{ "pages": [ { "pageType": "info" } ], "defaultVisibility": "remember" }
"""
var settings = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
#expect(settings.effectiveDefaultVisibility(for: .portrait) == .remember)
#expect(settings.effectiveDefaultVisibility(for: .landscape) == .remember)
settings.setDefaultVisibility(.visible, for: .portrait)
#expect(settings.effectiveDefaultVisibility(for: .portrait) == .visible)
#expect(settings.effectiveDefaultVisibility(for: .landscape) == .remember)
let decoded = try roundTrip(settings)
#expect(decoded == settings)
#expect(PanelDefaultVisibility.remember.resolve(lastVisible: true))
#expect(!PanelDefaultVisibility.hidden.resolve(lastVisible: true))
#expect(PanelDefaultVisibility.visible.resolve(lastVisible: false))
}
@Test("Unknown layoutMode and defaultVisibility decode to nil")
func unknownEnumValues() throws {
let json = """
{ "pages": [ { "pageType": "info" } ],
"layoutMode": "floating",
"defaultVisibility": "sometimes" }
"""
let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
#expect(decoded.layoutMode == nil)
#expect(decoded.defaultVisibility == nil)
#expect(decoded.effectiveLayoutMode == .stacked)
#expect(decoded.effectiveDefaultVisibility(for: .landscape) == .hidden)
}
// MARK: - Normalization
@Test("Normalized pages append missing types as disabled")
func normalizedPagesAppendMissing() throws {
let json = """
{ "pages": [ { "pageType": "info" } ] }
"""
let decoded = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
let normalized = decoded.normalizedPages
#expect(normalized.count == PanelPageType.allCases.count)
#expect(normalized.first?.pageType == .info)
#expect(normalized.dropFirst().allSatisfy { !$0.isEnabled })
}
@Test("Info is pinned first and always enabled")
func infoPinnedFirstAndEnabled() throws {
let json = """
{ "pages": [
{ "pageType": "related" },
{ "pageType": "info", "isEnabled": false },
{ "pageType": "chapters" }
] }
"""
var settings = try JSONDecoder().decode(PanelSettings.self, from: Data(json.utf8))
#expect(settings.normalizedPages.first?.pageType == .info)
#expect(settings.isPageEnabled(.info))
#expect(settings.enabledPages().map(\.pageType) == [.info, .related, .chapters])
settings.setEnabled(false, for: .info)
#expect(settings.isPageEnabled(.info))
// Moving info down, or another page above it, leaves info first
settings.movePages(fromOffsets: IndexSet(integer: 0), toOffset: 3)
#expect(settings.pages.first?.pageType == .info)
settings.movePages(fromOffsets: IndexSet(integer: 2), toOffset: 0)
#expect(settings.pages.map(\.pageType).prefix(3) == [.info, .chapters, .related])
}
@Test("Moving displayed pages keeps hidden pages in their slots")
func moveVisiblePagesKeepsHiddenSlots() {
var settings = PanelSettings.default
// Stored: info, comments, related, chapters, queue. A list that hides
// info (pinned) and queue displays comments, related, chapters.
let visible: [PanelPageType] = [.comments, .related, .chapters]
// Move chapters (displayed index 2) to the top of the displayed list
settings.moveVisiblePages(visible, fromOffsets: IndexSet(integer: 2), toOffset: 0)
#expect(settings.pages.map(\.pageType) == [.info, .chapters, .comments, .related, .queue])
// Hidden page in the middle keeps its slot
settings.movePages(fromOffsets: IndexSet(integer: 4), toOffset: 2)
#expect(settings.pages.map(\.pageType) == [.info, .chapters, .queue, .comments, .related])
settings.moveVisiblePages([.chapters, .comments, .related], fromOffsets: IndexSet(integer: 0), toOffset: 3)
#expect(settings.pages.map(\.pageType) == [.info, .comments, .queue, .related, .chapters])
}
@Test("Enabled pages filter by region")
func enabledPagesRegionFilter() {
let settings = PanelSettings.default
let primary = settings.enabledPages(in: .primary).map(\.pageType)
let belowVideo = settings.enabledPages(in: .belowVideo).map(\.pageType)
let all = settings.enabledPages().map(\.pageType)
#expect(primary == [.related, .chapters, .queue])
#expect(belowVideo == [.info, .comments])
#expect(all == [.info, .comments, .related, .chapters, .queue])
}
// MARK: - Mutations
@Test("Disabling and re-enabling a page preserves order")
func toggleKeepsOrder() {
var settings = PanelSettings.default
settings.setEnabled(false, for: .comments)
#expect(!settings.isPageEnabled(.comments))
settings.setEnabled(true, for: .comments)
#expect(settings.pages.map(\.pageType) == PanelSettings.default.pages.map(\.pageType))
}
@Test("Region assignment and layout mode round-trip through Codable")
func regionAndLayoutModeRoundTrip() throws {
var settings = PanelSettings.default
settings.layoutMode = .corner
settings.setRegion(.belowVideo, for: .related)
#expect(settings.configuration(for: .related)?.region == .belowVideo)
#expect(settings.enabledPages(in: .primary).map(\.pageType) == [.chapters, .queue])
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(PanelSettings.self, from: data)
#expect(decoded.effectiveLayoutMode == .corner)
#expect(decoded.configuration(for: .related)?.region == .belowVideo)
}
}