22// FillColumnTests.swift
33// TableProTests
44//
5- // Locks the Fill Column decision logic: which loaded rows a fill targets
6- // (editable guard, deleted-row skipping, empty result) and how the dialog
7- // resolves NULL distinctly from an empty string. The apply loop itself
8- // reuses commitTypedCellEdit, which the cell-edit and paste paths exercise .
5+ // Locks Fill Column behaviour at two levels: the pure decisions (which loaded
6+ // rows a fill targets, how the dialog resolves NULL vs an empty string) and
7+ // the effect (applyFillColumn records one staged change per loaded row through
8+ // DataChangeManager, skipping deleted rows and read-only result sets) .
99//
1010
1111import Foundation
@@ -14,11 +14,45 @@ import Testing
1414
1515@testable import TablePro
1616
17+ @MainActor
18+ private final class NoopColumnLayoutPersister : ColumnLayoutPersisting {
19+ func load( for tableName: String , connectionId: UUID ) -> ColumnLayoutState ? { nil }
20+ func save( _ layout: ColumnLayoutState , for tableName: String , connectionId: UUID ) { }
21+ func clear( for tableName: String , connectionId: UUID ) { }
22+ }
23+
1724@Suite ( " Fill Column " )
1825@MainActor
1926struct FillColumnTests {
20- @Test ( " Fills every loaded row when editable and none are deleted " )
21- func fillsAllLoadedRows( ) {
27+ private func makeCoordinator(
28+ columns: [ String ] ,
29+ rowCount: Int ,
30+ isEditable: Bool = true ,
31+ manager: DataChangeManager
32+ ) -> TableViewCoordinator {
33+ let coordinator = TableViewCoordinator (
34+ changeManager: AnyChangeManager ( manager) ,
35+ isEditable: isEditable,
36+ selectedRowIndices: . constant( [ ] ) ,
37+ delegate: nil ,
38+ layoutPersister: NoopColumnLayoutPersister ( )
39+ )
40+ let columnTypes : [ ColumnType ] = Array ( repeating: . text( rawType: nil ) , count: columns. count)
41+ let rows = ( 0 ..< rowCount) . map { i in ( 0 ..< columns. count) . map { c in " r \( i) c \( c) " } }
42+ let tableRows = TableRows . from (
43+ queryRows: rows. map { row in row. map { PluginCellValue . text ( $0) } } ,
44+ columns: columns,
45+ columnTypes: columnTypes
46+ )
47+ coordinator. tableRowsProvider = { tableRows }
48+ coordinator. updateCache ( )
49+ return coordinator
50+ }
51+
52+ // MARK: - Target selection
53+
54+ @Test ( " Targets every loaded row when editable and none are deleted " )
55+ func targetsAllLoadedRows( ) {
2256 let rows = TableViewCoordinator . fillTargetRows (
2357 rowCount: 5 ,
2458 isEditable: true ,
@@ -27,8 +61,8 @@ struct FillColumnTests {
2761 #expect( rows == [ 0 , 1 , 2 , 3 , 4 ] )
2862 }
2963
30- @Test ( " Skips rows marked for deletion" )
31- func skipsDeletedRows ( ) {
64+ @Test ( " Excludes rows marked for deletion" )
65+ func excludesDeletedRows ( ) {
3266 let rows = TableViewCoordinator . fillTargetRows (
3367 rowCount: 5 ,
3468 isEditable: true ,
@@ -57,6 +91,8 @@ struct FillColumnTests {
5791 #expect( rows. isEmpty)
5892 }
5993
94+ // MARK: - Value resolution
95+
6096 @Test ( " Resolves NULL distinctly from an empty string " )
6197 func resolvesNullDistinctFromEmpty( ) {
6298 #expect( TableViewCoordinator . fillColumnValue ( text: " ignored " , setNull: true ) == . null)
@@ -69,4 +105,63 @@ struct FillColumnTests {
69105 #expect( TableViewCoordinator . fillImpactDescription ( rowCount: 1 ) . contains ( " 1 loaded row " ) )
70106 #expect( TableViewCoordinator . fillImpactDescription ( rowCount: 42 ) . contains ( " 42 loaded rows " ) )
71107 }
108+
109+ // MARK: - Apply (staged effect)
110+
111+ @Test ( " Records one staged change per loaded row, leaving other columns untouched " )
112+ func appliesToEveryLoadedRow( ) {
113+ let manager = DataChangeManager ( )
114+ let coordinator = makeCoordinator ( columns: [ " a " , " b " ] , rowCount: 4 , manager: manager)
115+
116+ coordinator. applyFillColumn ( columnIndex: 0 , value: . text( " X " ) )
117+
118+ for row in 0 ..< 4 {
119+ #expect( manager. pending. isCellModified ( rowIndex: row, columnIndex: 0 ) )
120+ }
121+ #expect( manager. pending. isCellModified ( rowIndex: 0 , columnIndex: 1 ) == false )
122+ }
123+
124+ @Test ( " Does not touch rows marked for deletion " )
125+ func skipsDeletedRowsOnApply( ) {
126+ let manager = DataChangeManager ( )
127+ let coordinator = makeCoordinator ( columns: [ " a " ] , rowCount: 4 , manager: manager)
128+ manager. recordRowDeletion ( rowIndex: 2 , originalRow: [ . text( " r2c0 " ) ] )
129+
130+ coordinator. applyFillColumn ( columnIndex: 0 , value: . text( " X " ) )
131+
132+ #expect( manager. pending. isCellModified ( rowIndex: 0 , columnIndex: 0 ) )
133+ #expect( manager. pending. isCellModified ( rowIndex: 2 , columnIndex: 0 ) == false )
134+ }
135+
136+ @Test ( " Records nothing on a read-only result set " )
137+ func recordsNothingWhenReadOnly( ) {
138+ let manager = DataChangeManager ( )
139+ let coordinator = makeCoordinator ( columns: [ " a " ] , rowCount: 4 , isEditable: false , manager: manager)
140+
141+ coordinator. applyFillColumn ( columnIndex: 0 , value: . text( " X " ) )
142+
143+ #expect( manager. hasChanges == false )
144+ }
145+
146+ @Test ( " Writes NULL as a null change, not an empty string " )
147+ func appliesNull( ) {
148+ let manager = DataChangeManager ( )
149+ let coordinator = makeCoordinator ( columns: [ " a " ] , rowCount: 2 , manager: manager)
150+
151+ coordinator. applyFillColumn ( columnIndex: 0 , value: . null)
152+
153+ let change = manager. pending. change ( forRow: 0 , type: . update)
154+ #expect( change? . cellChanges. first? . newValue == . null)
155+ }
156+
157+ @Test ( " Skips rows whose value already equals the fill value " )
158+ func skipsRowsAlreadyEqual( ) {
159+ let manager = DataChangeManager ( )
160+ let coordinator = makeCoordinator ( columns: [ " a " ] , rowCount: 2 , manager: manager)
161+
162+ coordinator. applyFillColumn ( columnIndex: 0 , value: . text( " r0c0 " ) )
163+
164+ #expect( manager. pending. isCellModified ( rowIndex: 0 , columnIndex: 0 ) == false )
165+ #expect( manager. pending. isCellModified ( rowIndex: 1 , columnIndex: 0 ) )
166+ }
72167}
0 commit comments