dropoff.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { existsSync } from 'node:fs'
  2. import path from 'node:path'
  3. import { expect, test } from '@playwright/test'
  4. import {
  5. addMark,
  6. closeEditor,
  7. earlierSection,
  8. markEditor,
  9. openDropoff,
  10. ownMarks,
  11. } from '../../support/condition-map'
  12. import {
  13. conditionMarksOf,
  14. ownerOrganizationId,
  15. plantConditionJob,
  16. plantConditionMark,
  17. plantJobOnVehicle,
  18. plantOwnMarkKind,
  19. removeOwnMarkKind,
  20. userIdFor,
  21. } from '../../support/db'
  22. import { sharesDiskWithServer } from '../../support/files'
  23. import { TINY_PNG } from '../../support/pdf'
  24. /**
  25. * The drop-off on a work order: the car's condition as it came in, drawn on
  26. * the Drop-off tab of the files card. Marks belong to the vehicle, so a mark
  27. * from an earlier visit shows grey on the next job, and is cleared there once
  28. * it has been repaired.
  29. *
  30. * Every test plants a car of its own, so nothing another spec or an earlier
  31. * run drew is on it.
  32. */
  33. const stamp = Date.now()
  34. let organizationId = ''
  35. let userId = ''
  36. const DAY = 24 * 60 * 60 * 1000
  37. test.beforeAll(async () => {
  38. organizationId = await ownerOrganizationId()
  39. userId = await userIdFor('demo@torqvoice.com')
  40. })
  41. const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
  42. `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
  43. test('a click on the drawing records a mark, and what is said about it stays', async ({ page }) => {
  44. const job = await plantConditionJob(organizationId, userId, `E2E drop-off ${stamp}`)
  45. const map = await openDropoff(page, jobUrl(job))
  46. await expect(ownMarks(map)).toHaveCount(0)
  47. const editor = await addMark(page, map, 'left', 'left_front_door')
  48. // The click made the mark with the first kind on offer, on the panel clicked.
  49. await expect(editor.getByRole('heading')).toContainText('Dent on the Left front door')
  50. await editor.getByRole('group', { name: 'Type' }).getByRole('button', { name: 'Scratch' }).click()
  51. await expect(
  52. editor.getByRole('group', { name: 'Type' }).getByRole('button', { name: 'Scratch' })
  53. ).toHaveAttribute('aria-pressed', 'true')
  54. await editor
  55. .getByRole('group', { name: 'Severity' })
  56. .getByRole('button', { name: 'Major' })
  57. .click()
  58. await expect(
  59. editor.getByRole('group', { name: 'Severity' }).getByRole('button', { name: 'Major' })
  60. ).toHaveAttribute('aria-pressed', 'true')
  61. await editor.getByLabel('Note').fill('Long scratch under the handle')
  62. await editor.locator('input[type="file"][multiple]').setInputFiles({
  63. name: `e2e-scratch-${stamp}.png`,
  64. mimeType: 'image/png',
  65. buffer: TINY_PNG,
  66. })
  67. await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(1, {
  68. timeout: 30_000,
  69. })
  70. await closeEditor(page)
  71. await expect(ownMarks(map)).toHaveCount(1)
  72. await expect(ownMarks(map).first()).toHaveAccessibleName(
  73. /^Mark 1: Scratch on the Left front door/
  74. )
  75. // What the screen says is what was kept.
  76. const [row] = await conditionMarksOf(job.vehicleId)
  77. expect(row).toMatchObject({
  78. serviceRecordId: job.serviceRecordId,
  79. inspectionId: null,
  80. view: 'left',
  81. panel: 'left_front_door',
  82. kind: 'scratch',
  83. severity: 'major',
  84. note: 'Long scratch under the handle',
  85. resolvedAt: null,
  86. })
  87. expect(row.imageUrls).toHaveLength(1)
  88. // And a reload draws the same thing, with the tab counting it.
  89. const again = await openDropoff(page, jobUrl(job))
  90. await expect(ownMarks(again)).toHaveCount(1)
  91. await expect(ownMarks(again).first()).toContainText('Long scratch under the handle')
  92. await expect(page.getByTestId('condition-map-photos')).toBeVisible()
  93. })
  94. test('a mark removed is gone from the job and from the vehicle', async ({ page }) => {
  95. const job = await plantConditionJob(organizationId, userId, `E2E drop-off remove ${stamp}`)
  96. const map = await openDropoff(page, jobUrl(job))
  97. await addMark(page, map, 'top', 'hood')
  98. await closeEditor(page)
  99. await addMark(page, map, 'left', 'left_rear_door')
  100. await closeEditor(page)
  101. await expect(ownMarks(map)).toHaveCount(2)
  102. await ownMarks(map).first().click()
  103. const editor = markEditor(page)
  104. await expect(editor).toBeVisible()
  105. await editor.getByRole('button', { name: 'Remove mark' }).click()
  106. await expect(editor).toBeHidden()
  107. await expect(ownMarks(map)).toHaveCount(1)
  108. expect(await conditionMarksOf(job.vehicleId)).toHaveLength(1)
  109. const again = await openDropoff(page, jobUrl(job))
  110. await expect(ownMarks(again)).toHaveCount(1)
  111. await expect(ownMarks(again).first()).toHaveAccessibleName(/on the Left rear door/)
  112. })
  113. test.describe('marks from an earlier visit', () => {
  114. test('are grey, cannot be changed from here, and clear as repaired', async ({ page }) => {
  115. const earlier = await plantConditionJob(
  116. organizationId,
  117. userId,
  118. `E2E earlier visit ${stamp}`,
  119. new Date(Date.now() - 30 * DAY)
  120. )
  121. await plantConditionMark(
  122. organizationId,
  123. earlier.vehicleId,
  124. { serviceRecordId: earlier.serviceRecordId },
  125. { view: 'top', panel: 'hood', kind: 'dent', recordedAt: new Date(Date.now() - 30 * DAY) }
  126. )
  127. // Today's job on the same car.
  128. const today = await plantJobOnVehicle(organizationId, earlier.vehicleId, `E2E next ${stamp}`)
  129. const map = await openDropoff(page, jobUrl(today))
  130. await expect(ownMarks(map)).toHaveCount(0)
  131. const grey = earlierSection(map)
  132. await expect(grey).toContainText('Bonnet')
  133. // Opened, it is read only: no kind, severity or note to change, no removing it.
  134. await grey.getByRole('button', { name: /^Mark 1:/ }).click()
  135. const editor = markEditor(page)
  136. await expect(editor).toBeVisible()
  137. await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
  138. await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
  139. await expect(editor).toContainText('Recorded on an earlier visit. Clear it as repaired')
  140. await page.keyboard.press('Escape')
  141. await expect(editor).toBeHidden()
  142. await grey.getByRole('button', { name: 'Clear as repaired' }).click()
  143. const confirm = page.getByRole('alertdialog', { name: 'Clear as repaired' })
  144. await confirm.getByRole('button', { name: 'Clear as repaired' }).click()
  145. await expect(confirm).toBeHidden()
  146. await expect(earlierSection(map)).toHaveCount(0)
  147. // Kept in the history, and gone from the page for good.
  148. const [row] = await conditionMarksOf(earlier.vehicleId)
  149. expect(row.resolvedAt).not.toBeNull()
  150. const again = await openDropoff(page, jobUrl(today))
  151. await expect(earlierSection(again)).toHaveCount(0)
  152. })
  153. test('only means earlier: a later visit’s mark is not on an older job', async ({ page }) => {
  154. const older = await plantConditionJob(
  155. organizationId,
  156. userId,
  157. `E2E older visit ${stamp}`,
  158. new Date(Date.now() - 60 * DAY)
  159. )
  160. await plantConditionMark(
  161. organizationId,
  162. older.vehicleId,
  163. { serviceRecordId: older.serviceRecordId },
  164. { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60 * DAY) }
  165. )
  166. // A later job on the same car found a dent the older one never saw.
  167. const later = await plantJobOnVehicle(organizationId, older.vehicleId, `E2E later ${stamp}`)
  168. await plantConditionMark(
  169. organizationId,
  170. older.vehicleId,
  171. { serviceRecordId: later.serviceRecordId },
  172. { view: 'rear', panel: 'rear_bumper' }
  173. )
  174. const map = await openDropoff(page, jobUrl(older))
  175. await expect(ownMarks(map)).toHaveCount(1)
  176. await expect(earlierSection(map)).toHaveCount(0)
  177. // Not listed anywhere on it: the drawing's own caption still names the panel.
  178. await expect(map.getByRole('button', { name: /on the Rear bumper/ })).toHaveCount(0)
  179. })
  180. })
  181. test('a kind of the workshop’s own can be put on a mark', async ({ page }) => {
  182. const own = await plantOwnMarkKind(organizationId, `E2E Hail ${stamp}`)
  183. try {
  184. const job = await plantConditionJob(organizationId, userId, `E2E own kind ${stamp}`)
  185. const map = await openDropoff(page, jobUrl(job))
  186. const editor = await addMark(page, map, 'top', 'roof')
  187. const type = editor.getByRole('group', { name: 'Type' })
  188. await type.getByRole('button', { name: own.name }).click()
  189. await expect(type.getByRole('button', { name: own.name })).toHaveAttribute(
  190. 'aria-pressed',
  191. 'true'
  192. )
  193. await closeEditor(page)
  194. await expect(ownMarks(map).first()).toHaveAccessibleName(new RegExp(own.name))
  195. const [row] = await conditionMarksOf(job.vehicleId)
  196. expect(row.kind).toBe(own.key)
  197. } finally {
  198. await removeOwnMarkKind(own.id)
  199. }
  200. })
  201. test('a dozen photos at once all reach the mark', async ({ page }) => {
  202. const job = await plantConditionJob(organizationId, userId, `E2E many photos ${stamp}`)
  203. const map = await openDropoff(page, jobUrl(job))
  204. const editor = await addMark(page, map, 'front', 'front_bumper')
  205. await editor.locator('input[type="file"][multiple]').setInputFiles(
  206. Array.from({ length: 12 }, (_, i) => ({
  207. name: `e2e-bumper-${stamp}-${i}.png`,
  208. mimeType: 'image/png',
  209. buffer: TINY_PNG,
  210. }))
  211. )
  212. await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(12, {
  213. timeout: 60_000,
  214. })
  215. await closeEditor(page)
  216. const [row] = await conditionMarksOf(job.vehicleId)
  217. expect(row.imageUrls).toHaveLength(12)
  218. })
  219. test.describe('the photos on a mark, on disk', () => {
  220. /** Where the server keeps an uploaded file, from the URL a row stores. */
  221. const diskPath = (url: string) =>
  222. path.join('data', 'uploads', url.replace('/api/protected/files/', ''))
  223. async function markWithPhotos(
  224. page: import('@playwright/test').Page,
  225. label: string,
  226. count: number
  227. ) {
  228. const job = await plantConditionJob(organizationId, userId, `${label} ${stamp}`)
  229. const map = await openDropoff(page, jobUrl(job))
  230. const editor = await addMark(page, map, 'left', 'left_front_door')
  231. await editor.locator('input[type="file"][multiple]').setInputFiles(
  232. Array.from({ length: count }, (_, i) => ({
  233. name: `e2e-disk-${stamp}-${i}.png`,
  234. mimeType: 'image/png',
  235. buffer: TINY_PNG,
  236. }))
  237. )
  238. await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(count, {
  239. timeout: 30_000,
  240. })
  241. const [row] = await conditionMarksOf(job.vehicleId)
  242. for (const url of row.imageUrls)
  243. expect(existsSync(diskPath(url)), `${url} was written`).toBe(true)
  244. return { job, map, editor, urls: row.imageUrls }
  245. }
  246. test('a photo taken off a mark leaves the disk', async ({ page }) => {
  247. test.skip(!sharesDiskWithServer(), 'the server’s uploads are not on this disk')
  248. const { job, editor, urls } = await markWithPhotos(page, 'E2E photo off', 2)
  249. await editor.getByRole('button', { name: 'Remove photo 1' }).click()
  250. await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(1)
  251. const [row] = await conditionMarksOf(job.vehicleId)
  252. const removed = urls.filter((url) => !row.imageUrls.includes(url))
  253. expect(removed).toHaveLength(1)
  254. await expect.poll(() => existsSync(diskPath(removed[0]))).toBe(false)
  255. // The one still on the mark is untouched.
  256. expect(existsSync(diskPath(row.imageUrls[0]))).toBe(true)
  257. })
  258. test('a mark removed takes its photos off the disk', async ({ page }) => {
  259. test.skip(!sharesDiskWithServer(), 'the server’s uploads are not on this disk')
  260. const { job, editor, urls } = await markWithPhotos(page, 'E2E mark off', 2)
  261. await editor.getByRole('button', { name: 'Remove mark' }).click()
  262. await expect(editor).toBeHidden()
  263. expect(await conditionMarksOf(job.vehicleId)).toHaveLength(0)
  264. for (const url of urls) {
  265. await expect.poll(() => existsSync(diskPath(url)), `${url} left the disk`).toBe(false)
  266. }
  267. })
  268. })