//go:build browser // The scheme switch in a browser: a choice that a standalone page // remembers across a reload, a restore that lands before the body's // own script runs, and a server-owned page that reads nothing a // standalone page on the same origin stored. package check import ( "context" "fmt" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/axe" "amadan.net/rastrillo/fiesta/internal/harness" ) // schemeMenu is the scheme-menu partial's markup at the seed commit, // by hand: a dropdown of three one-field forms, System current, no // script. The scheme-menu example's standalone state carries the same // shape. The forms name no action, so a submit fiesta.js failed to // take would POST to the page itself and the fixture server would // answer 405, which the harness reports. const schemeMenu = `
Colour scheme
` // schemeDoc is a page with the stylesheet and a theme, the menu, and // the scripts head asks for. store puts data-rst-scheme-store="local" // on , where fiesta.js reads it before anything else runs. The // body's own script also counts the rst-scheme events a listener on // the document hears, which is how an app's cookie writer listens. func schemeDoc(store bool, head string) string { attr := "" if store { attr = ` data-rst-scheme-store="local"` } return `` + `scheme` + `` + head + `
` + schemeMenu + `
` + `` } // schemePages is what driveSchemeRemember and the drives beside it // navigate: / is a standalone page, /server the same page without the // attribute, and /early a standalone page with fiesta.js in // without defer. Every page records data-theme as the body's own // script sees it, in window.__early. func schemePages() map[string]string { deferred := `` return map[string]string{ "/": schemeDoc(true, deferred), "/server": schemeDoc(false, deferred), "/early": schemeDoc(true, ``), } } // schemeState is what the page says about itself after each step. type schemeState struct { Theme string // data-theme on , "" for none Stored string // localStorage["fiesta-scheme"], "" for none Cookie string // document.cookie Current string // the scheme whose button is aria-current Open bool // the menu is still open Mark bool // window.__mark survived: no navigation happened Early string // data-theme as the body's inline script saw it Heard int // rst-scheme events a document listener heard } const schemeStateJS = `(() => { const m = document.querySelector("[rst-scheme]"); const cur = m.querySelector("button[aria-current]"); let stored = ""; try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {} return JSON.stringify({ Theme: document.documentElement.getAttribute("data-theme") || "", Stored: stored, Cookie: document.cookie, Current: cur ? cur.form.elements.scheme.value : "", Open: m.open, Mark: window.__mark === 1, Early: window.__early, Heard: window.__heard || 0 }); })()` // chooseScheme opens the menu and presses one scheme's button, then // waits for the menu to close, which the submit handler does in place. // The mark it leaves on the window says afterwards whether the page // navigated. func chooseScheme(t *testing.T, ctx context.Context, scheme string) { t.Helper() btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme) mustRun(t, ctx, chromedp.Evaluate(`window.__mark = 1; true`, nil), chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery), chromedp.WaitVisible(btn, chromedp.ByQuery), chromedp.Click(btn, chromedp.ByQuery), ) settleUntil(t, ctx, `!document.querySelector("[rst-scheme]").open`) } // driveSchemeRemember is the body of TestAStandaloneSchemeSurvivesAReload, // against any rig that serves schemePages (internal/build's drives run it // with the release's scripts). Dark is chosen in place: the attribute // changes, the key is stored, no cookie is written, the menu closes and // nothing navigates. A reload paints Dark again and marks its button, // which can only have come from storage. System undoes all of it, reload // included. func driveSchemeRemember(t *testing.T, rig *harness.Rig) { t.Helper() ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() var got schemeState mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) at(t, ctx, schemeStateJS, &got) if got.Theme != "" || got.Current != "system" || got.Stored != "" { t.Fatalf("first visit: %+v", got) } chooseScheme(t, ctx, "dark") at(t, ctx, schemeStateJS, &got) if got.Theme != "dark" || got.Current != "dark" || got.Stored != "dark" || got.Cookie != "" || got.Open || !got.Mark { t.Fatalf("after choosing Dark in place: %+v", got) } if got.Heard != 1 { t.Fatalf("a listener on the document heard %d rst-scheme events after Dark, want 1; the event is dispatched on the form and must bubble", got.Heard) } mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) at(t, ctx, schemeStateJS, &got) if got.Mark { t.Fatal("the reload did not happen: the mark survived") } if got.Theme != "dark" || got.Current != "dark" { t.Fatalf("after a reload the stored scheme was not restored: %+v", got) } chooseScheme(t, ctx, "system") at(t, ctx, schemeStateJS, &got) if got.Theme != "" || got.Current != "system" || got.Stored != "" || !got.Mark { t.Fatalf("after choosing System in place: %+v", got) } mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) at(t, ctx, schemeStateJS, &got) if got.Theme != "" || got.Current != "system" || got.Stored != "" { t.Fatalf("after a reload on System: %+v", got) } rig.Screen("body", "the scheme menu after Dark, System and two reloads") } func TestAStandaloneSchemeSurvivesAReload(t *testing.T) { driveSchemeRemember(t, sizingRig(t, false, schemePages())) } // With fiesta.js in without defer, the restore runs before the // body: the inline script at the end of the body already sees // data-theme. Before anything is stored the same page records // nothing, which is the control. func TestTheStoredSchemeIsSetBeforeTheBodyRuns(t *testing.T) { rig := sizingRig(t, false, schemePages()) ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() var got schemeState mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/early"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) at(t, ctx, schemeStateJS, &got) if got.Early != "" || got.Theme != "" { t.Fatalf("with nothing stored: %+v", got) } mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil), chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) at(t, ctx, schemeStateJS, &got) if got.Early != "dark" { t.Errorf("the body's script saw data-theme %q; the restore did not run before the body", got.Early) } if got.Theme != "dark" || got.Current != "dark" { t.Errorf("after the reload: %+v", got) } rig.Screen("body", "the early page with Dark stored") } // A standalone page and a server-owned page on one origin share a // localStorage. The server-owned page, which carries no attribute, // neither reads what the other stored (it loads without data-theme and // stays light) nor writes over it when its own switch is used. func TestAServerOwnedSchemeSurvivesAStandalonePage(t *testing.T) { rig := sizingRig(t, false, schemePages()) ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() var got schemeState mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) chooseScheme(t, ctx, "dark") at(t, ctx, schemeStateJS, &got) if got.Stored != "dark" { t.Fatalf("the standalone page did not store Dark: %+v", got) } mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/server"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) at(t, ctx, schemeStateJS, &got) if got.Theme != "" || got.Early != "" || got.Current != "system" { t.Fatalf("the server-owned page took the stored scheme: %+v", got) } if got.Stored != "dark" { t.Fatalf("the server-owned page touched storage on load: %+v", got) } chooseScheme(t, ctx, "light") at(t, ctx, schemeStateJS, &got) if got.Theme != "light" || got.Current != "light" || !got.Mark { t.Fatalf("after choosing Light on the server-owned page: %+v", got) } if got.Stored != "dark" { t.Errorf("the server-owned page wrote storage: fiesta-scheme is %q, want the standalone page's dark", got.Stored) } rig.Screen("body", "the server-owned page after Light") } // ── The scheme menu under axe ──────────────────────────────────────── // schemeMenuPages serves the scheme-menu example's standalone state // whole, through example.Document, so the that carries // data-rst-scheme-store="local" comes along with the menu. Its // relative asset links are rewritten to the flat names the fixture // server answers (parityLinks), and nothing else in the document is // touched. It takes over from rastrillo's server-backed schemeApp, // which stays in rastrillo with the tests that need a server. func schemeMenuPages(t *testing.T) map[string]string { t.Helper() state, ok := manifest(t).Default("scheme-menu") if !ok || state == "" { t.Fatal("examples.json names no default state for scheme-menu") } doc, err := example.Document(fiesta.FS(), "scheme-menu", state) if err != nil { t.Fatal(err) } if !strings.Contains(doc, `data-rst-scheme-store="local"`) { t.Fatalf("scheme-menu/%s is not the standalone state: its root carries no data-rst-scheme-store", state) } return map[string]string{"/": parityLinks(doc)} } // axeClean scans the whole page and reports every violation, each // with the nodes it names; what axe could not decide is logged, so // the seam between the scanned half of the standard and the reviewed // half is never silent. func axeClean(t *testing.T, ctx context.Context, where string) { t.Helper() res, err := axe.Scan(ctx, "", axe.DefaultTags()) if err != nil { t.Fatalf("%s: axe: %v", where, err) } if len(res.Incomplete) > 0 { t.Logf("%s: needs review, axe could not decide: %v", where, res.Incomplete) } for _, v := range res.Violations { t.Errorf("%s: %s [%s] %s: %s", where, v.ID, v.Impact, v.Help, strings.Join(v.Nodes, "; ")) } } // TestTheSchemeMenuPassesAxeInBothSchemes: the standalone scheme menu, // served with fiesta.js as the example links it, passes axe with the // menu open before a choice and after Dark is chosen in place. The // choice sets data-theme="dark" on the root and navigates nowhere (the // mark chooseScheme leaves on the window survives it). The root is // read first: without data-rst-scheme-store="local" this would be a // server-owned page, and the drive could not tell the two apart. The // first scan is light because a light preference is emulated and // checked, not because the browser happens to default to one. func TestTheSchemeMenuPassesAxeInBothSchemes(t *testing.T) { rig := sizingRig(t, false, schemeMenuPages(t)) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() light := emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}) mustRun(t, ctx, light, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery)) var dark bool mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(prefers-color-scheme: dark)").matches`, &dark)) if dark { t.Fatal("the page prefers dark, so the light scan would scan dark") } var store string mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.getAttribute("data-rst-scheme-store") || ""`, &store)) if store != "local" { t.Fatalf("the served document's root carries data-rst-scheme-store=%q, want local: this is not the standalone page", store) } var got schemeState at(t, ctx, schemeStateJS, &got) if got.Theme != "" || got.Stored != "" { t.Fatalf("first visit: %+v", got) } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery)) axeClean(t, ctx, "light, menu open") mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = false`, nil)) chooseScheme(t, ctx, "dark") at(t, ctx, schemeStateJS, &got) if got.Theme != "dark" || got.Current != "dark" || got.Open || !got.Mark { t.Fatalf("after choosing Dark in place: %+v", got) } mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery)) axeClean(t, ctx, "dark, menu open") rig.Screen("body", "the standalone scheme menu open in dark") } // ── A scheme flip lands (ui/scheme_browser_test.go) ────────────────── // schemeFlipBody is the page a scheme flip is read on: a sidebar's // current nav item and a linked media tile, the two things whose // background fades between schemes (0.2s and 0.25s, for the press // and the hover) while their text changes at once, and the switch's // own forms as an app renders them. const schemeFlipBody = `
Theme
` // schemeFlipState is the two backgrounds and the scheme as read on // one frame. type schemeFlipState struct { Theme string // data-theme on , "" for none Nav string // the current nav item's computed background Tile string // the linked media tile's computed background } // schemeFlipReadJS reads the state on the next animation frame. On // the next frame rather than now: a transition's first frame is its // start value, which is the same number the test would read without // a flip at all. const schemeFlipReadJS = `new Promise(r => requestAnimationFrame(() => r(JSON.stringify({ Theme: document.documentElement.getAttribute("data-theme") || "", Nav: getComputedStyle(document.getElementById("nav-cur")).backgroundColor, Tile: getComputedStyle(document.getElementById("tile")).backgroundColor }))))` // schemeFlipLandedJS waits out every finite animation, then reads the // same state: the value a reader sees once the page has settled. const schemeFlipLandedJS = `(async () => { await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); return await ` + schemeFlipReadJS + `; })()` // A scheme flip lands in the frame it happens in. The current nav // item and a linked tile fade their background for the press and the // hover, and a change of data-theme started the same fade while the // text snapped: for about a tenth of a second after the click, light // text sat on a still-light ground. Read on the very next frame, both // backgrounds already equal what they settle to. The same through the // OS flipping while the page is on System, where no form is involved. func TestASchemeFlipLandsOnTheNextFrame(t *testing.T) { rig := sizingRig(t, false, map[string]string{"/": sizingDoc("scheme flip", schemeFlipBody)}) ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second) defer cancel() os := func(scheme string) chromedp.Action { return emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: scheme}}) } mustRun(t, ctx, os("light"), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) // choose submits one of the switch's forms the way a click does, // and reads the state on the next frame, in one script so no // round trip sits between the two. choose := func(scheme string) schemeFlipState { t.Helper() var got schemeFlipState at(t, ctx, fmt.Sprintf(`(() => { document.querySelector('[rst-scheme] input[value=%q] ~ button').click(); return %s; })()`, scheme, schemeFlipReadJS), &got, awaitPromise) return got } landed := func(where string) schemeFlipState { t.Helper() var got schemeFlipState at(t, ctx, schemeFlipLandedJS, &got, awaitPromise) t.Logf("%s, landed: %+v", where, got) return got } check := func(where string, got, want schemeFlipState) { t.Helper() if got.Theme != want.Theme { t.Fatalf("%s: data-theme %q, want %q", where, got.Theme, want.Theme) } if got.Nav != want.Nav || got.Tile != want.Tile { t.Errorf("%s: on the next frame the nav item is %s and the tile %s; settled they are %s and %s. The flip faded instead of landing.", where, got.Nav, got.Tile, want.Nav, want.Tile) } } // Learn the settled values of each scheme first, so the test holds // the theme's own numbers rather than a copy of them. choose("dark") dark := landed("dark chosen") choose("light") light := landed("light chosen") if dark.Nav == light.Nav || dark.Tile == light.Tile { t.Fatalf("the page does not change with the scheme: dark %+v, light %+v", dark, light) } check("Light to Dark by the switch", choose("dark"), dark) landed("after Dark") check("Dark to Light by the switch", choose("light"), light) landed("after Light") // System, with the OS flipping underneath: the attribute is gone, // light-dark() repaints on its own, and no form is submitted. Read // on the second frame, not the next: Chromium finds the media // change in the style update that starts the fade, and reports it // to listeners at the start of the frame after, so the shim can // land an OS flip one frame late at best. Without it the second // frame is a tenth of the way into the fade, and still light. got := choose("system") if got.Theme != "" { t.Fatalf("after choosing System data-theme is %q", got.Theme) } landed("system, OS light") mustRun(t, ctx, os("dark")) at(t, ctx, schemeFlipSecondFrameJS, &got, awaitPromise) check("System, OS light to dark", got, schemeFlipState{Nav: dark.Nav, Tile: dark.Tile}) landed("system, OS dark") mustRun(t, ctx, os("light")) at(t, ctx, schemeFlipSecondFrameJS, &got, awaitPromise) check("System, OS dark to light", got, schemeFlipState{Nav: light.Nav, Tile: light.Tile}) } // TestASchemeFlipLandsWhileHovered is the hovered case of the flip // finding (rastrillo issue #104): a tile and a rail item under the // pointer, with their hover tint settled, when an app's own script // flips data-theme the way an app's switch does (no shipped switch, // so nothing cancels a transition). The hover tint used to be a // background-color transition, and a flip retargeted it: the one item // under the pointer faded for 0.2 to 0.25s while its text changed at // once. The tint now rides a registered percentage and is re-resolved // from the scheme every frame, so the very next frame's background is // the value the item settles to. Light to dark on the tile, dark to // light on the rail item, each with the mouse really over it. func TestASchemeFlipLandsWhileHovered(t *testing.T) { body := strings.Replace(schemeFlipBody, ``, ``, 1) rig := sizingRig(t, false, map[string]string{"/": sizingDoc("hovered scheme flip", body)}, harness.WithHover()) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) bg := func(id string) string { t.Helper() var got string mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.getElementById(`+jsStr(id)+`)).backgroundColor`, &got)) return got } settled := func(id string) string { t.Helper() var got string mustRun(t, ctx, chromedp.Evaluate(`(async () => { await Promise.all(document.getAnimations().filter(a => a.effect && a.effect.getComputedTiming().endTime !== Infinity).map(a => a.finished.catch(() => null))); return getComputedStyle(document.getElementById(`+jsStr(id)+`)).backgroundColor; })()`, &got, awaitPromise)) return got } for _, c := range []struct{ id, from, to string }{{"tile", "light", "dark"}, {"nav-x", "dark", "light"}} { mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", `+jsStr(c.from)+`), true`, nil)) settled(c.id) rest := bg(c.id) // The shell fills the viewport and the tile sits below it. mustRun(t, ctx, chromedp.Evaluate(`document.getElementById(`+jsStr(c.id)+`).scrollIntoView({block: "center"}), true`, nil)) x, y, w, h := rect(t, ctx, "#"+c.id) // Two frames after the move, so the hover's transition exists // before settled waits on it. mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, x+w/2, y+h/2), chromedp.Evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`, nil, awaitPromise)) hovered := settled(c.id) if hovered == rest { t.Fatalf("#%s in %s: hovered it is %s, as at rest; the hover did not take, so the drive would prove nothing", c.id, c.from, rest) } // The app's flip and the next frame's read in one script, so no // round trip sits between the write and the read. var next string mustRun(t, ctx, chromedp.Evaluate(`(() => { document.documentElement.setAttribute("data-theme", `+jsStr(c.to)+`); return new Promise(r => requestAnimationFrame(() => r(getComputedStyle(document.getElementById(`+jsStr(c.id)+`)).backgroundColor))); })()`, &next, awaitPromise)) want := settled(c.id) t.Logf("#%s hovered: %s in %s, %s a frame after the app set %s, settled %s", c.id, hovered, c.from, next, c.to, want) if want == hovered { t.Fatalf("#%s: the hover tint is %s in %s and in %s alike; the drive would prove nothing", c.id, hovered, c.from, c.to) } if next != want { t.Errorf("#%s hovered, app script sets data-theme=%s: on the next frame it is %s; settled it is %s. The flip faded instead of landing.", c.id, c.to, next, want) } mustRun(t, ctx, chromedp.MouseEvent(input.MouseMoved, 5, 5), chromedp.Sleep(300*time.Millisecond)) } } // schemeFlipSecondFrameJS reads the state one frame later than // schemeFlipReadJS, for the OS flip (see the test). const schemeFlipSecondFrameJS = `new Promise(r => requestAnimationFrame(() => r(` + schemeFlipReadJS + `)))` // TestAnAppsOwnSchemeSwitchLandsAtRest: the shipped switch // lands a flip by cancelling the running transitions (land(), above), // but an app that sets data-theme from its own script, or that took // the new stylesheets with an old fiesta.js, never calls it. The // nav items', linked rows' and tiles' background transitions sit on // their :hover and :active rules only, not at rest, so at rest a // scheme change has no transition to start and lands on the next // frame with no script's help. The page is the scheme-flip fixture; // the flip is an attribute write from page script, the way an app's // switch does it, read on the very next frame against the value the // page settles to. func TestAnAppsOwnSchemeSwitchLandsAtRest(t *testing.T) { rig := sizingRig(t, false, map[string]string{"/": sizingDoc("app scheme flip", schemeFlipBody)}) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() mustRun(t, ctx, emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}}), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery), chromedp.WaitReady(`body`, chromedp.ByQuery)) // set writes the attribute the way an app's own switch does and // reads the next frame, in one script so no round trip sits // between the write and the read. set := func(scheme string) schemeFlipState { t.Helper() var got schemeFlipState at(t, ctx, fmt.Sprintf(`(() => { document.documentElement.setAttribute("data-theme", %q); return %s; })()`, scheme, schemeFlipReadJS), &got, awaitPromise) return got } settled := func() schemeFlipState { t.Helper() var got schemeFlipState at(t, ctx, schemeFlipLandedJS, &got, awaitPromise) return got } set("dark") dark := settled() set("light") light := settled() if dark.Nav == light.Nav || dark.Tile == light.Tile { t.Fatalf("the page does not change with the scheme: dark %+v, light %+v", dark, light) } for _, c := range []struct { to string want schemeFlipState }{{"dark", dark}, {"light", light}} { got := set(c.to) if got.Theme != c.to { t.Fatalf("data-theme is %q after the app set %q", got.Theme, c.to) } if got.Nav != c.want.Nav || got.Tile != c.want.Tile { t.Errorf("app script sets data-theme=%s: on the next frame the nav item is %s and the tile %s; settled they are %s and %s. The flip faded instead of landing, with no shipped switch to cancel it.", c.to, got.Nav, got.Tile, c.want.Nav, c.want.Tile) } settled() } }