import Foundation @testable @preconcurrency import LayoutKit import Testing import VGSL @Suite struct GalleryViewLayoutTests { @Test func producesEqualToModelBlocksCountNumberOfFrames() { let model = Blocks.horizontalCenterModel let layout = GalleryViewLayout(model: model) #expect(layout.blockFrames.count == model.items.count) } @Test func whenHasHorizontalDirection_producesSummaryWidthAndBlockMaximumHeightContentSizePlusCrossInsets( ) { let layout = GalleryViewLayout(model: Blocks.horizontalCenterModel) #expect(layout.contentSize.width == Blocks.contentAxialSize) #expect(layout.contentSize.height == Blocks.contentCrossMaxSize + Blocks.fixedInsets.sum) } @Test func whenDoesNotHaveBounds_andHorizontalDirection_appliesResizableBlockHeightToOtherBlocksMaximumHeight( ) throws { let model = Blocks.horizontalCenterModel let layout = GalleryViewLayout(model: model) let index = try #require(model.items.firstIndex(where: { $0.content.isVerticallyResizable })) #expect(layout.blockFrames[index].height == Blocks.contentCrossMaxSize) } @Test func whenHasBounds_andHorizontalDirection_appliesResizableBlockHeightToInsetedBoundsHeight( ) throws { let model = Blocks.horizontalCenterModel let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let height = boundsSize.height - Blocks.fixedInsets.sum let index = try #require(model.items.firstIndex(where: { $0.content.isVerticallyResizable })) #expect(layout.blockFrames[index].height == height) } @Test func whenHasVerticalDirection_producesSummaryHeightAndBlockMaximumWidthContentSizePlusCrossInsets( ) { let layout = GalleryViewLayout(model: Blocks.verticalCenterModel) #expect(layout.contentSize.width == Blocks.contentCrossMaxSize + Blocks.fixedInsets.sum) #expect(layout.contentSize.height == Blocks.contentAxialSize) } @Test func whenDoesNotHaveBounds_andVerticalDirection_appliesResizableBlockWidthToOtherBlocksMaximumWidth( ) throws { let model = Blocks.verticalCenterModel let layout = GalleryViewLayout(model: model) let index = try #require(model.items.firstIndex(where: { $0.content.isHorizontallyResizable })) #expect(layout.blockFrames[index].width == Blocks.contentCrossMaxSize) } @Test func whenHasBounds_andVerticalDirection_appliesResizableBlockWidthToInsetedBoundsWidth() throws { let model = Blocks.verticalCenterModel let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let index = try #require(model.items.firstIndex(where: { $0.content.isHorizontallyResizable })) #expect(layout.blockFrames[index].width == boundsSize.width - Blocks.fixedInsets.sum) } @Test func whenHasResizableAxialInsets_andHorizontalDirection_calculatesContentSizeFittingProvidedSize( ) { let model = Blocks.horizontalResizableModel let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let expectedSize = Blocks.contentAxialSizeWithoutSideGaps + boundsSize.width - Blocks .resizableInsets.maxViewportSize #expect(layout.contentSize.width == expectedSize) } @Test func whenHasResizableAxialInsets_andVerticalDirection_calculatesContentSizeFittingProvidedSize() { let model = Blocks.verticalResizableModel let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let expectedSize = Blocks.contentAxialSizeWithoutSideGaps + boundsSize.height - Blocks .resizableInsets.maxViewportSize #expect(layout.contentSize.height == expectedSize) } @Test func whenHasResizableAxialInsets_andHorizontalDirection_producesSideGapsFittingProvidedSize() { let model = Blocks.horizontalResizableModel let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let expectedInsetValue = (boundsSize.width - Blocks.resizableInsets.maxViewportSize) / 2 #expect(layout.blockFrames.first?.minX == expectedInsetValue) #expect(layout.blockFrames.last?.maxX == layout.contentSize.width - expectedInsetValue) } @Test func whenHasResizableAxialInsets_andVerticalDirection_producesSideGapsFittingProvidedSize() { let model = Blocks.verticalResizableModel let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let expectedInsetValue = (boundsSize.height - Blocks.resizableInsets.maxViewportSize) / 2 #expect(layout.blockFrames.first?.minY == expectedInsetValue) #expect(layout.blockFrames.last?.maxY == layout.contentSize.height - expectedInsetValue) } @Test func whenHasResizableCrossInsets_andVerticalDirection_producesSideGapsFittingProvidedSize() { let model = Blocks.makeModel( direction: .vertical, metrics: .resizableCross, crossAlignment: .leading ) let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let expectedInsetValue = (boundsSize.width - Blocks.resizableInsets.maxViewportSize) / 2 #expect(layout.blockFrames.first?.minX == expectedInsetValue) #expect(layout.blockFrames.first?.maxX == layout.contentSize.width - expectedInsetValue) } @Test func whenHasResizableCrossInsets_andHorizontalDirection_producesSideGapsFittingProvidedSize() { let model = Blocks.makeModel( direction: .horizontal, metrics: .resizableCross, crossAlignment: .leading ) let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let expectedInsetValue = (boundsSize.height - Blocks.resizableInsets.maxViewportSize) / 2 #expect(layout.blockFrames.first?.minY == expectedInsetValue) #expect(layout.blockFrames.first?.maxY == layout.contentSize.height - expectedInsetValue) } @Test func whenHasHorizontalDirection_startsLeftEdgeAtFirstGapAndFinishesRightEdgeBeforeLastGap() { let layout = GalleryViewLayout(model: Blocks.horizontalCenterModel) #expect(layout.blockFrames.first?.minX == Blocks.gapSize) #expect(layout.blockFrames.last?.maxX == Blocks.gapSize + Blocks.framesAxialSize) } @Test func whenHasHorizontalDirection_andCenterVerticalAlignment_layoutFramesAroundInsetedVerticalCenter( ) { let layout = GalleryViewLayout(model: Blocks.horizontalCenterModel, boundsSize: boundsSize) let center = floor( Blocks.fixedInsets.leading + (boundsSize.height - Blocks.fixedInsets.sum) / 2 ) #expect(layout.blockFrames.allSatisfy { $0.midY.isApproximatelyEqualTo(center) }) } @Test func whenHasHorizontalDirection_andLeadingVerticalAlignment_layoutFramesAboveCrossInsets() { let layout = GalleryViewLayout(model: Blocks.horizontalTrailingModel, boundsSize: boundsSize) let trailing = boundsSize.height - Blocks.fixedInsets.trailing #expect(layout.blockFrames.allSatisfy { $0.maxY.isApproximatelyEqualTo(trailing) }) } @Test func whenHasHorizontalDirection_andTrailingVerticalAlignment_layoutFramesBelowCrossInsets() { let layout = GalleryViewLayout(model: Blocks.horizontalLeadingModel, boundsSize: boundsSize) #expect( layout.blockFrames.allSatisfy { $0.minY.isApproximatelyEqualTo(Blocks.fixedInsets.leading) } ) } @Test func whenHasVerticalDirection_andCenterHorizontalAlignment_layoutFramesAroundInsetedHorizontalCenter( ) { let layout = GalleryViewLayout(model: Blocks.verticalCenterModel, boundsSize: boundsSize) let center = floor(Blocks.fixedInsets.leading + (boundsSize.width - Blocks.fixedInsets.sum) / 2) #expect(layout.blockFrames.allSatisfy { $0.midX.isApproximatelyEqualTo(center) }) } @Test func whenHasVerticalDirection_andLeadingHorizontalAlignment_layoutFramesAtLeadingOfCrossInset() { let layout = GalleryViewLayout(model: Blocks.verticalLeadingModel, boundsSize: boundsSize) #expect( layout.blockFrames.allSatisfy { $0.minX.isApproximatelyEqualTo(Blocks.fixedInsets.leading) } ) } @Test func whenHasVerticalDirection_andTrailingHorizontalAlignment_layoutFramesAtTrailingOfCrossInset() { let layout = GalleryViewLayout(model: Blocks.verticalTrailingModel, boundsSize: boundsSize) let trailing = boundsSize.width - Blocks.fixedInsets.trailing #expect(layout.blockFrames.allSatisfy { $0.maxX.isApproximatelyEqualTo(trailing) }) } @Test func whenHasVerticalDirection_startsHeightAtFirstGapAndFinishesBeforeLastGap() { let layout = GalleryViewLayout(model: Blocks.verticalCenterModel) #expect(layout.blockFrames.first?.minY == Blocks.gapSize) #expect(layout.blockFrames.last?.maxY == Blocks.gapSize + Blocks.framesAxialSize) } @Test func whenHasPagingModel_returnsLastIndexForLastPageOrigin() { let layout = GalleryViewLayout(model: Blocks.fixedPagingModel) let index = layout.blockFrames.count - 1 let offset = layout.blockPages[index].origin let pageIndex = layout.pageIndex(forContentOffset: offset) #expect( pageIndex.isApproximatelyEqualTo( CGFloat(index), withAccuracy: accuracy ) ) } @Test func whenHasPagingModel_returnsZeroIndexForOffsetOutOfRange() { let layout = GalleryViewLayout(model: Blocks.fixedPagingModel) #expect(layout.pageIndex(forContentOffset: -1).isApproximatelyEqualTo( 0, withAccuracy: accuracy )) #expect(layout.pageIndex(forContentOffset: .infinity).isApproximatelyEqualTo( 0, withAccuracy: accuracy )) } @Test func whenHasFixedPagingModel_returnsInitialOffsetForReverseConversion() { let layout = GalleryViewLayout(model: Blocks.fixedPagingModel, boundsSize: boundsSize) let page = layout.blockPages[2] let offset = page.origin + page.size / 2 let pageIndex = layout.pageIndex(forContentOffset: offset) let foundOffset = layout.contentOffset(pageIndex: pageIndex) #expect(offset.isApproximatelyEqualTo(foundOffset, withAccuracy: accuracy)) } @Test func whenHasAutoPagingModel_returnsInitialOffsetForReverseConversion() { let layout = GalleryViewLayout(model: Blocks.autoPagingModel, boundsSize: boundsSize) let page = layout.blockPages[2] let offset = page.origin + page.size / 2 let pageIndex = layout.pageIndex(forContentOffset: offset) let foundOffset = layout.contentOffset(pageIndex: pageIndex) #expect(offset.isApproximatelyEqualTo(foundOffset, withAccuracy: accuracy)) } @Test func whenHasAutoPagingModel_returnsMaxOffsetForLastPage() { let layout = GalleryViewLayout(model: Blocks.autoPagingModel, boundsSize: boundsSize) let offset = layout.contentOffset(pageIndex: CGFloat(layout.blockPages.count - 1)) let maxOffset = layout.contentSize.width - boundsSize.width #expect(maxOffset.isApproximatelyEqualTo(offset, withAccuracy: accuracy)) } @Test func whenHasPagingModel_detectsContainingGreaterOrEqualMinimumAndLowerMaximumWithAccuracy( ) throws { let layout = GalleryViewLayout(model: Blocks.fixedPagingModel) let page = try #require(layout.blockPages.first) let below = page.origin - .leastNonzeroMagnitude let middle = page.origin + page.size / 2 let upper = page.origin + page.size - .leastNonzeroMagnitude #expect(page.contains(below)) #expect(page.contains(middle)) #expect(!page.contains(upper)) } @Test func whenHasPagingModel_containsContinuousPages() { let layout = GalleryViewLayout(model: Blocks.fixedPagingModel) let totalSpace = (1..= pageSize - frame.width else { continue } let viewportTrailingEdge = page.origin + pageSize #expect(frame.maxX.isApproximatelyEqualTo(viewportTrailingEdge, withAccuracy: accuracy)) } } @Test( "Page count equals frame count for each paging mode and scrollAlignment", arguments: pagingScrollModes, scrollAlignments ) func pageCount_equalsFrameCount( scrollMode: GalleryViewModel.ScrollMode, alignment: Alignment ) { let model = Blocks.makeModel(scrollMode: scrollMode, scrollAlignment: alignment) let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) #expect(layout.blockPages.count == layout.blockFrames.count) } @Test( "Roundtrip pageIndex→contentOffset→pageIndex is stable for each paging mode and scrollAlignment", arguments: pagingScrollModes, scrollAlignments ) func roundtrip_pageIndexToContentOffsetToPageIndex_isStable( scrollMode: GalleryViewModel.ScrollMode, alignment: Alignment ) throws { let model = Blocks.makeModel(scrollMode: scrollMode, scrollAlignment: alignment) let layout = GalleryViewLayout(model: model, boundsSize: boundsSize) let page = try #require(layout.blockPages.dropFirst().first) let offset = page.origin + page.size / 2 let pageIndex = layout.pageIndex(forContentOffset: offset) let recoveredOffset = layout.contentOffset(pageIndex: pageIndex) #expect(offset.isApproximatelyEqualTo(recoveredOffset, withAccuracy: accuracy)) } @Test( "For items narrower than the viewport, trailing alignment has the smallest page origin and leading the largest", arguments: pagingScrollModes ) func scrollAlignment_pageOriginsOrdered_forNarrowItems( scrollMode: GalleryViewModel.ScrollMode ) throws { let leadingLayout = GalleryViewLayout( model: Blocks.makeModel(scrollMode: scrollMode, scrollAlignment: .leading), boundsSize: boundsSize ) let centerLayout = GalleryViewLayout( model: Blocks.makeModel(scrollMode: scrollMode, scrollAlignment: .center), boundsSize: boundsSize ) let trailingLayout = GalleryViewLayout( model: Blocks.makeModel(scrollMode: scrollMode, scrollAlignment: .trailing), boundsSize: boundsSize ) // Use the last page: it maps to a 1pt-wide resizable block (narrower than the 64pt viewport), // so the ordering trailing < center < leading holds regardless of scroll mode. let leadingOrigin = try #require(leadingLayout.blockPages.last).origin let centerOrigin = try #require(centerLayout.blockPages.last).origin let trailingOrigin = try #require(trailingLayout.blockPages.last).origin #expect(trailingOrigin < centerOrigin) #expect(centerOrigin < leadingOrigin) } } // MARK: - Test data private let pagingScrollModes: [GalleryViewModel.ScrollMode] = [ .autoPaging(inertionEnabled: true), .fixedPaging(pageSize: boundsSize.width), ] private let scrollAlignments: [Alignment] = [.leading, .center, .trailing] private func pageSizeFor( scrollMode: GalleryViewModel.ScrollMode, boundsWidth: CGFloat ) -> CGFloat { switch scrollMode { case .autoPaging, .default: boundsWidth case let .fixedPaging(pageSize): pageSize } } private enum Blocks { static let smallCount = 2 static let bigCount = 2 static let resizableCount = 2 static let totalCount = bigCount + smallCount + resizableCount static let blocksCount = smallCount + bigCount + resizableCount static let smallSize = CGFloat(64) static let bigSize = CGFloat(128) static let resizableSize = CGFloat(1) static let gapSize = CGFloat(1) static let bigAxialSize = bigSize * CGFloat(bigCount) static let smallAxialSize = smallSize * CGFloat(smallCount) static let resizableAxialSize = resizableSize * CGFloat(resizableCount) static let gapAxialSize = gapSize * CGFloat(blocksCount + 1) static let framesAxialSize = bigAxialSize + smallAxialSize + resizableAxialSize + gapSize * CGFloat(max(blocksCount - 1, 0)) static let contentAxialSize = bigAxialSize + smallAxialSize + resizableAxialSize + gapAxialSize static let contentAxialSizeWithoutSideGaps = contentAxialSize - 2 * gapSize static let contentCrossMaxSize = max(bigSize, smallSize) static let horizontalCenterModel = makeModel() static let horizontalLeadingModel = makeModel(crossAlignment: .leading) static let horizontalTrailingModel = makeModel(crossAlignment: .trailing) static let verticalCenterModel = makeModel(direction: .vertical) static let verticalLeadingModel = makeModel( direction: .vertical, crossAlignment: .leading ) static let verticalTrailingModel = makeModel( direction: .vertical, crossAlignment: .trailing ) static let fixedPagingModel = makeModel(scrollMode: .fixedPaging(pageSize: boundsSize.width)) static let autoPagingModel = makeModel(scrollMode: .autoPaging(inertionEnabled: true)) static let fixedInsets = SideInsets(leading: 4, trailing: 10) static let resizableInsets = InsetMode.Resizable(minValue: 10, maxViewportSize: 14) static let horizontalResizableModel = makeModel(metrics: .resizableAxial) static let verticalResizableModel = makeModel( direction: .vertical, metrics: .resizableAxial ) private static let horizontallyResizable: Block = SeparatorBlock(direction: .horizontal) private static let verticallyResizable: Block = SeparatorBlock(direction: .vertical) private static let small: Block = try! ContainerBlock( layoutDirection: .horizontal, widthTrait: .fixed(smallSize), heightTrait: .fixed(smallSize), children: [verticallyResizable] ) private static let big: Block = try! ContainerBlock( layoutDirection: .horizontal, widthTrait: .fixed(bigSize), heightTrait: .fixed(bigSize), children: [verticallyResizable] ) static func makeModel( direction: ScrollDirection = .horizontal, metrics: GalleryViewMetrics = .fixed, crossAlignment: Alignment = .center, scrollMode: GalleryViewModel.ScrollMode = .default, scrollAlignment: Alignment? = nil, layoutDirection: UserInterfaceLayoutDirection = .leftToRight ) -> GalleryViewModel { let resizableBlock = direction.isHorizontal ? verticallyResizable : horizontallyResizable let blocks = [Block](repeating: big, times: try! UInt(value: bigCount)) + [Block](repeating: small, times: try! UInt(value: smallCount)) + [Block](repeating: resizableBlock, times: try! UInt(value: resizableCount)) let items = blocks.map { GalleryViewModel.Item(crossAlignment: crossAlignment, content: $0) } return GalleryViewModel( items: items, layoutDirection: layoutDirection, metrics: metrics, scrollMode: scrollMode, path: UIElementPath("model"), direction: direction, scrollAlignment: scrollAlignment ) } } private let boundsSize = CGSize(width: 64, height: 64) private let accuracy: CGFloat = 1e-4 extension GalleryViewMetrics { fileprivate static let spacings = [CGFloat]( repeating: Blocks.gapSize, times: try! UInt(value: Blocks.totalCount - 1) ) fileprivate static let resizableAxial = GalleryViewMetrics( axialInsetMode: .resizable(params: Blocks.resizableInsets), crossInsetMode: .fixed(values: Blocks.fixedInsets), spacings: spacings, crossSpacing: 0 ) fileprivate static let fixed = GalleryViewMetrics( axialInsetMode: .fixed(values: SideInsets(leading: Blocks.gapSize, trailing: Blocks.gapSize)), crossInsetMode: .fixed(values: Blocks.fixedInsets), spacings: spacings, crossSpacing: 0 ) fileprivate static let resizableCross = GalleryViewMetrics( axialInsetMode: .fixed(values: Blocks.fixedInsets), crossInsetMode: .resizable(params: Blocks.resizableInsets), spacings: spacings, crossSpacing: 0 ) }