| 1 |
import { |
| 2 |
GridRow, |
| 3 |
buildOccupancy, |
| 4 |
cellColumn, |
| 5 |
planDeleteColumn, |
| 6 |
planDeleteRow, |
| 7 |
planInsertColumn, |
| 8 |
planInsertRow, |
| 9 |
planMerge, |
| 10 |
planSplit, |
| 11 |
} from "./grid-model"; |
| 12 |
|
| 13 |
const cell = (id: string, rowSpan = 1, colSpan = 1) => ({ |
| 14 |
id, |
| 15 |
rowSpan, |
| 16 |
colSpan, |
| 17 |
}); |
| 18 |
|
| 19 |
/** |
| 20 |
* 3x3 with a 2x2 merged block "M" anchored at (0,0): |
| 21 |
* M M a |
| 22 |
* M M b |
| 23 |
* c d e |
| 24 |
*/ |
| 25 |
const spanned: GridRow[] = [ |
| 26 |
[cell("M", 2, 2), cell("a")], |
| 27 |
[cell("b")], |
| 28 |
[cell("c"), cell("d"), cell("e")], |
| 29 |
]; |
| 30 |
|
| 31 |
const plain: GridRow[] = [ |
| 32 |
[cell("a1"), cell("a2"), cell("a3")], |
| 33 |
[cell("b1"), cell("b2"), cell("b3")], |
| 34 |
]; |
| 35 |
|
| 36 |
describe("buildOccupancy", () => { |
| 37 |
it("maps anchors and covered positions", () => { |
| 38 |
const { matrix, anchors, cols } = buildOccupancy(spanned); |
| 39 |
|
| 40 |
expect(cols).toBe(3); |
| 41 |
expect(matrix[0]).toEqual(["M", "M", "a"]); |
| 42 |
expect(matrix[1]).toEqual(["M", "M", "b"]); |
| 43 |
expect(matrix[2]).toEqual(["c", "d", "e"]); |
| 44 |
expect(anchors.get("b")).toEqual({ row: 1, col: 2 }); |
| 45 |
expect(anchors.get("d")).toEqual({ row: 2, col: 1 }); |
| 46 |
}); |
| 47 |
|
| 48 |
it("computes cellColumn through spans", () => { |
| 49 |
expect(cellColumn(spanned, "b")).toBe(2); |
| 50 |
expect(cellColumn(spanned, "e")).toBe(2); |
| 51 |
}); |
| 52 |
}); |
| 53 |
|
| 54 |
describe("planInsertRow", () => { |
| 55 |
it("plain: full-width new row", () => { |
| 56 |
expect(planInsertRow(plain, 1)).toEqual({ |
| 57 |
growSpans: [], |
| 58 |
newCellCount: 3, |
| 59 |
}); |
| 60 |
}); |
| 61 |
|
| 62 |
it("grows spans crossing the line and shrinks the new row", () => { |
| 63 |
// Between row0 and row1: M (rows 0-1) crosses. |
| 64 |
expect(planInsertRow(spanned, 1)).toEqual({ |
| 65 |
growSpans: ["M"], |
| 66 |
newCellCount: 1, |
| 67 |
}); |
| 68 |
}); |
| 69 |
|
| 70 |
it("no crossing at outer edges", () => { |
| 71 |
expect(planInsertRow(spanned, 0).growSpans).toEqual([]); |
| 72 |
expect(planInsertRow(spanned, 3).growSpans).toEqual([]); |
| 73 |
expect(planInsertRow(spanned, 0).newCellCount).toBe(3); |
| 74 |
}); |
| 75 |
}); |
| 76 |
|
| 77 |
describe("planDeleteRow", () => { |
| 78 |
it("plain: nothing special", () => { |
| 79 |
expect(planDeleteRow(plain, 0)).toEqual({ |
| 80 |
shrinkSpans: [], |
| 81 |
reanchor: [], |
| 82 |
}); |
| 83 |
}); |
| 84 |
|
| 85 |
it("shrinks spans crossing the deleted row", () => { |
| 86 |
// Deleting row 1: M crosses (anchored row 0, extends into row 1). |
| 87 |
expect(planDeleteRow(spanned, 1)).toEqual({ |
| 88 |
shrinkSpans: ["M"], |
| 89 |
reanchor: [], |
| 90 |
}); |
| 91 |
}); |
| 92 |
|
| 93 |
it("re-anchors spans anchored in the deleted row", () => { |
| 94 |
// Deleting row 0: M is anchored there with rowSpan 2 -> it must be |
| 95 |
// recreated in row 1 (as first cell) with rowSpan 1. |
| 96 |
expect(planDeleteRow(spanned, 0)).toEqual({ |
| 97 |
shrinkSpans: [], |
| 98 |
reanchor: [ |
| 99 |
{ id: "M", insertIndex: 0, rowSpan: 1, colSpan: 2 }, |
| 100 |
], |
| 101 |
}); |
| 102 |
}); |
| 103 |
}); |
| 104 |
|
| 105 |
describe("planInsertColumn", () => { |
| 106 |
it("plain: inserts at the right array position in each row", () => { |
| 107 |
expect(planInsertColumn(plain, 1)).toEqual([ |
| 108 |
{ type: "insert", rowIndex: 0, insertIndex: 1 }, |
| 109 |
{ type: "insert", rowIndex: 1, insertIndex: 1 }, |
| 110 |
]); |
| 111 |
}); |
| 112 |
|
| 113 |
it("grows a span crossed by the line, once, at its anchor row", () => { |
| 114 |
// Line between col0 and col1 runs through M (cols 0-1). |
| 115 |
expect(planInsertColumn(spanned, 1)).toEqual([ |
| 116 |
{ type: "grow", rowIndex: 0, id: "M" }, |
| 117 |
{ type: "skip", rowIndex: 1 }, |
| 118 |
{ type: "insert", rowIndex: 2, insertIndex: 1 }, |
| 119 |
]); |
| 120 |
}); |
| 121 |
|
| 122 |
it("edge inserts never split spans", () => { |
| 123 |
expect(planInsertColumn(spanned, 0)).toEqual([ |
| 124 |
{ type: "insert", rowIndex: 0, insertIndex: 0 }, |
| 125 |
{ type: "insert", rowIndex: 1, insertIndex: 0 }, |
| 126 |
{ type: "insert", rowIndex: 2, insertIndex: 0 }, |
| 127 |
]); |
| 128 |
expect(planInsertColumn(spanned, 3)).toEqual([ |
| 129 |
{ type: "insert", rowIndex: 0, insertIndex: 2 }, |
| 130 |
{ type: "insert", rowIndex: 1, insertIndex: 1 }, |
| 131 |
{ type: "insert", rowIndex: 2, insertIndex: 3 }, |
| 132 |
]); |
| 133 |
}); |
| 134 |
}); |
| 135 |
|
| 136 |
describe("planDeleteColumn", () => { |
| 137 |
it("plain: removes one cell per row", () => { |
| 138 |
expect(planDeleteColumn(plain, 1)).toEqual([ |
| 139 |
{ type: "remove", rowIndex: 0, id: "a2" }, |
| 140 |
{ type: "remove", rowIndex: 1, id: "b2" }, |
| 141 |
]); |
| 142 |
}); |
| 143 |
|
| 144 |
it("shrinks spans covering the column", () => { |
| 145 |
expect(planDeleteColumn(spanned, 0)).toEqual([ |
| 146 |
{ type: "shrink", rowIndex: 0, id: "M" }, |
| 147 |
{ type: "skip", rowIndex: 1 }, |
| 148 |
{ type: "remove", rowIndex: 2, id: "c" }, |
| 149 |
]); |
| 150 |
}); |
| 151 |
}); |
| 152 |
|
| 153 |
describe("planMerge", () => { |
| 154 |
it("merges a clean rectangle", () => { |
| 155 |
expect(planMerge(plain, ["a1", "a2"])).toEqual({ |
| 156 |
anchorId: "a1", |
| 157 |
rowSpan: 1, |
| 158 |
colSpan: 2, |
| 159 |
absorbedIds: ["a2"], |
| 160 |
}); |
| 161 |
}); |
| 162 |
|
| 163 |
it("merges vertically across rows", () => { |
| 164 |
expect(planMerge(plain, ["a1", "b1"])).toEqual({ |
| 165 |
anchorId: "a1", |
| 166 |
rowSpan: 2, |
| 167 |
colSpan: 1, |
| 168 |
absorbedIds: ["b1"], |
| 169 |
}); |
| 170 |
}); |
| 171 |
|
| 172 |
it("merges an already-merged cell with neighbours tiling a rect", () => { |
| 173 |
// M (2x2) + a + b = full 2x3 rect. |
| 174 |
expect(planMerge(spanned, ["M", "a", "b"])).toEqual({ |
| 175 |
anchorId: "M", |
| 176 |
rowSpan: 2, |
| 177 |
colSpan: 3, |
| 178 |
absorbedIds: ["a", "b"], |
| 179 |
}); |
| 180 |
}); |
| 181 |
|
| 182 |
it("rejects non-rectangular selections", () => { |
| 183 |
// L-shape: a1 + a2 + b1 |
| 184 |
expect(planMerge(plain, ["a1", "a2", "b1"])).toBeNull(); |
| 185 |
// M + c does not tile a rectangle. |
| 186 |
expect(planMerge(spanned, ["M", "c"])).toBeNull(); |
| 187 |
}); |
| 188 |
|
| 189 |
it("rejects fewer than two cells", () => { |
| 190 |
expect(planMerge(plain, ["a1"])).toBeNull(); |
| 191 |
}); |
| 192 |
}); |
| 193 |
|
| 194 |
describe("planSplit", () => { |
| 195 |
it("splits a merged cell into 1x1 positions", () => { |
| 196 |
expect(planSplit(spanned, "M")).toEqual({ |
| 197 |
inserts: [ |
| 198 |
{ rowIndex: 0, insertIndex: 1, count: 1 }, |
| 199 |
{ rowIndex: 1, insertIndex: 0, count: 2 }, |
| 200 |
], |
| 201 |
}); |
| 202 |
}); |
| 203 |
|
| 204 |
it("returns null for 1x1 cells", () => { |
| 205 |
expect(planSplit(plain, "a1")).toBeNull(); |
| 206 |
}); |
| 207 |
}); |
| 208 |
|
| 209 |
describe("round-trips", () => { |
| 210 |
it("insert row then occupancy stays consistent", () => { |
| 211 |
const plan = planInsertRow(spanned, 1); |
| 212 |
// Simulate: M grows to rowSpan 3, new row with 1 cell inserted at 1. |
| 213 |
const grown: GridRow[] = [ |
| 214 |
[cell("M", 3, 2), cell("a")], |
| 215 |
[cell("new1")], |
| 216 |
[cell("b")], |
| 217 |
[cell("c"), cell("d"), cell("e")], |
| 218 |
]; |
| 219 |
expect(plan.newCellCount).toBe(1); |
| 220 |
const { matrix, cols } = buildOccupancy(grown); |
| 221 |
expect(cols).toBe(3); |
| 222 |
expect(matrix[1]).toEqual(["M", "M", "new1"]); |
| 223 |
expect(matrix[2]).toEqual(["M", "M", "b"]); |
| 224 |
}); |
| 225 |
|
| 226 |
it("merge then split restores full occupancy", () => { |
| 227 |
const merge = planMerge(plain, ["a1", "a2", "b1", "b2"]); |
| 228 |
expect(merge).toEqual({ |
| 229 |
anchorId: "a1", |
| 230 |
rowSpan: 2, |
| 231 |
colSpan: 2, |
| 232 |
absorbedIds: ["a2", "b1", "b2"], |
| 233 |
}); |
| 234 |
|
| 235 |
const merged: GridRow[] = [ |
| 236 |
[cell("a1", 2, 2), cell("a3")], |
| 237 |
[cell("b3")], |
| 238 |
]; |
| 239 |
const split = planSplit(merged, "a1"); |
| 240 |
expect(split).toEqual({ |
| 241 |
inserts: [ |
| 242 |
{ rowIndex: 0, insertIndex: 1, count: 1 }, |
| 243 |
{ rowIndex: 1, insertIndex: 0, count: 2 }, |
| 244 |
], |
| 245 |
}); |
| 246 |
}); |
| 247 |
}); |
| 248 |
|