element-call-Github/test/video-grid/BigGrid-test.ts
Robin 169ccd9de5 Start refactoring some business logic into view models
As Element Call grows in complexity, it has become a pain point that our business logic remains so tightly coupled to the UI code. In particular, this has made testing difficult, and the complex semantics of React hooks are not a great match for arbitrary business logic. Here, I show the beginnings of what it would look like for us to adopt the MVVM pattern. I've created a CallViewModel and TileViewModel that expose their state to the UI as rxjs Observables, as well as a couple of helper functions for consuming view models in React code.

This should contain no user-visible changes, but we need to watch out for regressions particularly around focus switching and promotion of speakers, because this was the logic I chose to refactor first.
2023-12-01 17:09:21 -05:00

494 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TileDescriptor } from "../../src/state/CallViewModel";
import {
addItems,
column,
cycleTileSize,
fillGaps,
forEachCellInArea,
Grid,
SparseGrid,
resize,
row,
moveTile,
} from "../../src/video-grid/BigGrid";
/**
* Builds a grid from a string specifying the contents of each cell as a letter.
*/
function mkGrid(spec: string): Grid {
const secondNewline = spec.indexOf("\n", 1);
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
const areas = new Set(cells);
areas.delete(" "); // Space represents an empty cell, not an area
const grid: Grid = { columns, cells: new Array(cells.length) };
for (const area of areas) {
const start = cells.indexOf(area);
const end = cells.lastIndexOf(area);
const rows = row(end, grid) - row(start, grid) + 1;
const columns = column(end, grid) - column(start, grid) + 1;
forEachCellInArea(start, end, grid, (_c, i) => {
grid.cells[i] = {
item: { id: area } as unknown as TileDescriptor<unknown>,
origin: i === start,
rows,
columns,
};
});
}
return grid;
}
/**
* Turns a grid into a string showing the contents of each cell as a letter.
*/
function showGrid(g: Grid): string {
let result = "\n";
for (let i = 0; i < g.cells.length; i++) {
if (i > 0 && i % g.columns == 0) result += "\n";
result += g.cells[i]?.item.id ?? " ";
}
return result;
}
function testFillGaps(title: string, input: string, output: string): void {
test(`fillGaps ${title}`, () => {
expect(showGrid(fillGaps(mkGrid(input)))).toBe(output);
});
}
testFillGaps(
"does nothing on an empty grid",
`
`,
`
`,
);
testFillGaps(
"does nothing if there are no gaps",
`
ab
cd
ef`,
`
ab
cd
ef`,
);
testFillGaps(
"fills a gap",
`
a b
cde
f`,
`
cab
fde`,
);
testFillGaps(
"fills multiple gaps",
`
a bc
defgh
ijkl
mno`,
`
aebch
difgl
mjnok`,
);
testFillGaps(
"fills a big gap with 1×1 tiles",
`
abcd
e f
g h
ijkl`,
`
abcd
ehkf
glji`,
);
testFillGaps(
"fills a big gap with a large tile",
`
aa
bc`,
`
aa
cb`,
);
testFillGaps(
"prefers moving around large tiles",
`
a bc
ddde
dddf
ghij
k`,
`
abce
dddf
dddj
kghi`,
);
testFillGaps(
"moves through large tiles if necessary",
`
a bc
dddd
efgh
i`,
`
afbc
dddd
iegh`,
);
testFillGaps(
"keeps a large tile from hanging off the bottom",
`
abcd
efgh
ii
ii`,
`
abcd
iigh
iief`,
);
testFillGaps(
"collapses large tiles trapped at the bottom",
`
abcd
e fg
hh
hh
ii
ii`,
`
abcd
hhfg
hhie`,
);
testFillGaps(
"gives up on pushing large tiles upwards when not possible",
`
aa
aa
bccd
eccf
ghij`,
`
aadf
aaji
bcch
eccg`,
);
function testCycleTileSize(
title: string,
tileId: string,
input: string,
output: string,
): void {
test(`cycleTileSize ${title}`, () => {
const grid = mkGrid(input);
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
expect(showGrid(cycleTileSize(grid, tile))).toBe(output);
});
}
testCycleTileSize(
"expands a tile to 2×2 in a 3 column layout",
"c",
`
abc
def
ghi`,
`
acc
dcc
gbe
ifh`,
);
testCycleTileSize(
"expands a tile to 3×3 in a 4 column layout",
"g",
`
abcd
efgh`,
`
acdh
bggg
fggg
e`,
);
testCycleTileSize(
"restores a tile to 1×1",
"b",
`
abbc
dbbe
fghi
jk`,
`
abhc
djge
fik`,
);
testCycleTileSize(
"expands a tile even in a crowded grid",
"c",
`
abb
cbb
dde
ddf
ghi
klm`,
`
abb
gbb
dde
ddf
ccm
cch
lik`,
);
testCycleTileSize(
"does nothing if the tile has no room to expand",
"c",
`
abb
cbb
dde
ddf`,
`
abb
cbb
dde
ddf`,
);
test("cycleTileSize is its own inverse", () => {
const input = `
abc
def
ghi
jk`;
const grid = mkGrid(input);
let gridAfter = grid;
const toggle = (tileId: string): void => {
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
gridAfter = cycleTileSize(gridAfter, tile);
};
// Toggle a series of tiles
toggle("j");
toggle("h");
toggle("a");
// Now do the same thing in reverse
toggle("a");
toggle("h");
toggle("j");
// The grid should be back to its original state
expect(showGrid(gridAfter)).toBe(input);
});
function testAddItems(
title: string,
items: TileDescriptor<unknown>[],
input: string,
output: string,
): void {
test(`addItems ${title}`, () => {
expect(showGrid(addItems(items, mkGrid(input) as SparseGrid) as Grid)).toBe(
output,
);
});
}
testAddItems(
"appends 1×1 tiles",
["e", "f"].map((i) => ({ id: i }) as unknown as TileDescriptor<unknown>),
`
aab
aac
d`,
`
aab
aac
def`,
);
testAddItems(
"places one tile near another on request",
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor<unknown>],
`
abc
def`,
`
abc
g
def`,
);
testAddItems(
"places items with a large base size",
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor<unknown>],
`
abc
def`,
`
abc
ggf
gge
d`,
);
function testMoveTile(
title: string,
from: number,
to: number,
input: string,
output: string,
): void {
test(`moveTile ${title}`, () => {
expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
});
}
testMoveTile(
"refuses to move a tile too far to the left",
1,
-1,
`
abc`,
`
abc`,
);
testMoveTile(
"refuses to move a tile too far to the right",
1,
3,
`
abc`,
`
abc`,
);
testMoveTile(
"moves a large tile to an unoccupied space",
3,
1,
`
a b
ccd
cce`,
`
acc
bcc
d e`,
);
testMoveTile(
"refuses to move a large tile to an occupied space",
3,
1,
`
abb
ccd
cce`,
`
abb
ccd
cce`,
);
function testResize(
title: string,
columns: number,
input: string,
output: string,
): void {
test(`resize ${title}`, () => {
expect(showGrid(resize(mkGrid(input), columns))).toBe(output);
});
}
testResize(
"contracts the grid",
2,
`
abbb
cbbb
ddde
dddf
gh`,
`
af
bb
bb
dd
dd
ch
eg`,
);
testResize(
"expands the grid",
4,
`
af
bb
bb
ch
dd
dd
eg`,
`
afcd
bbbg
bbbe
h`,
);