Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 cac6c14a23 fix(beskeder): klokkens panel bliver inden for skærmen på en telefon
Panelet hang under klokken med `right: 0` og en fast bredde på 340 px.
Klokken sidder ikke yderst — der er en temaknap, et mellemrum og en
kantafstand til højre for den — så der er kun omkring 330 px tilbage, og
resten blev skubbet ud over venstre kant og klippet af. Målt til -10 px ved
en skærmbredde på 390; på en smallere telefon mere. Overskriften "Beskeder"
klæbede til kanten uden luft, hvilket er det synlige tegn.

På en telefon hører panelet nu til skærmen i stedet for til klokken: det
spænder fra kant til kant med en margen i hver side, under toplinjen. Samme
knæk ved 820 px som toplinjens egen menuknap, så de to skifter form
samtidig. Det får også `overscroll-behavior: contain`, så det at rulle i
listen ikke trækker siden bagved med.

Toplinjens underkant måles frem for at blive regnet ud. Bredden KUNNE
trækkes fra i CSS, men så ville den her fil kende sine naboers størrelse, og
den næste knap i toplinjen ville lave fejlen om igen uden at sige noget.

--- og fire ustabile prøver mere ---

Fladen tegner hver ændring med det samme og sender kaldet bagefter. Alt, man
kan se, er altså sandt, før serveren har hørt om det, og en prøve, der
genindlæser eller klikker videre i det hul, måler den gamle tilstand.

`en monospace-linje kan forlades` ventede 1200 ms før en genindlæsning.
Datoprøven ventede 400 ms. Et gæt om, hvor hurtig maskinen er, holder indtil
CI er travl — begge fejlede i en fuld kørsel her.

`en gruppe er en side med sine projekter` er den mest lærerige: trækket
virkede, og `toPass` blev grøn på den lokale tilstand, hvorefter klikket
videre hentede en gruppe uden projekter i. Prøvens egen kommentar gætter på
et upålideligt HTML5-træk; det var gemningen, der ikke var nået frem.

Monospace-prøven havde OVENI en anden fejl, som først viste sig, da den
første var lukket: den klikkede "Ny note" og skrev med det samme. `create()`
venter på serverens svar, og imens står den forrige notes editor stadig på
skærmen, så teksten gik ind i den forrige note og den nye blev tom. Ny
hjælper `nyNote()` venter på et tomt felt. Omkring 14 andre steder i filen
har samme mønster; de er ikke skrevet om, fordi de består, og en samlet
omskrivning ville bytte kendte prøver for ukendte.

Det hul findes også for et menneske: klikker man Ny note og skriver straks,
lander de første anslag i den forrige note. Det er millisekunder lokalt og
sekunder på en dårlig forbindelse. Samme familie som #86, men tiden FØR
svaret frem for efter. Noteret som et selvstændigt punkt, ikke rettet her.

`noteGemt()` fra #88 er nu `gemt(side, sti)` med noteGemt, opgaveGemt og
projektGemt ovenpå. Der står 23 faste ventetider tilbage, men ingen af dem
lige før en genindlæsning eller en navigation.

Fuld chromium-suite kørt to gange: 90 bestået, 0 fejlet, begge gange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:55:45 +02:00
Kristian Wind f632fb8643 Merge pull request #89 from kristianwind/to-vaern-mere
test(e2e): to genindlæsninger mere venter på gemningen
2026-09-17 13:12:07 +02:00
kristianwindandClaude Opus 5 eabb00ecc2 test(e2e): to genindlæsninger mere venter på gemningen
Samme form som de fire "Gemt"-ventetider, to andre steder — begge lige før
en `page.reload()`, hvilket er der, hvor det koster.

`en note kan skrives fed, og den kommer hel tilbage` havde `blur()` direkte
efterfulgt af en genindlæsning uden noget værn overhovedet. Nåede PATCH'en
ikke frem først, målte prøven en note, der aldrig blev skrevet — og den
prøve findes netop for at fange en omformer, der er uenig med sig selv.

