diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 16:34:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 16:34:12 +0200 |
| commit | eedbca3f0d47af39b4dd8812683e5a14ae4e48e6 (patch) | |
| tree | c99304c1f935e63a40e0546ef63ff30879d5aa9b /packages/meshbay-hub/tests/test_table_rows_measured.py | |
| parent | c384878aa0d6ef7a33bda23887dc264b94386725 (diff) | |
| download | meshbay-eedbca3f0d47af39b4dd8812683e5a14ae4e48e6.tar.gz | |
fix: two waits with no deadline, resume positions per account, group settings tab
**Joining a group could hang.** Reported after a first attempt that never
finished and a later one that worked — the shape of a network wait with no
deadline, and there were two.
Signaling here is non-trickle: the offer is not sent until ICE gathering says
it is done. A STUN server that is slow, filtered, or resolved through a DNS that
is not answering means `icegatheringstatechange` never reaches `complete`, and
`connect()` never returns. Same shape as the fullscreen denial fixed yesterday:
a promise that never settles leaves no error to find. Gathering now has four
seconds, after which the offer goes out with what it has — host candidates are
already there, which is enough on a LAN, and giving up instead would turn a slow
STUN server into a refusal to connect.
The second: `hub:fetch` in the desktop client had no timeout, so a host that
accepts a connection and then says nothing holds the request for as long as the
OS allows. `hub:probe` had one; the handler that carries signaling did not. Now
thirty seconds — longer than the hub's own fifteen-second signaling wait, so it
cannot abort a call that was about to succeed — and it says the hub did not
answer rather than "fetch failed".
**Resume positions belonged to the machine, not the account.** Stored as
`mb:pos:<file>`, so a second account signing in on the same computer was offered
"resume where you left off" in a film it had never opened. Wrong on its own
terms, and a small disclosure of what the other person watches, since the offer
only appears for files someone has actually been through. The account is in the
key now. Positions written before this are deleted rather than re-keyed: there
is no record of whose they were, and guessing hands them to whoever signs in
next, which is the bug.
**The staggered rules in the members table.** `display: flex` on the actions
`<td>` — a flex table cell stops being a table cell, so it no longer stretches
to its row and its bottom border is drawn wherever its own content ends.
Measured: in a row whose other cells were `top 76, height 40`, that cell was
`top 77, height 30`, its rule nine pixels above the rest. It is a table cell
again, held open by a zero-width strut so the owner's row — which has no remove
button — stays as tall as the others. Every cell now shares its row's top and
bottom exactly, at 420px and 900px.
**Members became Settings.** It was a list with three unrelated forms stacked
above it, laid out with inline styles on whichever element needed them, and the
group's own controls somewhere else entirely — leaving or deleting a group sat
in the page header beside the title. Now one tab in sections: invitations,
operator pairing, your devices on this node, leaving or deleting, and the roster
last, since it is the only part with no upper bound.
One consequence worth stating: the tab bar no longer waits for the node.
Membership is hub-side, and gating it on a live connection would have made
"leave this group" unreachable exactly when a node is down — which is when
someone most wants it. Files and chat still need the node and say so.
**A download button in the viewer**, beside the close button and in the same
style, for both the video player and the file preview.
844 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_table_rows_measured.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_table_rows_measured.py | 116 |
1 files changed, 116 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_table_rows_measured.py b/packages/meshbay-hub/tests/test_table_rows_measured.py new file mode 100644 index 0000000..60ec878 --- /dev/null +++ b/packages/meshbay-hub/tests/test_table_rows_measured.py @@ -0,0 +1,116 @@ +""" +Every cell in a row ends where its row ends. + +Reported as "un décalage sur les lignes du tableau listant les membres": the +horizontal rules between rows came out staggered rather than straight. + +The cause was `display: flex` on the actions `<td>`. **A flex table cell stops +being a table cell** — it no longer stretches to the height of its row, so its +`border-bottom` is drawn wherever its own content happens to end. Measured +before the fix, in a row whose other cells were `top 76, height 40`, the actions +cell was `top 77, height 30`: its rule nine pixels above the rest. + +Nothing in the stylesheet says this. `min-height: 30px` was already there, added +for a related symptom, and reads as though it settles the question. Only the +rectangles show it does not — which is what this file is for. +""" + +import json +import subprocess +from pathlib import Path + +import shutil + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "layout_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(), + reason="Chrome or the SPA stylesheet is not available") + +WIDTHS = [420, 900] + +# The members table as `MembersTab` renders it: the owner's row carries no +# button, which is the row that used to break. Two ordinary rows after it, so a +# rule between two *equal* rows can be told from a rule against the odd one. +TABLE = """ +<div class="main"><div class="settings-section"> +<table class="admin-table"> + <thead><tr><th>User</th><th>Role</th><th></th></tr></thead> + <tbody> + <tr><td>grenet</td> + <td><span class="badge">Owner</span></td> + <td class="admin-actions"></td></tr> + <tr><td>toto</td> + <td><span class="badge">Member</span></td> + <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr> + <tr><td>alice</td> + <td><span class="badge">Member</span></td> + <td class="admin-actions"><button class="admin-btn danger">Remove</button></td></tr> + </tbody> +</table> +</div></div> +""" + +ROWS = (1, 2, 3) +SELECTORS = [f"tbody tr:nth-child({r}) td:nth-child({c})" + for r in ROWS for c in (1, 2, 3)] + + +@pytest.fixture(scope="module") +def measured(tmp_path_factory): + fragment = tmp_path_factory.mktemp("table") / "fragment.html" + fragment.write_text(TABLE) + proc = subprocess.run( + ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), + str(fragment), *SELECTORS], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, f"no measurement: {out}" + return out + + +def _cells(measured, width: int, row: int) -> list[dict]: + boxes = measured[str(width)]["boxes"] + return [boxes[f"tbody tr:nth-child({row}) td:nth-child({c})"] for c in (1, 2, 3)] + + +@pytest.mark.parametrize("width", WIDTHS) +@pytest.mark.parametrize("row", ROWS) +def test_the_rule_under_a_row_is_one_straight_line(measured, width, row): + bottoms = [c["top"] + c["height"] for c in _cells(measured, width, row)] + assert max(bottoms) - min(bottoms) <= 1, ( + f"row {row} at {width}px ends at {bottoms} — the border under the " + f"actions cell is drawn {max(bottoms) - min(bottoms)}px off the others") + + +@pytest.mark.parametrize("width", WIDTHS) +@pytest.mark.parametrize("row", ROWS) +def test_every_cell_in_a_row_starts_at_the_same_height(measured, width, row): + tops = [c["top"] for c in _cells(measured, width, row)] + assert max(tops) - min(tops) <= 1, f"row {row} at {width}px starts at {tops}" + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_row_without_a_button_is_as_tall_as_the_others(measured, width): + """The owner cannot be removed, so that row has an empty actions cell. It + still has to be a row, not a thin one that reads as a rendering fault.""" + heights = [_cells(measured, width, r)[0]["height"] for r in ROWS] + assert max(heights) - min(heights) <= 1, ( + f"row heights at {width}px are {heights}") + + +@pytest.mark.parametrize("width", WIDTHS) +def test_the_rows_are_stacked_with_no_gap_or_overlap(measured, width): + """A cell that does not fill its row leaves the next one starting early or + late; consecutive rows meeting exactly is what says the table is intact.""" + for row in ROWS[:-1]: + below = _cells(measured, width, row + 1)[0]["top"] + for cell in _cells(measured, width, row): + end = cell["top"] + cell["height"] + assert abs(below - end) <= 1, ( + f"at {width}px a cell of row {row} ends at {end} while row " + f"{row + 1} starts at {below}") |