Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions VueApp/src/Personnel/__tests__/phone-list-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { mount } from "@vue/test-utils"
import { Quasar } from "quasar"
import PhoneListFilter from "../components/PhoneListFilter.vue"

function mountFilter(search = "") {
function mountFilter(search = "", noMatches?: boolean) {
return mount(PhoneListFilter, {
props: { modelValue: search },
props: { modelValue: search, noMatches },
global: { plugins: [Quasar] },
})
}
Expand Down Expand Up @@ -38,19 +38,47 @@ describe("phoneListFilter.vue", () => {
it("carries the class the sticky rule is keyed to", () => {
expect.hasAssertions()

// The pinning itself is a media query, which jsdom does not apply; this only guards the
// hook it is attached to. Whether it looks right on a phone still needs a real browser.
// The pinning itself is CSS, which these tests do not load; this only guards the hook it
// is attached to. Whether it looks right still needs a real browser, at desktop and phone
// widths.
expect(mountFilter().find(".phone-list-filter").exists()).toBeTruthy()
})

it("publishes its own height, which the scroll-margin offset is calculated from", () => {
it("publishes its own height, which the page's scroll padding is calculated from", () => {
expect.hasAssertions()
document.documentElement.style.removeProperty("--phone-list-filter-height")

mountFilter()

// Jsdom reports 0 for every measurement, so this guards that the variable is published at
// all, not what it comes to. The value itself is only meaningful in a real browser.
// Happy-dom does no layout and reports 0 for every measurement, so this guards that the
// variable is published at all, not what it comes to. The value itself is only meaningful
// in a real browser.
expect(document.documentElement.style.getPropertyValue("--phone-list-filter-height")).toBe("0px")
})
})

describe("phoneListFilter.vue - no matches line", () => {
it("names the search that matched nothing", () => {
expect.hasAssertions()

expect(mountFilter("smith", true).find("[role='status']").text()).toBe('No records match "smith".')
})

it("says nothing while the search has matches", () => {
expect.hasAssertions()

expect(mountFilter("smith", false).find("[role='status']").text()).toBe("")
})

it("says nothing when the page does not set it, as the maintain pages do not", () => {
expect.hasAssertions()

expect(mountFilter("smith").find("[role='status']").text()).toBe("")
})

it("keeps the live region rendered while it is silent, so its first message is announced", () => {
expect.hasAssertions()

expect(mountFilter("smith", false).find("[role='status']").exists()).toBeTruthy()
})
})
54 changes: 51 additions & 3 deletions VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getPhoneListData } from "../composables/phone-list-data-fetch.ts"
import { phoneListService } from "../services/phone-list-service.ts"
import { phoneListUnitService } from "../services/phone-list-unit-service.ts"
import type { PhoneListUnit } from "../types/phone-list-phone-types"
import { apiError, apiResult } from "./test-utils"
import { apiError, apiResult, linkTargets, openJumpLinks, trackAttached, unmountAttached } from "./test-utils"

/**
* PhoneListMaintain shows a StatusBanner only when a delete/save action reports an error
Expand Down Expand Up @@ -90,15 +90,20 @@ const personSelectorStub = {
template: "<div class='selector-stub' />",
}

function mountPage() {
return mount(PhoneListMaintain, {
/** Pass attach to mount into the document, which the jump links' portalled menu needs. */
function mountPage(attach = false) {
const wrapper = mount(PhoneListMaintain, {
global: {
plugins: [[Quasar, { plugins: { Notify } }]],
stubs: { PersonSelector: personSelectorStub },
},
...(attach ? { attachTo: document.body } : {}),
})
return attach ? trackAttached(wrapper) : wrapper
}

afterEach(unmountAttached)

function findAddButton(wrapper: ReturnType<typeof mountPage>) {
return wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "add")
}
Expand Down Expand Up @@ -242,3 +247,46 @@ describe("phoneListMaintain.vue - error banner", () => {
expect(vi.mocked(getPhoneListData).mock.calls.length).toBeGreaterThan(callsBeforeSave)
})
})

describe("phoneListMaintain.vue - unit jump links", () => {
async function mountWithTwoUnits() {
stubListInfo()
vi.mocked(getPhoneListData).mockResolvedValue([
unitWithDeletableRow(),
{ ...unitWithDeletableRow(), id: 11, name: "Business Office" },
])
const wrapper = mountPage(true)
await flushPromises()
return wrapper
}

it("links to every unit", async () => {
expect.hasAssertions()
const wrapper = await mountWithTwoUnits()

await expect(linkTargets(wrapper)).resolves.toStrictEqual(["Dean's Office", "Business Office"])
})

it("keeps linking to units a search has emptied, since they stay shown with their add button", async () => {
expect.hasAssertions()
const wrapper = await mountWithTwoUnits()

await wrapper.findComponent({ name: "QInput" }).setValue("nobody")

await expect(linkTargets(wrapper)).resolves.toStrictEqual(["Dean's Office", "Business Office"])
})

it("points each link at the heading of its own unit", async () => {
expect.hasAssertions()
const wrapper = await mountWithTwoUnits()
const links = await openJumpLinks(wrapper)

expect(links).toHaveLength(2)

for (const link of links) {
const id = link.getAttribute("href")!.slice(1)

expect(wrapper.find(`h2#${id}`).exists()).toBeTruthy()
}
})
})
108 changes: 103 additions & 5 deletions VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mount } from "@vue/test-utils"
import { Quasar } from "quasar"
import { Quasar, Screen } from "quasar"
import PhoneListUnitTable from "../components/PhoneListUnitTable.vue"
import type { PhoneListDisplayRecord, PhoneListUnit } from "../types/phone-list-phone-types"