`[[henvisning]]`-prøven havde `waitForTimeout(900)`. Tallet var gættet til
at overleve tastepausen på 700 ms; et gæt om tid måler maskinen frem for
programmet. Det er den konstruktion, den her fil allerede advarer imod.

Begge venter nu på PATCH-svaret gennem noteGemt().

Der står 24 faste ventetider tilbage i filen. De er ikke rørt: flere af dem
venter med vilje (at skrive i et menneskeligt tempo er en rigtig grund), og
at skrive dem om samlet ville bytte kendte prøver for ukendte.

Fuld chromium-suite: 89 bestået, 0 fejlet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:02:31 +02:00
Kristian Wind d2b5821e57 Merge pull request #88 from kristianwind/vaern-der-venter-paa-noget
test(e2e): værn, der kan vente på noget
2026-09-17 12:59:26 +02:00
kristianwindandClaude Opus 5 4874f7bbd8 test(e2e): værn, der kan vente på noget
Fire steder ventede prøverne på teksten "Gemt" i notefoden. Den er
hvilestanden: feltet siger "Gemmer" mens en gemning kører og "Gemt"
ellers — altså også før den første gemning overhovedet er begyndt. Alle
fire ventetider var grønne med det samme og ventede på ingenting.

To af dem dækkede over et rigtigt kapløb:

- `en delt note tager de noter med, den peger på` skriver i en note og
  navigerer væk umiddelbart efter. Nåede PATCH'en ikke frem først, var
  teksten væk, da siden kom igen.

- `en gruppe er en side med sine projekter` har samme form et andet sted:
  beskrivelsen tegnes optimistisk — siden sætter teksten på gruppen straks
  og sender PATCH'en bagefter — så kontrollen var grøn, før serveren havde
  hørt om den, og `page.reload()` lige efter hentede den gamle gruppe
  tilbage. Prøven fejlede altså på noget, der ikke var i stykker.

De to sidste ventede på ingenting uden at skade noget, men var stadig
vildledende at læse.

Der er nu én hjælper, `noteGemt(side)`, der venter på selve PATCH-svaret,
med forklaringen på hvorfor "Gemt" ikke duer skrevet ét sted.
Gruppeprøven venter tilsvarende på sin egen PATCH. Hvor rækken i listen
allerede var det rigtige værn, er den overflødige linje væk.

