diff --git a/CHANGELOG.md b/CHANGELOG.md index d0aa58f8..e9c51031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -310,6 +310,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood (tidying) +- **Every health model, proven in a browser.** The second gap in e2e/COVERAGE.md is closed: a new + browser test plays each of the seven health models a system can choose, one at a time. A player + with a token hurts and heals it from HIT_POINTS (a pool under its own name, two tracks where + damage past the second spills into the first, damage types marked lightly and heavily, harm + levels where a full level moves harm up, a wound count down to INCAPACITATED, hit locations with + their notes, and none, which says harm is conditions). The GM sets the second track's maximum. + Another player watches from the chat's user list and sees only the picture: the heartbeat, a bar, + the worst harm's name, which place is hurt. The test also reads what the server sends that + player, and it never holds a number or a note. - **Importing a list of choices, the server's half (choices import 1).** The server can now read a file of a list's options and their features (`[options]` name, description, requires; `[features]` option, feature, level, words, uses, refill) against the draft and say what it diff --git a/README.md b/README.md index 39afd795..61529d03 100644 --- a/README.md +++ b/README.md @@ -1268,6 +1268,7 @@ CITY_NET/ │ ├── graph-shots.spec.ts # Only with GRAPH_SHOTS=1: pictures of the graph view (CWN open, fitted, each theme) into docs/mockups/shots, to look at before a PR that changes how it looks │ ├── builder-journey.spec.ts # The GM builds a system from + NEW through the builder's screens, publishes it, publishes v2 through the version preview, and switches the game to it; from Phase 6 a save with a target whose players see only the verdict, rolled by a player from their sheet, the GM told more │ ├── player-journey.spec.ts # A custom system played in two browsers: a player makes a character in its words and numbers; the GM types in a catalogue, gives them an item and calls a rest; the player sees both +│ ├── health-models.spec.ts # Every health model at the table: a player hurts and heals their token from HIT_POINTS under each of the seven, the GM sets a second track's maximum, another player watches from GLOBAL_CHAT and is sent no number or note (coverage family 2) │ ├── sheet-choices.spec.ts # A custom system's CHOICES block at the table (7a3b1): a player's class not met off with what it needs, SOLDIER picked with its features and its bonus on Defense, raised to level 2 unlocking the next feature, a use ticked off and given back by a short rest the GM calls; as the server keeps it; the GM on their own sheet adding an option not met, the bonus named on Defense, and a pick taken away, asked first (7a3b2) │ ├── sheet-visibility.spec.ts # Who sees what on a customized sheet: the owner (the GM's field read-only), another player looking them up from GLOBAL_CHAT (public lines only, nothing else sent), the GM opening the whole sheet the same way and setting the GM's field (coverage family 1) │ └── builder-smoke.spec.ts # The GM signs in, opens the System Builder, meets and skips the first-time tour (reset first), finds MY SYSTEMS, and goes back to the map diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md index 636473df..5f87279a 100644 --- a/e2e/COVERAGE.md +++ b/e2e/COVERAGE.md @@ -38,7 +38,7 @@ panel), so a control that looks fine but does nothing is caught for all of them. | Option | Variants | Server tests | Browser test | Status | |---|---|---|---|---| -| Health model | pool, two tracks, typed, harm levels, wounds, hit locations, none | `health`, `health_view`, `parts_health`, `try_health` | `player-journey` and `sheet-rolls` run a pool, never hurt or healed | **GAP**: no model damaged, healed or shown at the table | +| Health model | pool, two tracks, typed, harm levels, wounds, hit locations, none | `health`, `health_view`, `parts_health`, `try_health` | `health-models` (each one hurt and healed by its owner from HIT_POINTS, the GM setting a second track's maximum, another player watching from the chat; what that player is sent checked for numbers and notes; a model needing a sheet refused without one) | **AT THE TABLE** | | Advancement | XP levels, milestone, spend XP, improve by use | `core` | | **GAP**, comes alive with 7d | | Dice the game uses, distance unit | | `core` | | **GAP** | @@ -131,7 +131,7 @@ panel), so a control that looks fine but does nothing is caught for all of them. Fitted into Phase 7, one family between each of its pieces, riskiest first: 1. ~~**Sheet visibility**~~: done, `sheet-visibility.spec.ts`. It also gave the journeys a way to reach another player's token without the 3D map: GLOBAL_CHAT, their name, VIEW_PLAYER_INFO. -2. **Health models**: each model damaged, healed and shown at the table, and what other players see. +2. ~~**Health models**~~: done, `health-models.spec.ts`. It gave the journeys a token on the map without the 3D map too: the GM posts one to /api/locations, and HIT_POINTS opens it. 3. **Parts switched off**: each part hidden at the table while off, and back when on. 4. **House rules at the table**: switched in the GAME tab, and what waits on them shown and hidden. 5. **Conditions on tokens**: put on, a modifier reaching a stat, a formula and every roll, ending after rounds and at a rest. diff --git a/e2e/README.md b/e2e/README.md index fdd35402..fddf8d2b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -146,6 +146,7 @@ frontend suites. | `tests/dice-tray.spec.ts` | DICE_TRAY.EXE's new dice (6c4b2b): a handful of d4 to d20 rolled from the dice menu, the throw naming every die and the face it landed on (what it shows, less one); a running system's own dice (a drawn SKULL, a bad die) rolled by a player, each landing on a face showing what the log wrote, the bad die in the danger color; no errors on the page (with GRAPH_SHOTS=1, the tray early in the throw, mid-throw and landed); and the title bar's ANIMATION toggle (6c4b3b): turned off, still off after a reload and a fresh sign-in (kept on the account), turned back on; and no queue (6c4c): two quick rolls from one person sharing the server's group and landing as one throw, then a roll a moment later cutting in with the two before already in the record and its own not yet; and the tray in the theme picked: in Oceanic, its board's lit dots and its record are cyan, not Classic's green (it once sat outside the app's theme); and the board (6c4e): rolling while the dice are thrown, then the total in big digits once they are down; someone else's roll flashing a player's shut tray buttons, one rail click opening the tray on its history, and the bell (right of ANIMATION) turning that off (6c4g) | | `tests/sheet-rolls.spec.ts` | A custom system's own roll at the table (6b2a): a published system running as the game with a roll rolled by a stats group; a player's sheet showing a die button by each stat in it, and a click rolled by the server from the stored sheet (read from what it broadcasts, the line the log shows); a stat with two rolls asking first, GRIT SAVE with ADVANTAGE picked in the chooser, two d20 the higher kept; PUSH adding 2 Stress (6b3a), the chooser showing 1 OF 4, the roll paying it and the sheet showing 3, then off at 3 OF 4; and a running system's own dice in the dice menu (6c4a): listed under THIS GAME'S DICE, two Good and a Bad rolled by the server, each face as the log writes it | | `tests/sheet-choices.spec.ts` | A custom system's CHOICES block at the table (7a3b1): a player picks a class on their sheet (one they don't meet is off, saying what it needs), its bonus reaches Defense, a level up unlocks the next feature, a use ticked off comes back with a short rest the GM calls; and the GM on their own sheet adds an option they don't meet (ADD ANYWAY), sees Guard Stance's bonus named on Defense, and takes a pick away, asked first (7a3b2) | +| `tests/health-models.spec.ts` | Every health model at the table (coverage family 2): for each, a system running it and a player's token; the player hurts and heals it from HIT_POINTS (a pool's own name; two tracks spilling over, the GM setting the second's maximum; damage types light and heavy; harm levels moving up, refused without a sheet; a wound count to INCAPACITATED; hit locations with notes; none), and another player watches from GLOBAL_CHAT (VIEW_PLAYER_INFO, HEALTH): the heartbeat's band, a bar, the worst harm's name, which place is hurt, and the frames they are sent hold no number or note | | `tests/sheet-visibility.spec.ts` | Who sees what on a customized sheet (coverage family 1): its owner fills in a public field, a private one and a combat value and can't change the GM's field; another player, looking them up from GLOBAL_CHAT's user list (VIEW_PLAYER_INFO), sees only the public lines, and the server sends nothing else; the GM opens the whole sheet the same way (OPEN_SHEET) and sets the GM's field, which the owner then sees | | `tests/player-journey.spec.ts` | A custom system running (set up through the API), played in two browsers at once: a player makes a character and sees the system's words and its formula worked out; the GM types in a catalogue, gives the player something from it with a note, and calls a rest from the GAME tab; the player sees the gift arrive and the rest change their sheet | | `tests/builder-smoke.spec.ts` | The GM signs in, opens the System Builder, meets and skips the first-time tour (reset first, so it always shows), and goes back to the map | diff --git a/e2e/tests/health-models.spec.ts b/e2e/tests/health-models.spec.ts new file mode 100644 index 00000000..411a3316 --- /dev/null +++ b/e2e/tests/health-models.spec.ts @@ -0,0 +1,227 @@ +import { test, expect, type Page, type APIRequestContext, type Browser, type BrowserContext, type TestInfo } from '@playwright/test'; +import { signInAsGm, signInAsPlayer, gmToken, asGm, runningSystem, approvedPlayer } from './helpers'; + +// Every health model at the table (coverage family 2, e2e/COVERAGE.md; the models from 2g2, the +// HEALTH folder from the approved mockup docs/mockups/health-windows.html): for each, a custom system +// running it, a player's token on the map, the player hurting and healing it from HIT_POINTS, and +// another player watching from the chat's user list (VIEW_PLAYER_INFO, HEALTH): a picture, never a +// number or a note, and what their browser is sent holds none either. + +/** The players' own browser windows, closed after each test. */ +const opened: BrowserContext[] = []; +type Frame = Record; + +/** Every health view the server sends this page, as it sends it. */ +const viewsSeenBy = (page: Page, frames: Frame[]) => { + page.on('websocket', (ws) => ws.on('framereceived', (f) => { + const m = /^42\["healthView",(.*)\]$/s.exec(String(f.payload)); + if (m) frames.push(JSON.parse(m[1])); + })); + return frames; +}; + +/** Someone looks a player up the way the app offers, and opens HEALTH. */ +const watch = async (page: Page, username: string) => { + await page.getByRole('button', { name: 'GLOBAL_CHAT', exact: true }).click(); + await page.getByText(username, { exact: true }).first().click(); + await page.getByRole('button', { name: 'VIEW_PLAYER_INFO', exact: true }).click(); + await page.getByRole('tab', { name: /HEALTH/ }).click(); + return page.getByRole('tabpanel'); +}; + +/** + * A player's sheet, made as a player makes it: opened, a name typed, closed again. The models that + * keep anything past the token's numbers keep it there. + */ +const giveSheet = async (page: Page, request: APIRequestContext, token: string, username: string) => { + await page.getByRole('button', { name: 'CHARACTER_CONTROLS', exact: true }).click(); + await page.getByRole('button', { name: 'CHARACTER_SHEET', exact: true }).click(); + await page.getByLabel('Name', { exact: true }).fill('Rook'); + await expect.poll(async () => (await asGm(request, token, 'get', `/api/sheets/user/${username}`).catch(() => null))?.data?.name).toBe('Rook'); + // Closed with its own ×: the nearest window round the Name box. + await page.getByLabel('Name', { exact: true }) + .locator('xpath=ancestor::*[.//button[contains(@class, "win95-close-btn")]][1]//button[contains(@class, "win95-close-btn") and text()="×"]').click(); + await expect(page.getByLabel('Name', { exact: true })).toBeHidden(); +}; + +/** One model: its system running, the owner's token (and sheet), both players signed in, the owner on HIT_POINTS. */ +const atTheTable = async (browser: Browser, request: APIRequestContext, info: TestInfo, model: string, health: object, hp: [number, number], { sheet = true } = {}) => { + const again = info.retry ? `_${info.retry + 1}` : ''; + const ownerName = `e2e_${model}_hurt${again}`; + const lookerName = `e2e_${model}_look${again}`; + const token = await gmToken(request); + await runningSystem(request, token, { format: 1, name: `Health ${model}${again}`, core: { health } }); + await approvedPlayer(request, token, { username: ownerName, password: 'e2e-player-password' }); + await approvedPlayer(request, token, { username: lookerName, password: 'e2e-player-password' }); + await asGm(request, token, 'post', '/api/locations', { + name: ownerName, description: '', shape: 'rhombus', owner: ownerName, x: 0, y: 0, z: 0, width: 1, height: 1, depth: 1, + hp_current: hp[0], hp_max: hp[1], hp_temp: 0, battle_map_id: -1, floor_index: -1, + }); + const open = async (username: string, frames?: Frame[]) => { + const context = await browser.newContext({ baseURL: info.project.use.baseURL }); + opened.push(context); + const page = await context.newPage(); + // Listening from the start: the socket opens as they sign in. + if (frames) viewsSeenBy(page, frames); + await signInAsPlayer(page, { username, password: 'e2e-player-password' }); + return page; + }; + const owner = await open(ownerName); + if (sheet) await giveSheet(owner, request, token, ownerName); + await owner.getByRole('button', { name: 'HIT_POINTS', exact: true }).click(); + await expect(owner.getByRole('tab', { name: /HEALTH/ })).toHaveAttribute('aria-selected', 'true'); + const frames: Frame[] = []; + const looker = await open(lookerName, frames); + const watching = await watch(looker, ownerName); + return { owner, ownerPanel: owner.getByRole('tabpanel'), looker, watching, frames, ownerName, token, giveSheet: () => giveSheet(owner, request, token, ownerName) }; +}; + +const monitor = (panel: ReturnType) => panel.getByRole('img', { name: /^Heart monitor/ }); +/** The looker's views of the owner's token, none of them in full. */ +const othersOnly = (frames: Frame[]) => { + expect(frames.length).toBeGreaterThan(0); + for (const f of frames) expect(f.full).toBe(false); +}; + +test.describe('every health model, hurt, healed and watched', () => { + test.setTimeout(180_000); + test.afterEach(async () => { await Promise.all(opened.splice(0).map((c) => c.close())); }); + + test('one pool, under its own name', async ({ browser, request }, info) => { + const t = await atTheTable(browser, request, info, 'pool', { model: 'pool', label: 'VIGOR' }, [10, 10]); + await expect(t.ownerPanel.getByText('VIGOR', { exact: true })).toBeVisible(); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('6'); + await t.ownerPanel.getByRole('button', { name: 'DAMAGE', exact: true }).click(); + await expect(t.ownerPanel.getByText('4 / 10')).toBeVisible(); + // Watched: a faster heartbeat, and no number. + await expect(monitor(t.watching)).toHaveAttribute('data-band', 'fast'); + await expect(t.watching).not.toContainText('/ 10'); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('3'); + await t.ownerPanel.getByRole('button', { name: 'HEAL', exact: true }).click(); + await expect(t.ownerPanel.getByText('7 / 10')).toBeVisible(); + await expect(monitor(t.watching)).toHaveAttribute('data-band', 'steady'); + othersOnly(t.frames); + for (const f of t.frames) expect(Object.keys(f).sort()).toEqual(['full', 'label', 'location_id', 'model']); + }); + + test('two tracks: the GM sets the second\'s maximum, and damage past it spills into the first', async ({ browser, request, page }, info) => { + const t = await atTheTable(browser, request, info, 'tracks', { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }, [10, 10]); + // The GM, from the same lookup: the maximum of STUN. + await signInAsGm(page); + const gm = await watch(page, t.ownerName); + await gm.getByRole('group', { name: 'Track' }).getByRole('button', { name: 'STUN' }).click(); + await gm.getByRole('spinbutton', { name: 'MAX STUN' }).fill('4'); + await gm.getByRole('button', { name: 'SET', exact: true }).first().click(); + await expect(gm.getByText('0 / 4')).toBeVisible(); + + await t.ownerPanel.getByRole('group', { name: 'Track' }).getByRole('button', { name: 'STUN' }).click(); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('6'); + await t.ownerPanel.getByRole('button', { name: 'DAMAGE', exact: true }).click(); + await expect(t.ownerPanel.getByRole('status')).toHaveText('STUN FULL · 2 SPILLED INTO PHYSICAL'); + await expect(t.ownerPanel.getByText('8 / 10')).toBeVisible(); + // Watched: STUN full as a bar, PHYSICAL as the heartbeat. + await expect(t.watching.getByTestId('second-track')).toContainText('STUN — FULL · OVERFLOW → PHYSICAL'); + await expect(monitor(t.watching)).toHaveAttribute('data-band', 'steady'); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('3'); + await t.ownerPanel.getByRole('button', { name: 'HEAL', exact: true }).click(); + await expect(t.ownerPanel.getByText('1 / 4 · OVERFLOW → PHYSICAL')).toBeVisible(); + await expect(t.watching.getByTestId('second-track')).not.toContainText('FULL'); + othersOnly(t.frames); + for (const f of t.frames) expect(JSON.stringify(f.second ?? {})).not.toMatch(/"(current|max)"/); + }); + + test('damage types on one track: boxes marked lightly and heavily', async ({ browser, request }, info) => { + const t = await atTheTable(browser, request, info, 'typed', { model: 'typed', types: [{ id: 'superficial', label: 'SUPERFICIAL' }, { id: 'aggravated', label: 'AGGRAVATED' }] }, [4, 4]); + const type = t.ownerPanel.getByRole('group', { name: 'Damage type' }); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('2'); + await t.ownerPanel.getByRole('button', { name: 'DAMAGE', exact: true }).click(); + await expect(t.ownerPanel.getByText('2 OF 4 BOXES CLEAR')).toBeVisible(); + await type.getByRole('button', { name: 'AGGRAVATED' }).click(); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('1'); + await t.ownerPanel.getByRole('button', { name: 'DAMAGE', exact: true }).click(); + await expect(t.ownerPanel.getByText('1 OF 4 BOXES CLEAR')).toBeVisible(); + await expect(t.ownerPanel.getByRole('img', { name: 'Health boxes' })).toHaveText('X//'); + // Watched: light against heavy, as a bar. + const split = t.watching.getByTestId('damage-split'); + await expect(split).toContainText('DAMAGE TAKEN'); + await expect.poll(() => split.getByTestId('heavy-fill').evaluate((el) => (el as HTMLElement).style.width)).toBe('25%'); + await type.getByRole('button', { name: 'SUPERFICIAL' }).click(); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('2'); + await t.ownerPanel.getByRole('button', { name: 'HEAL', exact: true }).click(); + await expect(t.ownerPanel.getByText('3 OF 4 BOXES CLEAR')).toBeVisible(); + othersOnly(t.frames); + for (const f of t.frames) expect(f.types).toBeUndefined(); + }); + + test('harm levels: a full level moves harm up, and others see only the worst level\'s name', async ({ browser, request }, info) => { + const t = await atTheTable(browser, request, info, 'harm', { model: 'harm', levels: [ + { id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Reduced effect' }, + { id: 'moderate', label: 'MODERATE', slots: 1, penalty: '-1d' }, + { id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Need help' }, + ] }, [4, 4], { sheet: false }); + // Notes are kept on the sheet: with none yet, the server says so and nothing is taken. + await t.ownerPanel.getByRole('textbox', { name: 'Harm note' }).fill('Bruised ribs'); + await t.ownerPanel.getByRole('button', { name: 'TAKE HARM' }).click(); + await expect(t.ownerPanel.getByRole('status')).toHaveText('This token has no sheet to keep that on. Give it one first.'); + await t.giveSheet(); + for (const note of ['Bruised ribs', 'Cut hand', 'Twisted ankle']) { + await t.ownerPanel.getByRole('textbox', { name: 'Harm note' }).fill(note); + await t.ownerPanel.getByRole('button', { name: 'TAKE HARM' }).click(); + await expect(t.ownerPanel.getByText(note)).toBeVisible(); + } + await expect(t.ownerPanel.getByRole('status')).toHaveText('LESSER FULL · MOVED UP TO MODERATE'); + // Watched: the worst level's name, never the notes. + await expect(t.watching.getByText('MODERATE HARM')).toBeVisible(); + await expect(monitor(t.watching)).toHaveAttribute('data-band', 'fast'); + await expect(t.watching).not.toContainText('Twisted'); + await t.ownerPanel.getByRole('button', { name: 'Clear Twisted ankle' }).click(); + await expect(t.watching.getByText('LESSER HARM')).toBeVisible(); + othersOnly(t.frames); + for (const f of t.frames) expect(JSON.stringify(f)).not.toMatch(/Bruised|Cut hand|Twisted/); + }); + + test('a wound count: out after the last, and others see WOUNDED, then INCAPACITATED', async ({ browser, request }, info) => { + const t = await atTheTable(browser, request, info, 'wounds', { model: 'wounds', count: 3, penalty: -1 }, [3, 3]); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('1'); + await t.ownerPanel.getByRole('button', { name: 'TAKE WOUND' }).click(); + await expect(t.ownerPanel.getByText('2 OF 3 WOUNDS LEFT')).toBeVisible(); + await expect(t.ownerPanel.getByText('PENALTY -1')).toBeVisible(); + await expect(t.watching.getByText('WOUNDED', { exact: true })).toBeVisible(); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('2'); + await t.ownerPanel.getByRole('button', { name: 'TAKE WOUND' }).click(); + await expect(t.ownerPanel.getByRole('status')).toHaveText('OUT OF WOUNDS · INCAPACITATED'); + await expect(t.watching.getByText('INCAPACITATED', { exact: true })).toBeVisible(); + await expect(monitor(t.watching)).toHaveAttribute('data-band', 'down'); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('3'); + await t.ownerPanel.getByRole('button', { name: 'HEAL', exact: true }).click(); + await expect(t.watching.getByText('UNHURT', { exact: true })).toBeVisible(); + othersOnly(t.frames); + for (const f of t.frames) expect(f.penalty).toBeUndefined(); + }); + + test('hit locations: a hit with its note, and others see only which place is hurt', async ({ browser, request }, info) => { + const t = await atTheTable(browser, request, info, 'locations', { model: 'locations', locations: [{ id: 'head', label: 'HEAD' }, { id: 'body', label: 'BODY' }, { id: 'legs', label: 'LEGS' }] }, [20, 20]); + await t.ownerPanel.getByRole('combobox', { name: 'Where it hit' }).selectOption('head'); + await t.ownerPanel.getByRole('textbox', { name: 'Location note' }).fill('Concussed'); + await t.ownerPanel.getByRole('spinbutton', { name: 'Amount' }).fill('5'); + await t.ownerPanel.getByRole('button', { name: 'DAMAGE', exact: true }).click(); + await expect(t.ownerPanel.getByText('15 / 20')).toBeVisible(); + await expect(t.ownerPanel.getByText('Concussed')).toBeVisible(); + // Watched: HEAD marked, the others not, and no note. + await expect(t.watching.locator('[data-hit]')).toHaveText(['HEAD', 'BODY', 'LEGS']); + await expect(t.watching.locator('[data-hit="yes"]')).toHaveText(['HEAD']); + await expect(t.watching).not.toContainText('Concussed'); + await t.ownerPanel.getByRole('button', { name: 'Clear HEAD' }).click(); + await expect(t.watching.locator('[data-hit="yes"]')).toHaveCount(0); + othersOnly(t.frames); + for (const f of t.frames) expect(JSON.stringify(f)).not.toContain('Concussed'); + }); + + test('none: harm is conditions, and the folder says so to both', async ({ browser, request }, info) => { + const t = await atTheTable(browser, request, info, 'none', { model: 'none' }, [10, 10]); + await expect(t.ownerPanel.getByText('THIS SYSTEM TRACKS HARM AS CONDITIONS, NOT HEALTH.')).toBeVisible(); + await expect(t.ownerPanel.getByRole('button', { name: 'DAMAGE', exact: true })).toHaveCount(0); + await expect(t.watching.getByText('NO HEALTH TRACKED.')).toBeVisible(); + await expect(monitor(t.watching)).toHaveCount(0); + }); +});