Expand Down Expand Up @@ -50,16 +50,20 @@ function makeUnit(rows: PhoneListDisplayRecord[]): PhoneListUnit {
return { name: "Dean's Office", id: 10, cols, rows }
}

function mountTable(props: { unit: PhoneListUnit; loading: boolean; isMaintain: boolean; search: string }) {
function mountTable(props: {
unit: PhoneListUnit
loading: boolean
isMaintain: boolean
search: string
anchorId?: string
}) {
return mount(PhoneListUnitTable, {
props,
global: { plugins: [Quasar] },
})
}

// QTable always renders a .q-table__title element from its own :title prop, so it can't
// distinguish the custom top-left slot (the "add" button) from the base title - check for the
// button itself instead.
// The add button is found by its icon: its only text is an aria-label, so there is no label to find it by.
function hasAddButton(wrapper: ReturnType<typeof mountTable>): boolean {
return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add")
}
Expand Down Expand Up @@ -106,6 +110,19 @@ describe("phoneListUnitTable.vue - isMaintain gating", () => {
expect(wrapper.text()).toContain("Smith, Amy")
})

it("keeps the add button out of the unit headings, so its label is not part of their name", () => {
expect.hasAssertions()
const wrapper = mountTable({ unit: makeUnit([makeRow()]), loading: false, isMaintain: true, search: "" })
const headings = wrapper.findAll("h2")

// One for the desktop table, one for the mobile list.
expect(headings).toHaveLength(2)
for (const heading of headings) {
expect(heading.text()).toBe("Dean's Office")
expect(heading.find("button").exists()).toBeFalsy()
}
})

it("shows a check icon only on the row where listFirst is true", () => {
expect.hasAssertions()
const wrapper = mountTable({
Expand All @@ -123,6 +140,87 @@ describe("phoneListUnitTable.vue - isMaintain gating", () => {
})
})

/** Whether the desktop table and its heading are left showing, rather than hidden by v-show. */
function desktopShown(wrapper: ReturnType<typeof mountTable>): boolean {
return (wrapper.find(".gt-sm").element as HTMLElement).style.display !== "none"
}

describe("phoneListUnitTable.vue - desktop table visibility", () => {
it("shows the table while the search matches one of its rows", () => {
expect.hasAssertions()
const wrapper = mountTable({ unit: makeUnit([makeRow()]), loading: false, isMaintain: false, search: "smith" })

expect(desktopShown(wrapper)).toBeTruthy()
})

it("hides the table once a search leaves it with no rows, as the list does", () => {
expect.hasAssertions()
const wrapper = mountTable({
unit: makeUnit([makeRow()]),
loading: false,
isMaintain: false,
search: "no match",
})

expect(desktopShown(wrapper)).toBeFalsy()
})

it("hides a unit with no rows before any search", () => {
expect.hasAssertions()
const wrapper = mountTable({ unit: makeUnit([]), loading: false, isMaintain: false, search: "" })

expect(desktopShown(wrapper)).toBeFalsy()
})

it("keeps the emptied table in maintain mode, where it carries the add button", () => {
expect.hasAssertions()
const wrapper = mountTable({
unit: makeUnit([makeRow()]),
loading: false,
isMaintain: true,
search: "no match",
})

expect(desktopShown(wrapper)).toBeTruthy()
})
})

describe("phoneListUnitTable.vue - jump link anchor", () => {
const initialLtMd = Screen.lt.md

afterEach(() => {
Screen.lt.md = initialLtMd
})

function anchoredHeadings(ltMd: boolean) {
Screen.lt.md = ltMd
const wrapper = mountTable({
unit: makeUnit([makeRow()]),
loading: false,
isMaintain: false,
search: "",
anchorId: "phone-unit-10",
})
return wrapper.findAll("h2#phone-unit-10")
}

it("anchors the desktop heading at desktop widths, where the mobile one is hidden", () => {
expect.hasAssertions()
const headings = anchoredHeadings(false)

expect(headings).toHaveLength(1)
expect(headings[0].element.closest(".gt-sm")).not.toBeNull()
})

it("anchors the mobile heading below 1024px, where the desktop one is hidden", () => {
expect.hasAssertions()
const headings = anchoredHeadings(true)

expect(headings).toHaveLength(1)
expect(headings[0].element.closest(".lt-md")).not.toBeNull()
})
})

/** The mobile list, which renders alongside the table and is shown by CSS below 1024px. */
function listItems(wrapper: ReturnType<typeof mountTable>) {
return wrapper.findComponent({ name: "QList" }).findAllComponents({ name: "QItem" })
Expand Down
Loading
Loading