Samme form som reglen i CLAUDE.md om at læse, hvad en kontrol skrev, da
den BESTOD: "venter på gemning" og "matchede den tekst, der altid står
der", er samme grønne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:49:15 +02:00
Kristian Wind 8bf4f8fbe4 Merge pull request #87 from kristianwind/et-udfald-siger-det
feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
2026-09-17 12:38:19 +02:00
2 changed files with 198 additions and 16 deletions
+155 -14
View File
@@ -38,6 +38,61 @@ async function projectAction(page, name) {
await item.click();
}
/**
* Venter på, at en gemning faktisk er nået frem til serveren.
*
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
* og fejler på noget, der ikke er i stykker.
*
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
*
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
*
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
* allerede kommet og gået.
*/
function gemt(side, sti) {
return side.waitForResponse(
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
);
}
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
/**
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
*
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
* findes den titel, prøven leder efter, slet ikke.
*
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
*
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
*/
async function nyNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
return felt;
}
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
@@ -1683,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
// HTML5 drag occasionally does not register the first time under CI load, and a
// single dragTo then leaves the group empty and the page assertion failing for a
// reason that has nothing to do with what the test is about.
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
const flyttet = projektGemt(page);
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
await expect(async () => {
await sidebar
@@ -1690,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await expect(nested).toHaveCount(1, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await flyttet;
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
@@ -1703,8 +1764,21 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
const about = page.getByLabel('Om gruppen');
await expect(about).toBeVisible();
await about.fill('Alt der larmer og støver.');
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
// der ikke er i stykker. Svaret er det, der kan ventes på.
const gemt = page.waitForResponse(
(r) =>
r.request().method() === 'PATCH' &&
new URL(r.url()).pathname.includes('/project-groups/') &&
r.ok()
);
await about.blur();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
await gemt;
// And it survives a reload, which is the difference between the page keeping up
// and the server taking it.
@@ -2724,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
const rundtur = noteGemt(page);
await page_.blur();
await rundtur;
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
@@ -2911,8 +2987,9 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
const gemt = noteGemt(gæst);
await body.blur();
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
await gemt;
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
@@ -2975,10 +3052,33 @@ test('en delt note tager de noter med, den peger på', async ({ browser, page })
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
// på den er grøn med det samme og venter på ingenting.
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type(text);
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await felt.blur();
const titel = text.split('\n')[0];
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
// gik i begge:
//
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
// delstreng og uden hensyn til store og små bogstaver — den fandt også
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
// af hele suiten.
//
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
// tekst med den titel i, så et opslag på teksten fandt tre.
//
// `button.row strong` er rækkens titelelement, og en forankret regex er både
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
await expect(
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
).toHaveCount(1);
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
@@ -3064,8 +3164,9 @@ test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ p
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
const kildeGemt = noteGemt(page);
await body.blur();
await expect(page.locator('footer .hint')).toHaveText('Gemt');
await kildeGemt;
await page.getByRole('button', { name: /Vis kilden/ }).click();
await expect(page.locator('textarea.source')).toHaveValue(/`/);
@@ -3137,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
const linketGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(900);
await linketGemt;
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
@@ -3675,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
const ed = await nyNote(page);
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
@@ -3713,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await nyNote(page);
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
@@ -3722,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
const rundturGemt = noteGemt(page);
await ed.blur();
await page.waitForTimeout(1200);
await rundturGemt;
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
@@ -4025,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
const datoGemt = opgaveGemt(page);
await date.fill(day);
await date.blur();
await page.waitForTimeout(400);
await datoGemt;
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
@@ -4782,6 +4885,44 @@ test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
});
/**
* Klokkens panel bliver inden for skærmen på en telefon.
*
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
* kant end det — der er en temaknap og en kantafstand til højre for den — så
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
* den.
*
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
* det stikke ud til højre, er ikke en rettelse.
*/
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByRole('button', { name: /^Beskeder/ }).click();
const panel = page.getByRole('dialog', { name: 'Beskeder' });
await expect(panel).toBeVisible();
const kasse = await panel.boundingBox();
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
// ud på telefonen.
const overskrift = panel.getByText('Beskeder', { exact: true });
const tekst = await overskrift.boundingBox();
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
@@ -4937,7 +5078,7 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
const krop = page.getByLabel('Notens tekst');
await krop.fill('Kaffemølle\n\nnoget om den');
await krop.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
+43 -2
View File
@@ -20,6 +20,31 @@
let open = $state(false);
let wrap = $state(null);
/**
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
*
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
* klippet. Der stod tre en halv linje tekst uden for skærmen.
*
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
* kantafstand til højre for klokken — men så ville den her fil kende sine
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
* skærmen med en kant i hver side.
*/
let below = $state(0);
function place() {
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
}
function toggle() {
open = !open;
if (open) place();
}
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
@@ -72,6 +97,7 @@
<svelte:window
onclick={outside}
onresize={() => open && place()}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
@@ -81,7 +107,7 @@
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
onclick={toggle}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={app.unread
@@ -101,7 +127,7 @@
</button>
{#if open}
<div class="panel" role="dialog" aria-label={t('notif.title')}>
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
<header>
<span>{t('notif.title')}</span>
{#if app.unread}
@@ -193,6 +219,21 @@
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
}
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
toplinjens egen menuknap, så de to skifter form samtidig. */
@media (max-width: 820px) {
.panel {
position: fixed;
top: calc(var(--below) + 6px);
left: var(--s3);
right: var(--s3);
width: auto;
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
overscroll-behavior: contain;
}
}
.panel header {
display: flex;
align-items: center;