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
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,21 @@ application unless that application says otherwise.
`app/shared/pretool-presets.ts`, unreferenced on purpose and going back
through the same `ToolRequest.presets`. Do not write a second exporter here — `docs/CATALOG-SPEC.md` § 5 has what changed and
what is left.
**Nor is the Mastercam export** (2026-09-12) — it is
`@toolpath/tool-support/export/mastercam`, and a `.TOOLDB` is a SQLite
database carrying Mastercam's own 79-table schema, pinned from a real library
upstream. `app/shared/mastercam-input.ts` is that seam, and it reads
differently from the Fusion one on purpose: Fusion embeds a holder inside the
tool record, so one tool in two holders is two records, where Mastercam joins
them relationally — so the order list goes in **grouped**, one entry per
distinct tool carrying one set-up per stack, under the catalog's own guids.
The one identifier this application mints is the assembly's, per stack, and
that module says why. What both formats read is `app/shared/export-input.ts`;
one dialog serves both, `app/components/library-export-dialog.tsx`, and the
format supplies its four strings. **The exporter is imported on the press**,
not at the top of the route: it carries 82 KB of generated schema, and
`vite.config.ts` pre-bundles the subpath so that import is a fetch rather
than a discovery.
**A holder can be drawn from its own CAD model** rather than from the nine
numbers a vendor publishes: `catalog-profiles` is a second Vite alias beside
`catalog-dataset`, `shared/catalog.ts` `getProfile` is the only way to reach
Expand Down Expand Up @@ -317,7 +332,10 @@ application unless that application says otherwise.
| what is on the order list, for both pages | `app/shared/order-list.ts` |
| whether a row has anything ordered, and what to buy | `isIncomplete` / `componentTotals`, same file |
| which of four things the page is being asked | `asked()`, same file |
| what both exporters read of a row | `app/shared/export-input.ts` |
| the bill as a Fusion library, and what its notes say | `app/shared/fusion-input.ts` |
| the bill as a Mastercam `.TOOLDB`, and its notes | `app/shared/mastercam-input.ts` |
| the one dialog both exports ask their question in | `app/components/library-export-dialog.tsx` |
| the three presses over the part that add a row | `app/components/add-bar.tsx` |
| whether the presses and the rows are drawn at all | `app/shared/part-chrome.ts` |
| where the part is framed, beside the questions | `app/shared/frame-inset.ts` |
Expand Down
42 changes: 0 additions & 42 deletions apps/catalog/app/components/fusion-export-dialog.test.tsx

This file was deleted.

130 changes: 0 additions & 130 deletions apps/catalog/app/components/fusion-export-dialog.tsx

This file was deleted.

149 changes: 149 additions & 0 deletions apps/catalog/app/components/library-export-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FUSION_FORMAT, LibraryExportDialog, MASTERCAM_FORMAT } from './library-export-dialog'

describe('LibraryExportDialog', () => {
it('passes the trimmed library name to export and reports what landed', async () => {
const onExport = vi.fn().mockResolvedValue({ exported: 1, skipped: [], warnings: [] })
render(
<LibraryExportDialog
format={FUSION_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={onExport}
/>,
)

fireEvent.click(screen.getByRole('button', { name: 'Download .json' }))

await vi.waitFor(() => expect(onExport).toHaveBeenCalledWith('Shop library'))
expect(await screen.findByText('Downloaded 1 tool assembly.')).toBeInTheDocument()
})

it('will not export under an empty name, which the CAM system shows as the library label', () => {
render(
<LibraryExportDialog
format={FUSION_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={vi.fn()}
/>,
)

fireEvent.change(screen.getByLabelText('Library name'), { target: { value: ' ' } })
expect(screen.getByRole('button', { name: 'Download .json' })).toBeDisabled()
})

it('says which tool was left out, and which holder travelled short', async () => {
const onExport = vi.fn().mockResolvedValue({
exported: 1,
skipped: [{ catalogNumber: 'TDMX0800', reason: 'Fusion requires RE and none is stated' }],
warnings: [{ catalogNumber: 'TDMX0600', reason: 'the vendor publishes no gauge length' }],
})
render(
<LibraryExportDialog
format={FUSION_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={onExport}
/>,
)

fireEvent.click(screen.getByRole('button', { name: 'Download .json' }))

expect(
await screen.findByText('TDMX0800 skipped — Fusion requires RE and none is stated'),
).toBeInTheDocument()
expect(
await screen.findByText('TDMX0600 — the vendor publishes no gauge length.'),
).toBeInTheDocument()
})

/**
* The whole point of the generalisation: one dialog, and the format decides
* its four strings. A second copy of this component is what this pins against.
*/
/**
* `aria-modal` is a promise about the keyboard, and these are the three
* halves of keeping it. They are pinned because the markup makes the claim
* whether or not the behaviour is there, so nothing else would notice it
* going: a reader would simply be told the page behind is inert while Tab
* walked onto it.
*/
describe('the focus it holds while it is open', () => {
it('puts the caret in the name, so the dialog is usable without a mouse', () => {
render(
<LibraryExportDialog
format={FUSION_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={vi.fn()}
/>,
)

expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Library name' }))
})

it('keeps Tab inside itself rather than letting it walk onto the page behind', () => {
render(
<LibraryExportDialog
format={FUSION_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={vi.fn()}
/>,
)

const download = screen.getByRole('button', { name: 'Download .json' })
download.focus()
fireEvent.keyDown(document, { key: 'Tab' })

expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Library name' }))
})

it('hands the focus back to whatever opened it', () => {
const opener = document.createElement('button')
document.body.appendChild(opener)
opener.focus()

const view = render(
<LibraryExportDialog
format={FUSION_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={vi.fn()}
/>,
)
expect(document.activeElement).not.toBe(opener)

view.unmount()

expect(document.activeElement).toBe(opener)
opener.remove()
})
})

it('takes its name, its extension and its blurb from the format it is given', async () => {
const onExport = vi.fn().mockResolvedValue({ exported: 0, skipped: [], warnings: [] })
render(
<LibraryExportDialog
format={MASTERCAM_FORMAT}
initialName="Shop library"
onCancel={vi.fn()}
onExport={onExport}
/>,
)

expect(
screen.getByRole('dialog', { name: 'Export Mastercam tool library' }),
).toBeInTheDocument()
expect(screen.getByText('Mastercam tool library')).toBeInTheDocument()
expect(
screen.getByText('Mastercam will use this filename for the library.'),
).toBeInTheDocument()

fireEvent.click(screen.getByRole('button', { name: 'Download .TOOLDB' }))

expect(await screen.findByText('No Mastercam library was downloaded.')).toBeInTheDocument()
})
})
Loading