carry-forward.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import { expect, type Locator, type Page, type Request, test } from '@playwright/test'
  2. import {
  3. certificatePdf,
  4. conditionMap,
  5. earlierSection,
  6. markEditor,
  7. openDropoff,
  8. ownMarks,
  9. printsMap,
  10. workOrderPdf,
  11. } from '../../support/condition-map'
  12. import { type CarryMarkRow, marksWithCarry, setMarkPhotos } from '../../support/condition-map-db'
  13. import {
  14. ownerOrganizationId,
  15. plantConditionInspection,
  16. plantConditionJob,
  17. plantConditionMark,
  18. plantJobOnVehicle,
  19. userIdFor,
  20. } from '../../support/db'
  21. import { settle } from '../../support/hydration'
  22. import { TINY_PNG } from '../../support/pdf'
  23. /**
  24. * "Confirm all as still there": the marks still open from earlier visits,
  25. * listed grey under the map, recorded again on this sheet as this visit's
  26. * own in one press. Each earlier mark is closed in the same go and points at
  27. * the mark that took its place (`carriedToId`), so:
  28. *
  29. * - this sheet now holds them in colour, with the note and photos they had;
  30. * - the earlier sheet still shows and prints them as its own record, read
  31. * only, since the newer copy is the one that changes now;
  32. * - the vehicle meets the dent once: a newer visit sees the copies, never
  33. * the originals as well;
  34. * - pressing it again finds nothing left to take.
  35. *
  36. * Every test plants a car of its own, with the earlier job a month back and
  37. * the job doing the confirming ten days back, so a third job opened today is
  38. * the newest visit.
  39. */
  40. const stamp = Date.now()
  41. const DAY = 24 * 60 * 60 * 1000
  42. /** The grey every earlier visit's mark is drawn in (PREVIOUS_MARK_COLOR). */
  43. const GREY = '#9ca3af'
  44. const CARRIED_TEXT =
  45. "Confirmed again on a later visit. It stays here as this visit's record and is changed there."
  46. // Each test opens three or four pages and reads PDFs; a minute is tight.
  47. test.describe.configure({ timeout: 180_000 })
  48. let organizationId = ''
  49. let userId = ''
  50. test.beforeAll(async () => {
  51. organizationId = await ownerOrganizationId()
  52. userId = await userIdFor('demo@torqvoice.com')
  53. })
  54. const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
  55. `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
  56. /** Every mark drawn on the map itself (the legend's icons carry no role). */
  57. const glyphs = (map: Locator) => map.locator('svg[role="img"] g[role="button"]')
  58. /** The ones drawn grey: an earlier visit's. */
  59. const greyGlyphs = (map: Locator) =>
  60. glyphs(map).filter({ has: map.page().locator(`[stroke="${GREY}"]`) })
  61. /** The buttons that open a mark listed under "Recorded on an earlier visit". */
  62. const earlierMarks = (map: Locator) =>
  63. earlierSection(map).getByRole('button', { name: /^Mark \d+:/ })
  64. /**
  65. * A car with two marks from a visit a month ago (a dent on the bonnet with a
  66. * note, a major scratch on the left front door), and the job ten days ago
  67. * that is to confirm them.
  68. */
  69. async function carWithTwoEarlierMarks(label: string) {
  70. const earlier = await plantConditionJob(
  71. organizationId,
  72. userId,
  73. `${label} earlier ${stamp}`,
  74. new Date(Date.now() - 30 * DAY)
  75. )
  76. const note = `Dent by the badge ${label} ${stamp}`
  77. const hood = await plantConditionMark(
  78. organizationId,
  79. earlier.vehicleId,
  80. { serviceRecordId: earlier.serviceRecordId },
  81. {
  82. view: 'top',
  83. panel: 'hood',
  84. kind: 'dent',
  85. note,
  86. recordedAt: new Date(Date.now() - 30 * DAY),
  87. }
  88. )
  89. const door = await plantConditionMark(
  90. organizationId,
  91. earlier.vehicleId,
  92. { serviceRecordId: earlier.serviceRecordId },
  93. {
  94. view: 'left',
  95. panel: 'left_front_door',
  96. kind: 'scratch',
  97. severity: 'major',
  98. recordedAt: new Date(Date.now() - 30 * DAY + 60_000),
  99. }
  100. )
  101. const later = await plantJobOnVehicle(
  102. organizationId,
  103. earlier.vehicleId,
  104. `${label} later ${stamp}`,
  105. new Date(Date.now() - 10 * DAY)
  106. )
  107. return { earlier, later, hood, door, note, vehicleId: earlier.vehicleId }
  108. }
  109. /**
  110. * Presses "Confirm all as still there" and waits for the earlier marks to be
  111. * closed in the database. Repeated until the grey list goes: a press before
  112. * hydration does nothing.
  113. */
  114. async function confirmAll(map: Locator, vehicleId: string, expected: number) {
  115. const button = map.getByTestId('condition-map-confirm-all')
  116. await expect(button).toHaveText('Confirm all as still there')
  117. await expect(async () => {
  118. await button.click()
  119. await expect(earlierSection(map)).toHaveCount(0, { timeout: 3_000 })
  120. }).toPass({ timeout: 30_000 })
  121. await expect
  122. .poll(async () => (await marksWithCarry(vehicleId)).filter((m) => m.carriedToId).length, {
  123. message: 'the earlier marks are closed and point at their copies',
  124. })
  125. .toBe(expected)
  126. }
  127. /** The planted originals and the copies that took their place, by the original's id. */
  128. function carried(rows: CarryMarkRow[], originalIds: string[]) {
  129. return originalIds.map((id) => {
  130. const original = rows.find((m) => m.id === id)
  131. if (!original) throw new Error(`mark ${id} is gone`)
  132. const copy = rows.find((m) => m.id === original.carriedToId)
  133. if (!copy) throw new Error(`mark ${id} has no copy`)
  134. return { original, copy }
  135. })
  136. }
  137. /** The carry action's request from this job's page: a server action naming the sheet. */
  138. function isCarryRequest(request: Request, serviceRecordId: string): boolean {
  139. return (
  140. request.method() === 'POST' &&
  141. Boolean(request.headers()['next-action']) &&
  142. (request.postData() ?? '').includes(serviceRecordId)
  143. )
  144. }
  145. async function openInspection(page: Page, inspectionId: string) {
  146. await page.goto(`/inspections/${inspectionId}`)
  147. await settle(page)
  148. const map = conditionMap(page)
  149. await expect(map).toBeVisible({ timeout: 30_000 })
  150. return map
  151. }
  152. test('confirming all makes the earlier marks this job’s own, with their note and photos', async ({
  153. page,
  154. }) => {
  155. const car = await carWithTwoEarlierMarks('E2E carry')
  156. // A real photo on the earlier dent, uploaded the way the editor uploads one.
  157. const uploaded = await page.request.post('/api/protected/upload/service-files', {
  158. multipart: {
  159. file: { name: `e2e-carry-${stamp}.png`, mimeType: 'image/png', buffer: TINY_PNG },
  160. },
  161. })
  162. expect(uploaded.status()).toBe(200)
  163. const photo = ((await uploaded.json()) as { url: string }).url
  164. await setMarkPhotos(car.hood, [photo])
  165. // A job with no marks of its own prints no map yet.
  166. expect(printsMap(await workOrderPdf(page, car.later.serviceRecordId))).toBe(false)
  167. const map = await openDropoff(page, jobUrl(car.later))
  168. await expect(ownMarks(map)).toHaveCount(0)
  169. await expect(earlierMarks(map)).toHaveCount(2)
  170. await expect(glyphs(map)).toHaveCount(2)
  171. await expect(greyGlyphs(map)).toHaveCount(2)
  172. await expect(map.getByTestId('condition-map-confirm-all')).toHaveAttribute(
  173. 'title',
  174. "Records them on this sheet as this visit's marks, in colour."
  175. )
  176. await confirmAll(map, car.vehicleId, 2)
  177. // On screen: the grey list is gone, the own list grew by two, the drawing is in colour.
  178. await expect(earlierSection(map)).toHaveCount(0)
  179. await expect(ownMarks(map)).toHaveCount(2)
  180. await expect(ownMarks(map).nth(0)).toHaveAccessibleName(/^Mark 1: Dent on the Bonnet/)
  181. await expect(ownMarks(map).nth(0)).toContainText(car.note)
  182. await expect(ownMarks(map).nth(1)).toHaveAccessibleName(/^Mark 2: Scratch on the Left front door/)
  183. await expect(glyphs(map)).toHaveCount(2)
  184. await expect(greyGlyphs(map)).toHaveCount(0)
  185. // In the database: each original closed and pointing at its copy, the copy
  186. // this job's and open, with everything the original said.
  187. const rows = await marksWithCarry(car.vehicleId)
  188. expect(rows).toHaveLength(4)
  189. for (const { original, copy } of carried(rows, [car.hood, car.door])) {
  190. expect(original.serviceRecordId).toBe(car.earlier.serviceRecordId)
  191. expect(original.resolvedAt).not.toBeNull()
  192. expect(copy).toMatchObject({
  193. serviceRecordId: car.later.serviceRecordId,
  194. inspectionId: null,
  195. inspectionItemId: null,
  196. resolvedAt: null,
  197. carriedToId: null,
  198. bodyType: original.bodyType,
  199. view: original.view,
  200. panel: original.panel,
  201. x: original.x,
  202. y: original.y,
  203. kind: original.kind,
  204. severity: original.severity,
  205. note: original.note,
  206. imageUrls: original.imageUrls,
  207. })
  208. }
  209. const [hood] = carried(rows, [car.hood])
  210. expect(hood.copy.note).toBe(car.note)
  211. expect(hood.copy.imageUrls).toEqual([photo])
  212. // A reload draws the same: this job's, in colour, its photo captioned as this visit's.
  213. const again = await openDropoff(page, jobUrl(car.later))
  214. await expect(ownMarks(again)).toHaveCount(2)
  215. await expect(earlierSection(again)).toHaveCount(0)
  216. await expect(greyGlyphs(again)).toHaveCount(0)
  217. await expect(
  218. page
  219. .getByTestId('condition-map-photos')
  220. .getByRole('button', { name: 'Photos of mark 1: 1. Dent on the Bonnet', exact: true })
  221. ).toBeVisible()
  222. // It is this sheet's to change now.
  223. await ownMarks(again).first().click()
  224. const editor = markEditor(page)
  225. await expect(editor).toBeVisible()
  226. await expect(editor.getByRole('button', { name: 'Remove mark' })).toBeVisible()
  227. await expect(editor).not.toContainText(CARRIED_TEXT)
  228. await page.keyboard.press('Escape')
  229. await expect(editor).toBeHidden()
  230. // And the work order prints them as its own, not as earlier ones.
  231. const pdf = await workOrderPdf(page, car.later.serviceRecordId)
  232. expect(printsMap(pdf), 'the job now has marks of its own').toBe(true)
  233. expect(pdf.flat).toContain('Bonnet')
  234. expect(pdf.flat).toContain('Left front door')
  235. expect(pdf.flat).toContain(car.note)
  236. expect(pdf.flat).not.toContain('recorded earlier')
  237. })
  238. test('the earlier job still shows and prints its marks as its own record, read only', async ({
  239. page,
  240. }) => {
  241. const car = await carWithTwoEarlierMarks('E2E carry kept')
  242. await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
  243. const map = await openDropoff(page, jobUrl(car.earlier))
  244. await expect(ownMarks(map)).toHaveCount(2)
  245. await expect(earlierSection(map)).toHaveCount(0)
  246. await expect(ownMarks(map).nth(0)).toHaveAccessibleName(/^Mark 1: Dent on the Bonnet/)
  247. await expect(ownMarks(map).nth(1)).toHaveAccessibleName(/^Mark 2: Scratch on the Left front door/)
  248. await expect(glyphs(map)).toHaveCount(2)
  249. await expect(greyGlyphs(map)).toHaveCount(0)
  250. // Opened, it says where it lives on, and cannot be changed or removed here.
  251. await ownMarks(map).first().click()
  252. const editor = markEditor(page)
  253. await expect(editor).toBeVisible()
  254. await expect(editor).toContainText(CARRIED_TEXT)
  255. await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
  256. await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
  257. await page.keyboard.press('Escape')
  258. await expect(editor).toBeHidden()
  259. // Its work order still prints the map with both, as this job's own.
  260. const pdf = await workOrderPdf(page, car.earlier.serviceRecordId)
  261. expect(printsMap(pdf), 'the earlier job keeps its map').toBe(true)
  262. expect(pdf.flat).toContain('Bonnet')
  263. expect(pdf.flat).toContain('Left front door')
  264. expect(pdf.flat).toContain(car.note)
  265. expect(pdf.flat).not.toContain('recorded earlier')
  266. // And the later one prints them as its own too.
  267. const later = await workOrderPdf(page, car.later.serviceRecordId)
  268. expect(printsMap(later)).toBe(true)
  269. expect(later.flat).toContain('Bonnet')
  270. expect(later.flat).toContain(car.note)
  271. expect(later.flat).not.toContain('recorded earlier')
  272. })
  273. test('the vehicle shows the dent once: a newer job meets the copies, not the originals', async ({
  274. page,
  275. }) => {
  276. const car = await carWithTwoEarlierMarks('E2E carry once')
  277. await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
  278. const copies = carried(await marksWithCarry(car.vehicleId), [car.hood, car.door]).map(
  279. ({ copy }) => copy.id
  280. )
  281. // Today's job, the newest visit.
  282. const newest = await plantJobOnVehicle(organizationId, car.vehicleId, `E2E carry newest ${stamp}`)
  283. const map = await openDropoff(page, jobUrl(newest))
  284. await expect(ownMarks(map)).toHaveCount(0)
  285. await expect(earlierMarks(map)).toHaveCount(2)
  286. await expect(earlierSection(map).getByRole('button', { name: /on the Bonnet/ })).toHaveCount(1)
  287. await expect(
  288. earlierSection(map).getByRole('button', { name: /on the Left front door/ })
  289. ).toHaveCount(1)
  290. await expect(glyphs(map)).toHaveCount(2)
  291. await expect(greyGlyphs(map)).toHaveCount(2)
  292. // What is still open on the car is exactly the copies.
  293. const open = (await marksWithCarry(car.vehicleId)).filter((m) => !m.resolvedAt).map((m) => m.id)
  294. expect(open.sort()).toEqual([...copies].sort())
  295. // Its work order has nothing of its own to print, so no map.
  296. expect(printsMap(await workOrderPdf(page, newest.serviceRecordId))).toBe(false)
  297. })
  298. test('pressing it again from a tab opened before changes nothing', async ({ page }) => {
  299. const car = await carWithTwoEarlierMarks('E2E carry twice')
  300. // A second tab on the same job, opened while the marks were still earlier ones.
  301. const stale = await page.context().newPage()
  302. const staleMap = await openDropoff(stale, jobUrl(car.later))
  303. await expect(earlierMarks(staleMap)).toHaveCount(2)
  304. await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
  305. const before = await marksWithCarry(car.vehicleId)
  306. expect(before).toHaveLength(4)
  307. // The stale tab still offers the button; pressing it reaches the server and takes nothing.
  308. const button = staleMap.getByTestId('condition-map-confirm-all')
  309. await expect(button).toBeVisible()
  310. const answered = stale.waitForResponse((response) =>
  311. isCarryRequest(response.request(), car.later.serviceRecordId)
  312. )
  313. await button.click()
  314. await answered
  315. await expect
  316. .poll(async () => (await marksWithCarry(car.vehicleId)).length, {
  317. message: 'no further marks were written',
  318. })
  319. .toBe(4)
  320. const after = await marksWithCarry(car.vehicleId)
  321. expect(after.map((m) => [m.id, m.carriedToId, m.resolvedAt?.toISOString() ?? null])).toEqual(
  322. before.map((m) => [m.id, m.carriedToId, m.resolvedAt?.toISOString() ?? null])
  323. )
  324. // Both tabs, reloaded, show the same two marks as this job's own.
  325. for (const tab of [page, stale]) {
  326. const map = await openDropoff(tab, jobUrl(car.later))
  327. await expect(ownMarks(map)).toHaveCount(2)
  328. await expect(earlierSection(map)).toHaveCount(0)
  329. }
  330. await stale.close()
  331. })
  332. test('a tab that confirms what another already confirmed shows them as this job’s', async ({
  333. page,
  334. }) => {
  335. const car = await carWithTwoEarlierMarks('E2E carry stale')
  336. const stale = await page.context().newPage()
  337. const staleMap = await openDropoff(stale, jobUrl(car.later))
  338. await expect(earlierMarks(staleMap)).toHaveCount(2)
  339. await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
  340. const answered = stale.waitForResponse((response) =>
  341. isCarryRequest(response.request(), car.later.serviceRecordId)
  342. )
  343. await staleMap.getByTestId('condition-map-confirm-all').click()
  344. await answered
  345. // Without a reload: the marks are this job's now, whoever pressed first,
  346. // so the tab should not go on offering them as earlier ones.
  347. await expect(ownMarks(staleMap)).toHaveCount(2)
  348. await expect(earlierSection(staleMap)).toHaveCount(0)
  349. await stale.close()
  350. })
  351. test('a mark cleared as repaired leaves its own sheet; a confirmed one stays there', async ({
  352. page,
  353. }) => {
  354. const car = await carWithTwoEarlierMarks('E2E carry repaired')
  355. const map = await openDropoff(page, jobUrl(car.later))
  356. // The bonnet was repaired; the scratch is still there.
  357. const grey = earlierSection(map)
  358. await grey
  359. .getByRole('listitem')
  360. .filter({ hasText: 'Bonnet' })
  361. .getByRole('button', { name: 'Clear as repaired' })
  362. .click()
  363. const confirm = page.getByRole('alertdialog', { name: 'Clear as repaired' })
  364. await confirm.getByRole('button', { name: 'Clear as repaired' }).click()
  365. await expect(confirm).toBeHidden()
  366. await expect(earlierMarks(map)).toHaveCount(1)
  367. await expect
  368. .poll(async () => (await marksWithCarry(car.vehicleId)).find((m) => m.id === car.hood))
  369. .toMatchObject({ resolvedAt: expect.any(Date), carriedToId: null })
  370. await confirmAll(map, car.vehicleId, 1)
  371. await expect(ownMarks(map)).toHaveCount(1)
  372. await expect(ownMarks(map).first()).toHaveAccessibleName(/on the Left front door/)
  373. // The earlier job keeps the confirmed scratch and has lost the repaired dent.
  374. const earlier = await openDropoff(page, jobUrl(car.earlier))
  375. await expect(ownMarks(earlier)).toHaveCount(1)
  376. await expect(ownMarks(earlier).first()).toHaveAccessibleName(/on the Left front door/)
  377. })
  378. test.describe('on an inspection’s map check', () => {
  379. test('confirming all records them on the check and leaves the earlier job its record', async ({
  380. page,
  381. }) => {
  382. const earlier = await plantConditionJob(
  383. organizationId,
  384. userId,
  385. `E2E carry inspection earlier ${stamp}`,
  386. new Date(Date.now() - 30 * DAY)
  387. )
  388. const note = `Chip by the mirror ${stamp}`
  389. const hood = await plantConditionMark(
  390. organizationId,
  391. earlier.vehicleId,
  392. { serviceRecordId: earlier.serviceRecordId },
  393. { view: 'top', panel: 'hood', kind: 'dent', recordedAt: new Date(Date.now() - 30 * DAY) }
  394. )
  395. const door = await plantConditionMark(
  396. organizationId,
  397. earlier.vehicleId,
  398. { serviceRecordId: earlier.serviceRecordId },
  399. {
  400. view: 'right',
  401. panel: 'right_front_door',
  402. kind: 'chip',
  403. note,
  404. recordedAt: new Date(Date.now() - 30 * DAY + 60_000),
  405. }
  406. )
  407. // Today's inspection on the same car, not linked to that job.
  408. const inspection = await plantConditionInspection(
  409. organizationId,
  410. earlier.vehicleId,
  411. `E2E carry ${stamp}`
  412. )
  413. const map = await openInspection(page, inspection.inspectionId)
  414. await expect(ownMarks(map)).toHaveCount(0)
  415. await expect(earlierMarks(map)).toHaveCount(2)
  416. await expect(greyGlyphs(map)).toHaveCount(2)
  417. await confirmAll(map, earlier.vehicleId, 2)
  418. await expect(ownMarks(map)).toHaveCount(2)
  419. await expect(earlierSection(map)).toHaveCount(0)
  420. await expect(greyGlyphs(map)).toHaveCount(0)
  421. await expect(ownMarks(map).nth(1)).toContainText(note)
  422. // The copies are the check's, the originals closed and pointing at them.
  423. const rows = await marksWithCarry(earlier.vehicleId)
  424. expect(rows).toHaveLength(4)
  425. for (const { original, copy } of carried(rows, [hood, door])) {
  426. expect(original.resolvedAt).not.toBeNull()
  427. expect(copy).toMatchObject({
  428. serviceRecordId: null,
  429. inspectionId: inspection.inspectionId,
  430. inspectionItemId: inspection.inspectionItemId,
  431. resolvedAt: null,
  432. view: original.view,
  433. panel: original.panel,
  434. kind: original.kind,
  435. severity: original.severity,
  436. note: original.note,
  437. })
  438. }
  439. // A reload keeps them on the check.
  440. const again = await openInspection(page, inspection.inspectionId)
  441. await expect(ownMarks(again)).toHaveCount(2)
  442. await expect(earlierSection(again)).toHaveCount(0)
  443. // The certificate prints them as the inspection's own.
  444. const certificate = await certificatePdf(page, inspection.inspectionId)
  445. expect(printsMap(certificate)).toBe(true)
  446. expect(certificate.flat).toContain('Right front door')
  447. expect(certificate.flat).toContain(note)
  448. // The earlier job still holds them as its own record, read only, and prints them.
  449. const onJob = await openDropoff(page, jobUrl(earlier))
  450. await expect(ownMarks(onJob)).toHaveCount(2)
  451. await expect(earlierSection(onJob)).toHaveCount(0)
  452. await ownMarks(onJob).nth(1).click()
  453. const editor = markEditor(page)
  454. await expect(editor).toContainText(CARRIED_TEXT)
  455. await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
  456. await page.keyboard.press('Escape')
  457. const pdf = await workOrderPdf(page, earlier.serviceRecordId)
  458. expect(printsMap(pdf)).toBe(true)
  459. expect(pdf.flat).toContain('Right front door')
  460. expect(pdf.flat).toContain(note)
  461. })
  462. })