diff --git a/Components/PublicNoticeHost.razor b/Components/PublicNoticeHost.razor index 170c225..3f0b083 100644 --- a/Components/PublicNoticeHost.razor +++ b/Components/PublicNoticeHost.razor @@ -3,7 +3,8 @@ @* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event (PublicNoticeService), in MainLayout. *@ - + @(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])

@@ -14,7 +15,7 @@ @Localizer["Cancel"] - @Localizer["Continue"] + @Localizer["Continue"] diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index 816529b..635cf0c 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -112,7 +112,8 @@

- + diff --git a/tests/e2e/Mobile/Fit.cs b/tests/e2e/Mobile/Fit.cs index 56a5ff9..1cede72 100644 --- a/tests/e2e/Mobile/Fit.cs +++ b/tests/e2e/Mobile/Fit.cs @@ -111,6 +111,17 @@ namespace decePubClient.E2E.Mobile return report; } + // one measurement of the page as it is now; with a scope (an open menu, sheet or dialog), its targets only + public static async Task Now(IPage page, string scope = null) + { + await Settle(page); + var report = new Report(); + var measured = JsonNode.Parse((await page.EvaluateAsync("scope => window.__fit.measure(scope)", scope)).GetRawText())!; + report.Take(measured, 0); + report.Screens = 1; + return report; + } + // nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole public static void AssertFits(Report report) { diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs new file mode 100644 index 0000000..107fea2 --- /dev/null +++ b/tests/e2e/Mobile/FlowTests.cs @@ -0,0 +1,188 @@ +using System.Text.Json.Nodes; + +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Mobile +{ + // What a thumb does on the narrowest phone, step by step: every menu, sheet and dialog that opens is whole on the + // screen and its targets have 44px hit areas, and each one closes the way a phone user expects. One cell each, + // client.mobile.flow.*. + [Trait("Category", "Mobile")] + public sealed class FlowTests(Browser browser) + { + static readonly Phone Phone = Phone.Narrow360; + + static Pasture.Persona Persona() => Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First(); + + static async Task Flow(Session session, string name, Func steps) + { + var page = session.Page; + await Results.Cell($"client.mobile.flow.{name}", "privapub", Phone.Key, async () => + { + await steps(page); + await session.AssertGuards(); + }, page); + } + + // what opened is whole (not cut, inside the screen) and its targets are thumb-sized; the rest of the page is the + // sweep's + static async Task AssertWhole(IPage page, string scope) + { + var report = await Fit.Now(page, scope); + Assert.True(report.Popovers.Count == 0, "cut or outside: " + string.Join("\n", report.Popovers.Select(p => p.ToJsonString()))); + Fit.AssertTouchable(report); + } + + static ILocator Card(IPage page, string has = null) => + (has is null ? page.Locator("#feed-scroll article[data-uri]") : page.Locator($"#feed-scroll article[data-uri]:has({has})")).First; + + [Fact] + public async Task The_menu_closes_after_a_choice_an_outside_tap_and_escape() + { + await using var session = await SignIn.As(browser, Persona(), Phone); + await Flow(session, "nav", async page => + { + var toggle = page.Locator("[data-nav-toggle]"); + var panel = page.Locator("[data-nav-panel]"); + await toggle.TapAsync(); + await Assertions.Expect(panel).ToBeVisibleAsync(); + await AssertWhole(page, "[data-nav-panel]"); + await panel.Locator("a[href='explore']").TapAsync(); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore"); + await Assertions.Expect(panel).ToBeHiddenAsync(); + + await toggle.TapAsync(); + await Assertions.Expect(panel).ToBeVisibleAsync(); + var size = page.ViewportSize!; + await page.Touchscreen.TapAsync(size.Width - 12, size.Height - 40); + await Assertions.Expect(panel).ToBeHiddenAsync(); + + await toggle.TapAsync(); + await Assertions.Expect(panel).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("Escape"); + await Assertions.Expect(panel).ToBeHiddenAsync(); + }); + } + + [Fact] + public async Task A_dialog_rises_from_the_bottom_and_closes_from_its_button() + { + await using var session = await SignIn.As(browser, Persona(), Phone); + await Flow(session, "dialog", async page => + { + var card = Card(page, "[data-liked-by]"); + if (await card.CountAsync() == 0) + Assert.Skip("no post in the home feed has been liked"); + await card.ScrollIntoViewIfNeededAsync(); + await card.Locator("footer [title='Other'] > [role=button]").TapAsync(); + await AssertWhole(page, "[data-sheet]"); + await card.Locator("[data-liked-by]").TapAsync(); + var dialog = page.Locator(".modal-open .modal-box").First; + await Assertions.Expect(dialog).ToBeVisibleAsync(); + await AssertWhole(page, ".modal-open .modal-box"); + var box = (await dialog.BoundingBoxAsync())!; + Assert.True(box.Y + box.Height >= page.ViewportSize!.Height - 2, "the dialog doesn't rise from the bottom of the screen"); + await page.Locator(".modal-open [data-modal-close]").TapAsync(); + await Assertions.Expect(page.Locator(".modal-open")).ToHaveCountAsync(0); + }); + } + + [Fact] + public async Task A_new_post_is_written_in_a_sheet_from_a_scrolled_feed() + { + var persona = Persona(); + var text = $"from a phone {Guid.NewGuid():N}"[..24]; + await using var session = await SignIn.As(browser, persona, Phone); + string made = null; + try + { + await Flow(session, "compose", async page => + { + await page.EvaluateAsync("() => document.getElementById('feed-scroll').scrollTo({ top: 1500, behavior: 'instant' })"); + await page.Locator("[data-compose-open]").TapAsync(); + var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First; + await Assertions.Expect(sheet).ToBeVisibleAsync(); + await AssertWhole(page, "[data-sheet]"); + Assert.True(await page.EvaluateAsync("() => document.activeElement?.matches('textarea[data-compose]') === true"), + "the composer's words don't have the focus"); + await page.Keyboard.TypeAsync(text); + await sheet.Locator("button[type=submit]").TapAsync(); + await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 }); + JsonNode latest = null; + Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains(text) == true, 15), + "the post was never published"); + made = latest!["id"]!.GetValue(); + }); + } + finally + { + if (made is not null) + await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete); + } + } + + [Fact] + public async Task A_reply_is_written_in_a_sheet() + { + var persona = Persona(); + var text = $"a phone reply {Guid.NewGuid():N}"[..24]; + await using var session = await SignIn.As(browser, persona, Phone); + string made = null; + try + { + await Flow(session, "reply", async page => + { + var card = Card(page, "footer button[title='Reply']"); + await card.Locator("footer button[title='Reply']").TapAsync(); + var sheet = page.Locator("[data-sheet]:has(textarea[data-compose])").First; + await Assertions.Expect(sheet).ToBeVisibleAsync(); + await AssertWhole(page, "[data-sheet]"); + await sheet.Locator("textarea[data-compose]").FillAsync(text); + await sheet.Locator("button[type=submit]").TapAsync(); + await Assertions.Expect(sheet).ToBeHiddenAsync(new() { Timeout = 15000 }); + JsonNode latest = null; + Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue().Contains(text) == true, 15), + "the reply was never published"); + made = latest!["id"]!.GetValue(); + Assert.NotNull(latest["in_reply_to_id"]?.GetValue()); + }); + } + finally + { + if (made is not null) + await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete); + } + } + + [Fact] + public async Task A_reaction_is_picked_from_a_sheet() + { + await using var session = await SignIn.As(browser, Persona(), Phone); + await Flow(session, "react", async page => + { + var card = Card(page, "footer [title='React']"); + var uri = await card.GetAttributeAsync("data-uri"); + card = page.Locator($"#feed-scroll article[data-uri='{uri}']").First; + await card.Locator("footer [title='React'] > [role=button]").TapAsync(); + await AssertWhole(page, "[data-sheet]"); + await card.Locator("[data-sheet] button", new() { HasText = "😮" }).First.TapAsync(); + var notice = page.Locator("[data-public-notice].modal-open"); + if (await notice.IsVisibleAsync() || await Pasture.Eventually(() => notice.IsVisibleAsync(), 2)) + { + await AssertWhole(page, "[data-public-notice] .modal-box"); + await page.Locator("[data-public-notice-yes]").TapAsync(); + } + var mine = card.Locator("[data-reactions] button.btn-active", new() { HasText = "😮" }); + await Assertions.Expect(mine).ToBeVisibleAsync(new() { Timeout = 15000 }); + // and taken back + await mine.TapAsync(); + await Assertions.Expect(mine).ToHaveCountAsync(0, new() { Timeout = 15000 }); + }); + } + + static async Task Latest(Pasture.Persona persona) => + (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); + } +} diff --git a/tests/e2e/Mobile/fit.js b/tests/e2e/Mobile/fit.js index 0de81a8..ad8336d 100644 --- a/tests/e2e/Mobile/fit.js +++ b/tests/e2e/Mobile/fit.js @@ -3,7 +3,8 @@ // settle() waits for fonts, for the page to stop changing, two frames and the pictures in view // primary() marks the page's main scroller, the one the sweep steps through // step(f) scrolls it by f of its height; false at its end -// measure() what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text +// measure(s) what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text; +// with a selector, the targets inside what it matches only (an open menu or dialog) window.__fit = (() => { const scrolls = value => value === "auto" || value === "scroll" const shown = el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true }) @@ -196,9 +197,10 @@ window.__fit = (() => { // a target needs a 44x44 CSS px hit area (Apple's guideline, WCAG 2.5.5): the centre and four points 21px away must // reach it, so a neighbour reaching into that square fails it as much as a small box does. Only targets a tap can reach // now are judged; one under an overlay or out of view is judged at another step - function targets() { + function targets(scope) { const small = [], tiny = [] - for (const el of document.querySelectorAll(candidates)) { + const within = scope ? [...document.querySelectorAll(scope)].flatMap(s => [...s.querySelectorAll(candidates)]) : document.querySelectorAll(candidates) + for (const el of within) { if (exempt(el) || !shown(el)) continue const own = el.getBoundingClientRect() @@ -234,9 +236,9 @@ window.__fit = (() => { return { small, tiny } } - function measure() { + function measure(scope) { styles = new Map() - const { small, tiny } = targets() + const { small, tiny } = targets(scope) const result = { url: location.pathname + location.search, overflow: overflow(), beyond: beyond(), popovers: popovers(), small, tiny } styles = new Map() return result