feat(docmodel): add block kind motions
- commit
- d59606683551b3b692c22477d1fd71732384afeb
- author
- brice · · 2026-08-14 04:28 UTC
- committer
- brice · · 2026-08-14 07:01 UTC
- parent
- be6b7ea
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +306 -0
A internal/domain/docmodel/block_motions_test.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | package docmodel | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestStrictNextBlockOfKind(t *testing.T) { | |
| 9 | + | doc := blockKindMotionDocument(t) | |
| 10 | + | ||
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | from Cursor | |
| 14 | + | kind BlockKind | |
| 15 | + | count int | |
| 16 | + | want Cursor | |
| 17 | + | ok bool | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "next code block", | |
| 21 | + | from: Cursor{BlockID: "p0"}, | |
| 22 | + | kind: BlockKindCode, | |
| 23 | + | want: Cursor{BlockID: "code1"}, | |
| 24 | + | ok: true, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "next table block", | |
| 28 | + | from: Cursor{BlockID: "p0"}, | |
| 29 | + | kind: BlockKindTable, | |
| 30 | + | want: Cursor{BlockID: "table1"}, | |
| 31 | + | ok: true, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "next image block", | |
| 35 | + | from: Cursor{BlockID: "p0"}, | |
| 36 | + | kind: BlockKindImage, | |
| 37 | + | want: Cursor{BlockID: "image1"}, | |
| 38 | + | ok: true, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "count below one means one", | |
| 42 | + | from: Cursor{BlockID: "p1"}, | |
| 43 | + | kind: BlockKindTable, | |
| 44 | + | count: -2, | |
| 45 | + | want: Cursor{BlockID: "table1"}, | |
| 46 | + | ok: true, | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "strict next does not match current block", | |
| 50 | + | from: Cursor{BlockID: "code1"}, | |
| 51 | + | kind: BlockKindCode, | |
| 52 | + | count: 1, | |
| 53 | + | want: Cursor{BlockID: "code2"}, | |
| 54 | + | ok: true, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "count selects nth next block of kind", | |
| 58 | + | from: Cursor{BlockID: "p0"}, | |
| 59 | + | kind: BlockKindCode, | |
| 60 | + | count: 2, | |
| 61 | + | want: Cursor{BlockID: "code2"}, | |
| 62 | + | ok: true, | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "no later matching kind returns false", | |
| 66 | + | from: Cursor{BlockID: "code2"}, | |
| 67 | + | kind: BlockKindCode, | |
| 68 | + | }, | |
| 69 | + | { | |
| 70 | + | name: "count past available matches returns false", | |
| 71 | + | from: Cursor{BlockID: "p0"}, | |
| 72 | + | kind: BlockKindImage, | |
| 73 | + | count: 3, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "zero cursor returns false", | |
| 77 | + | from: Cursor{}, | |
| 78 | + | kind: BlockKindCode, | |
| 79 | + | }, | |
| 80 | + | { | |
| 81 | + | name: "missing cursor returns false", | |
| 82 | + | from: Cursor{BlockID: "missing"}, | |
| 83 | + | kind: BlockKindCode, | |
| 84 | + | }, | |
| 85 | + | { | |
| 86 | + | name: "empty kind returns false", | |
| 87 | + | from: Cursor{BlockID: "p0"}, | |
| 88 | + | kind: "", | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | for _, tt := range tests { | |
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |
| 94 | + | before := doc.Blocks() | |
| 95 | + | got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count) | |
| 96 | + | if ok != tt.ok || got != tt.want { | |
| 97 | + | t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |
| 98 | + | } | |
| 99 | + | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 100 | + | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 101 | + | } | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestStrictPreviousBlockOfKind(t *testing.T) { | |
| 107 | + | doc := blockKindMotionDocument(t) | |
| 108 | + | ||
| 109 | + | tests := []struct { | |
| 110 | + | name string | |
| 111 | + | from Cursor | |
| 112 | + | kind BlockKind | |
| 113 | + | count int | |
| 114 | + | want Cursor | |
| 115 | + | ok bool | |
| 116 | + | }{ | |
| 117 | + | { | |
| 118 | + | name: "previous code block", | |
| 119 | + | from: Cursor{BlockID: "p2"}, | |
| 120 | + | kind: BlockKindCode, | |
| 121 | + | want: Cursor{BlockID: "code2"}, | |
| 122 | + | ok: true, | |
| 123 | + | }, | |
| 124 | + | { | |
| 125 | + | name: "previous table block", | |
| 126 | + | from: Cursor{BlockID: "p2"}, | |
| 127 | + | kind: BlockKindTable, | |
| 128 | + | want: Cursor{BlockID: "table2"}, | |
| 129 | + | ok: true, | |
| 130 | + | }, | |
| 131 | + | { | |
| 132 | + | name: "previous image block", | |
| 133 | + | from: Cursor{BlockID: "p2"}, | |
| 134 | + | kind: BlockKindImage, | |
| 135 | + | want: Cursor{BlockID: "image2"}, | |
| 136 | + | ok: true, | |
| 137 | + | }, | |
| 138 | + | { | |
| 139 | + | name: "count below one means one", | |
| 140 | + | from: Cursor{BlockID: "p2"}, | |
| 141 | + | kind: BlockKindTable, | |
| 142 | + | count: 0, | |
| 143 | + | want: Cursor{BlockID: "table2"}, | |
| 144 | + | ok: true, | |
| 145 | + | }, | |
| 146 | + | { | |
| 147 | + | name: "strict previous does not match current block", | |
| 148 | + | from: Cursor{BlockID: "image2"}, | |
| 149 | + | kind: BlockKindImage, | |
| 150 | + | count: 1, | |
| 151 | + | want: Cursor{BlockID: "image1"}, | |
| 152 | + | ok: true, | |
| 153 | + | }, | |
| 154 | + | { | |
| 155 | + | name: "count selects nth previous block of kind", | |
| 156 | + | from: Cursor{BlockID: "p2"}, | |
| 157 | + | kind: BlockKindTable, | |
| 158 | + | count: 2, | |
| 159 | + | want: Cursor{BlockID: "table1"}, | |
| 160 | + | ok: true, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | name: "no earlier matching kind returns false", | |
| 164 | + | from: Cursor{BlockID: "code1"}, | |
| 165 | + | kind: BlockKindCode, | |
| 166 | + | }, | |
| 167 | + | { | |
| 168 | + | name: "count before available matches returns false", | |
| 169 | + | from: Cursor{BlockID: "p2"}, | |
| 170 | + | kind: BlockKindImage, | |
| 171 | + | count: 3, | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | name: "zero cursor returns false", | |
| 175 | + | from: Cursor{}, | |
| 176 | + | kind: BlockKindCode, | |
| 177 | + | }, | |
| 178 | + | { | |
| 179 | + | name: "missing cursor returns false", | |
| 180 | + | from: Cursor{BlockID: "missing"}, | |
| 181 | + | kind: BlockKindCode, | |
| 182 | + | }, | |
| 183 | + | { | |
| 184 | + | name: "empty kind returns false", | |
| 185 | + | from: Cursor{BlockID: "p2"}, | |
| 186 | + | kind: "", | |
| 187 | + | }, | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | for _, tt := range tests { | |
| 191 | + | t.Run(tt.name, func(t *testing.T) { | |
| 192 | + | before := doc.Blocks() | |
| 193 | + | got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count) | |
| 194 | + | if ok != tt.ok || got != tt.want { | |
| 195 | + | t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |
| 196 | + | } | |
| 197 | + | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |
| 198 | + | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |
| 199 | + | } | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) { | |
| 205 | + | tests := []struct { | |
| 206 | + | name string | |
| 207 | + | blocks []Block | |
| 208 | + | from Cursor | |
| 209 | + | kind BlockKind | |
| 210 | + | }{ | |
| 211 | + | { | |
| 212 | + | name: "empty document", | |
| 213 | + | from: Cursor{BlockID: "missing"}, | |
| 214 | + | kind: BlockKindCode, | |
| 215 | + | }, | |
| 216 | + | { | |
| 217 | + | name: "document without requested structural kind", | |
| 218 | + | blocks: []Block{ | |
| 219 | + | {ID: "p0", Kind: BlockKind("paragraph")}, | |
| 220 | + | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |
| 221 | + | {ID: "p1", Kind: BlockKind("list_item")}, | |
| 222 | + | }, | |
| 223 | + | from: Cursor{BlockID: "p0"}, | |
| 224 | + | kind: BlockKindImage, | |
| 225 | + | }, | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | for _, tt := range tests { | |
| 229 | + | t.Run(tt.name, func(t *testing.T) { | |
| 230 | + | doc := mustDocument(t, tt.blocks) | |
| 231 | + | ||
| 232 | + | if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |
| 233 | + | t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |
| 234 | + | } | |
| 235 | + | if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |
| 236 | + | t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |
| 237 | + | } | |
| 238 | + | }) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func blockKindMotionDocument(t *testing.T) Document { | |
| 243 | + | t.Helper() | |
| 244 | + | ||
| 245 | + | return mustDocument(t, []Block{ | |
| 246 | + | {ID: "p0", Kind: BlockKind("paragraph")}, | |
| 247 | + | {ID: "code1", Kind: BlockKindCode}, | |
| 248 | + | {ID: "p1", Kind: BlockKind("paragraph")}, | |
| 249 | + | {ID: "table1", Kind: BlockKindTable}, | |
| 250 | + | {ID: "image1", Kind: BlockKindImage}, | |
| 251 | + | {ID: "code2", Kind: BlockKindCode}, | |
| 252 | + | {ID: "table2", Kind: BlockKindTable}, | |
| 253 | + | {ID: "image2", Kind: BlockKindImage}, | |
| 254 | + | {ID: "p2", Kind: BlockKind("paragraph")}, | |
| 255 | + | }) | |
| 256 | + | } | |
| @@ -0,0 +1,256 @@ | |||||
| 1 | + | package docmodel | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestStrictNextBlockOfKind(t *testing.T) { | |||
| 9 | + | doc := blockKindMotionDocument(t) | |||
| 10 | + | ||||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | from Cursor | |||
| 14 | + | kind BlockKind | |||
| 15 | + | count int | |||
| 16 | + | want Cursor | |||
| 17 | + | ok bool | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "next code block", | |||
| 21 | + | from: Cursor{BlockID: "p0"}, | |||
| 22 | + | kind: BlockKindCode, | |||
| 23 | + | want: Cursor{BlockID: "code1"}, | |||
| 24 | + | ok: true, | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "next table block", | |||
| 28 | + | from: Cursor{BlockID: "p0"}, | |||
| 29 | + | kind: BlockKindTable, | |||
| 30 | + | want: Cursor{BlockID: "table1"}, | |||
| 31 | + | ok: true, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "next image block", | |||
| 35 | + | from: Cursor{BlockID: "p0"}, | |||
| 36 | + | kind: BlockKindImage, | |||
| 37 | + | want: Cursor{BlockID: "image1"}, | |||
| 38 | + | ok: true, | |||
| 39 | + | }, | |||
| 40 | + | { | |||
| 41 | + | name: "count below one means one", | |||
| 42 | + | from: Cursor{BlockID: "p1"}, | |||
| 43 | + | kind: BlockKindTable, | |||
| 44 | + | count: -2, | |||
| 45 | + | want: Cursor{BlockID: "table1"}, | |||
| 46 | + | ok: true, | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | name: "strict next does not match current block", | |||
| 50 | + | from: Cursor{BlockID: "code1"}, | |||
| 51 | + | kind: BlockKindCode, | |||
| 52 | + | count: 1, | |||
| 53 | + | want: Cursor{BlockID: "code2"}, | |||
| 54 | + | ok: true, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "count selects nth next block of kind", | |||
| 58 | + | from: Cursor{BlockID: "p0"}, | |||
| 59 | + | kind: BlockKindCode, | |||
| 60 | + | count: 2, | |||
| 61 | + | want: Cursor{BlockID: "code2"}, | |||
| 62 | + | ok: true, | |||
| 63 | + | }, | |||
| 64 | + | { | |||
| 65 | + | name: "no later matching kind returns false", | |||
| 66 | + | from: Cursor{BlockID: "code2"}, | |||
| 67 | + | kind: BlockKindCode, | |||
| 68 | + | }, | |||
| 69 | + | { | |||
| 70 | + | name: "count past available matches returns false", | |||
| 71 | + | from: Cursor{BlockID: "p0"}, | |||
| 72 | + | kind: BlockKindImage, | |||
| 73 | + | count: 3, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "zero cursor returns false", | |||
| 77 | + | from: Cursor{}, | |||
| 78 | + | kind: BlockKindCode, | |||
| 79 | + | }, | |||
| 80 | + | { | |||
| 81 | + | name: "missing cursor returns false", | |||
| 82 | + | from: Cursor{BlockID: "missing"}, | |||
| 83 | + | kind: BlockKindCode, | |||
| 84 | + | }, | |||
| 85 | + | { | |||
| 86 | + | name: "empty kind returns false", | |||
| 87 | + | from: Cursor{BlockID: "p0"}, | |||
| 88 | + | kind: "", | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | for _, tt := range tests { | |||
| 93 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 94 | + | before := doc.Blocks() | |||
| 95 | + | got, ok := doc.NextBlockOfKind(tt.from, tt.kind, tt.count) | |||
| 96 | + | if ok != tt.ok || got != tt.want { | |||
| 97 | + | t.Fatalf("NextBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |||
| 98 | + | } | |||
| 99 | + | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 100 | + | t.Fatalf("NextBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 101 | + | } | |||
| 102 | + | }) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestStrictPreviousBlockOfKind(t *testing.T) { | |||
| 107 | + | doc := blockKindMotionDocument(t) | |||
| 108 | + | ||||
| 109 | + | tests := []struct { | |||
| 110 | + | name string | |||
| 111 | + | from Cursor | |||
| 112 | + | kind BlockKind | |||
| 113 | + | count int | |||
| 114 | + | want Cursor | |||
| 115 | + | ok bool | |||
| 116 | + | }{ | |||
| 117 | + | { | |||
| 118 | + | name: "previous code block", | |||
| 119 | + | from: Cursor{BlockID: "p2"}, | |||
| 120 | + | kind: BlockKindCode, | |||
| 121 | + | want: Cursor{BlockID: "code2"}, | |||
| 122 | + | ok: true, | |||
| 123 | + | }, | |||
| 124 | + | { | |||
| 125 | + | name: "previous table block", | |||
| 126 | + | from: Cursor{BlockID: "p2"}, | |||
| 127 | + | kind: BlockKindTable, | |||
| 128 | + | want: Cursor{BlockID: "table2"}, | |||
| 129 | + | ok: true, | |||
| 130 | + | }, | |||
| 131 | + | { | |||
| 132 | + | name: "previous image block", | |||
| 133 | + | from: Cursor{BlockID: "p2"}, | |||
| 134 | + | kind: BlockKindImage, | |||
| 135 | + | want: Cursor{BlockID: "image2"}, | |||
| 136 | + | ok: true, | |||
| 137 | + | }, | |||
| 138 | + | { | |||
| 139 | + | name: "count below one means one", | |||
| 140 | + | from: Cursor{BlockID: "p2"}, | |||
| 141 | + | kind: BlockKindTable, | |||
| 142 | + | count: 0, | |||
| 143 | + | want: Cursor{BlockID: "table2"}, | |||
| 144 | + | ok: true, | |||
| 145 | + | }, | |||
| 146 | + | { | |||
| 147 | + | name: "strict previous does not match current block", | |||
| 148 | + | from: Cursor{BlockID: "image2"}, | |||
| 149 | + | kind: BlockKindImage, | |||
| 150 | + | count: 1, | |||
| 151 | + | want: Cursor{BlockID: "image1"}, | |||
| 152 | + | ok: true, | |||
| 153 | + | }, | |||
| 154 | + | { | |||
| 155 | + | name: "count selects nth previous block of kind", | |||
| 156 | + | from: Cursor{BlockID: "p2"}, | |||
| 157 | + | kind: BlockKindTable, | |||
| 158 | + | count: 2, | |||
| 159 | + | want: Cursor{BlockID: "table1"}, | |||
| 160 | + | ok: true, | |||
| 161 | + | }, | |||
| 162 | + | { | |||
| 163 | + | name: "no earlier matching kind returns false", | |||
| 164 | + | from: Cursor{BlockID: "code1"}, | |||
| 165 | + | kind: BlockKindCode, | |||
| 166 | + | }, | |||
| 167 | + | { | |||
| 168 | + | name: "count before available matches returns false", | |||
| 169 | + | from: Cursor{BlockID: "p2"}, | |||
| 170 | + | kind: BlockKindImage, | |||
| 171 | + | count: 3, | |||
| 172 | + | }, | |||
| 173 | + | { | |||
| 174 | + | name: "zero cursor returns false", | |||
| 175 | + | from: Cursor{}, | |||
| 176 | + | kind: BlockKindCode, | |||
| 177 | + | }, | |||
| 178 | + | { | |||
| 179 | + | name: "missing cursor returns false", | |||
| 180 | + | from: Cursor{BlockID: "missing"}, | |||
| 181 | + | kind: BlockKindCode, | |||
| 182 | + | }, | |||
| 183 | + | { | |||
| 184 | + | name: "empty kind returns false", | |||
| 185 | + | from: Cursor{BlockID: "p2"}, | |||
| 186 | + | kind: "", | |||
| 187 | + | }, | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | for _, tt := range tests { | |||
| 191 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 192 | + | before := doc.Blocks() | |||
| 193 | + | got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, tt.count) | |||
| 194 | + | if ok != tt.ok || got != tt.want { | |||
| 195 | + | t.Fatalf("PreviousBlockOfKind(%#v, %q, %d) = (%#v, %v), want (%#v, %v)", tt.from, tt.kind, tt.count, got, ok, tt.want, tt.ok) | |||
| 196 | + | } | |||
| 197 | + | if after := doc.Blocks(); !reflect.DeepEqual(after, before) { | |||
| 198 | + | t.Fatalf("PreviousBlockOfKind mutated document: before %#v after %#v", before, after) | |||
| 199 | + | } | |||
| 200 | + | }) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestBlockOfKindMotionsOnEmptyAndUnmatchedDocuments(t *testing.T) { | |||
| 205 | + | tests := []struct { | |||
| 206 | + | name string | |||
| 207 | + | blocks []Block | |||
| 208 | + | from Cursor | |||
| 209 | + | kind BlockKind | |||
| 210 | + | }{ | |||
| 211 | + | { | |||
| 212 | + | name: "empty document", | |||
| 213 | + | from: Cursor{BlockID: "missing"}, | |||
| 214 | + | kind: BlockKindCode, | |||
| 215 | + | }, | |||
| 216 | + | { | |||
| 217 | + | name: "document without requested structural kind", | |||
| 218 | + | blocks: []Block{ | |||
| 219 | + | {ID: "p0", Kind: BlockKind("paragraph")}, | |||
| 220 | + | {ID: "h1", Kind: BlockKindHeading, HeadingLevel: 1}, | |||
| 221 | + | {ID: "p1", Kind: BlockKind("list_item")}, | |||
| 222 | + | }, | |||
| 223 | + | from: Cursor{BlockID: "p0"}, | |||
| 224 | + | kind: BlockKindImage, | |||
| 225 | + | }, | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | for _, tt := range tests { | |||
| 229 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 230 | + | doc := mustDocument(t, tt.blocks) | |||
| 231 | + | ||||
| 232 | + | if got, ok := doc.NextBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |||
| 233 | + | t.Fatalf("NextBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 234 | + | } | |||
| 235 | + | if got, ok := doc.PreviousBlockOfKind(tt.from, tt.kind, 1); ok || got != (Cursor{}) { | |||
| 236 | + | t.Fatalf("PreviousBlockOfKind() = (%#v, %v), want zero cursor and false", got, ok) | |||
| 237 | + | } | |||
| 238 | + | }) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func blockKindMotionDocument(t *testing.T) Document { | |||
| 243 | + | t.Helper() | |||
| 244 | + | ||||
| 245 | + | return mustDocument(t, []Block{ | |||
| 246 | + | {ID: "p0", Kind: BlockKind("paragraph")}, | |||
| 247 | + | {ID: "code1", Kind: BlockKindCode}, | |||
| 248 | + | {ID: "p1", Kind: BlockKind("paragraph")}, | |||
| 249 | + | {ID: "table1", Kind: BlockKindTable}, | |||
| 250 | + | {ID: "image1", Kind: BlockKindImage}, | |||
| 251 | + | {ID: "code2", Kind: BlockKindCode}, | |||
| 252 | + | {ID: "table2", Kind: BlockKindTable}, | |||
| 253 | + | {ID: "image2", Kind: BlockKindImage}, | |||
| 254 | + | {ID: "p2", Kind: BlockKind("paragraph")}, | |||
| 255 | + | }) | |||
| 256 | + | } | |||
M internal/domain/docmodel/docmodel.go +6 -0
| @@ -15,6 +15,12 @@ type BlockKind string | |||
| 15 | 15 | const ( | |
| 16 | 16 | // BlockKindHeading marks blocks that participate in heading motions. | |
| 17 | 17 | BlockKindHeading BlockKind = "heading" | |
| 18 | + | // BlockKindCode marks a structural code block for Vim Mode block-kind motions. | |
| 19 | + | BlockKindCode BlockKind = "code" | |
| 20 | + | // BlockKindTable marks a structural table block for Vim Mode block-kind motions. | |
| 21 | + | BlockKindTable BlockKind = "table" | |
| 22 | + | // BlockKindImage marks a structural image block for Vim Mode block-kind motions. | |
| 23 | + | BlockKindImage BlockKind = "image" | |
| 18 | 24 | ||
| 19 | 25 | // MinHeadingLevel is the lowest valid heading level. | |
| 20 | 26 | MinHeadingLevel = 1 | |
| @@ -15,6 +15,12 @@ type BlockKind string | |||||
| 15 | const ( | 15 | const ( | ||
| 16 | // BlockKindHeading marks blocks that participate in heading motions. | 16 | // BlockKindHeading marks blocks that participate in heading motions. | ||
| 17 | BlockKindHeading BlockKind = "heading" | 17 | BlockKindHeading BlockKind = "heading" | ||
| 18 | + | // BlockKindCode marks a structural code block for Vim Mode block-kind motions. | |||
| 19 | + | BlockKindCode BlockKind = "code" | |||
| 20 | + | // BlockKindTable marks a structural table block for Vim Mode block-kind motions. | |||
| 21 | + | BlockKindTable BlockKind = "table" | |||
| 22 | + | // BlockKindImage marks a structural image block for Vim Mode block-kind motions. | |||
| 23 | + | BlockKindImage BlockKind = "image" | |||
| 18 | 24 | ||||
| 19 | // MinHeadingLevel is the lowest valid heading level. | 25 | // MinHeadingLevel is the lowest valid heading level. | ||
| 20 | MinHeadingLevel = 1 | 26 | MinHeadingLevel = 1 | ||
M internal/domain/docmodel/motions.go +44 -0
| @@ -16,6 +16,20 @@ func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor | |||
| 16 | 16 | return d.headingMotion(from, count, -1, levels) | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | // NextBlockOfKind returns the cursor for the count-th block of kind after from. | |
| 20 | + | // Motions are strict: a block at from is not considered a match. A count less | |
| 21 | + | // than or equal to zero is treated as one. Empty kinds never match. | |
| 22 | + | func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |
| 23 | + | return d.blockKindMotion(from, kind, count, 1) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // PreviousBlockOfKind returns the cursor for the count-th block of kind before | |
| 27 | + | // from. Motions are strict: a block at from is not considered a match. A count | |
| 28 | + | // less than or equal to zero is treated as one. Empty kinds never match. | |
| 29 | + | func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |
| 30 | + | return d.blockKindMotion(from, kind, count, -1) | |
| 31 | + | } | |
| 32 | + | ||
| 19 | 33 | func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) { | |
| 20 | 34 | start, ok := d.indexByID[from.BlockID] | |
| 21 | 35 | if !ok { | |
| @@ -42,6 +56,36 @@ func (d Document) headingMotion(from Cursor, count, direction int, levels []int) | |||
| 42 | 56 | return Cursor{}, false | |
| 43 | 57 | } | |
| 44 | 58 | ||
| 59 | + | func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) { | |
| 60 | + | if kind == "" { | |
| 61 | + | return Cursor{}, false | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | start, ok := d.indexByID[from.BlockID] | |
| 65 | + | if !ok { | |
| 66 | + | return Cursor{}, false | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | if count <= 0 { | |
| 70 | + | count = 1 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | remaining := count | |
| 74 | + | for i := start + direction; i >= 0 && i < len(d.blocks); i += direction { | |
| 75 | + | block := d.blocks[i] | |
| 76 | + | if block.Kind != kind { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | remaining-- | |
| 81 | + | if remaining == 0 { | |
| 82 | + | return Cursor{BlockID: block.ID}, true | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | return Cursor{}, false | |
| 87 | + | } | |
| 88 | + | ||
| 45 | 89 | func headingLevelMatches(level int, levels []int) bool { | |
| 46 | 90 | if len(levels) == 0 { | |
| 47 | 91 | return true | |
| @@ -16,6 +16,20 @@ func (d Document) PreviousHeading(from Cursor, count int, levels ...int) (Cursor | |||||
| 16 | return d.headingMotion(from, count, -1, levels) | 16 | return d.headingMotion(from, count, -1, levels) | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | + | // NextBlockOfKind returns the cursor for the count-th block of kind after from. | |||
| 20 | + | // Motions are strict: a block at from is not considered a match. A count less | |||
| 21 | + | // than or equal to zero is treated as one. Empty kinds never match. | |||
| 22 | + | func (d Document) NextBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |||
| 23 | + | return d.blockKindMotion(from, kind, count, 1) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // PreviousBlockOfKind returns the cursor for the count-th block of kind before | |||
| 27 | + | // from. Motions are strict: a block at from is not considered a match. A count | |||
| 28 | + | // less than or equal to zero is treated as one. Empty kinds never match. | |||
| 29 | + | func (d Document) PreviousBlockOfKind(from Cursor, kind BlockKind, count int) (Cursor, bool) { | |||
| 30 | + | return d.blockKindMotion(from, kind, count, -1) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 19 | func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) { | 33 | func (d Document) headingMotion(from Cursor, count, direction int, levels []int) (Cursor, bool) { | ||
| 20 | start, ok := d.indexByID[from.BlockID] | 34 | start, ok := d.indexByID[from.BlockID] | ||
| 21 | if !ok { | 35 | if !ok { | ||
| @@ -42,6 +56,36 @@ func (d Document) headingMotion(from Cursor, count, direction int, levels []int) | |||||
| 42 | return Cursor{}, false | 56 | return Cursor{}, false | ||
| 43 | } | 57 | } | ||
| 44 | 58 | ||||
| 59 | + | func (d Document) blockKindMotion(from Cursor, kind BlockKind, count, direction int) (Cursor, bool) { | |||
| 60 | + | if kind == "" { | |||
| 61 | + | return Cursor{}, false | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | start, ok := d.indexByID[from.BlockID] | |||
| 65 | + | if !ok { | |||
| 66 | + | return Cursor{}, false | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | if count <= 0 { | |||
| 70 | + | count = 1 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | remaining := count | |||
| 74 | + | for i := start + direction; i >= 0 && i < len(d.blocks); i += direction { | |||
| 75 | + | block := d.blocks[i] | |||
| 76 | + | if block.Kind != kind { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | remaining-- | |||
| 81 | + | if remaining == 0 { | |||
| 82 | + | return Cursor{BlockID: block.ID}, true | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | return Cursor{}, false | |||
| 87 | + | } | |||
| 88 | + | ||||
| 45 | func headingLevelMatches(level int, levels []int) bool { | 89 | func headingLevelMatches(level int, levels []int) bool { | ||
| 46 | if len(levels) == 0 { | 90 | if len(levels) == 0 { | ||
| 47 | return true | 91 | return true | ||