@@ -292,6 +292,25 @@ function makeWebViewStateWithResetSpy(resetActiveProject: () => void) {
292292 ] ;
293293}
294294
295+ /**
296+ * Builds a `useWebViewState` stub with spies on both the setter and the reset for the
297+ * `'activeProject'` key, so tests can assert which path was taken after a create attempt.
298+ *
299+ * @param setActiveProject - Spy invoked when the `'activeProject'` slot is set.
300+ * @param resetActiveProject - Spy invoked when the `'activeProject'` slot is reset.
301+ * @returns A `useWebViewState`-shaped hook stub.
302+ */
303+ function makeWebViewStateWithActiveProjectSpies (
304+ setActiveProject : jest . Mock ,
305+ resetActiveProject : jest . Mock ,
306+ ) {
307+ return < T , > ( key : string , defaultValue : T ) : [ T , ( v : T ) => void , ( ) => void ] => [
308+ defaultValue ,
309+ key === 'activeProject' ? setActiveProject : ( ) => { } ,
310+ key === 'activeProject' ? resetActiveProject : ( ) => { } ,
311+ ] ;
312+ }
313+
295314describe ( 'ProjectModals' , ( ) => {
296315 beforeEach ( ( ) => {
297316 jest . mocked ( papi . notifications . send ) . mockResolvedValue ( 'notification-id' ) ;
@@ -452,7 +471,48 @@ describe('ProjectModals', () => {
452471 } ) ;
453472
454473 describe ( 'new (create) flow' , ( ) => {
455- it ( 'starts an empty draft, clears the active project, and closes when not dirty' , async ( ) => {
474+ it ( 'seeds the draft, calls createProject on the backend, and closes' , async ( ) => {
475+ jest . mocked ( papi . commands . sendCommand ) . mockResolvedValueOnce ( JSON . stringify ( MOCK_PROJECT ) ) ;
476+ const newDraft = jest . fn ( ) ;
477+ const setModal = jest . fn ( ) ;
478+ const setActiveProject = jest . fn ( ) ;
479+ const resetActiveProject = jest . fn ( ) ;
480+ render (
481+ < ProjectModals
482+ { ...buildProps ( {
483+ modal : 'create' ,
484+ newDraft,
485+ setModal,
486+ useWebViewState : makeWebViewStateWithActiveProjectSpies (
487+ setActiveProject ,
488+ resetActiveProject ,
489+ ) ,
490+ } ) }
491+ /> ,
492+ ) ;
493+
494+ await userEvent . click ( screen . getByTestId ( 'create-submit' ) ) ;
495+
496+ await waitFor ( ( ) => expect ( setActiveProject ) . toHaveBeenCalledWith ( MOCK_PROJECT ) ) ;
497+ expect ( newDraft ) . toHaveBeenCalledWith ( {
498+ analysisLanguages : [ 'en' ] ,
499+ suggestedName : 'New' ,
500+ suggestedDescription : 'Desc' ,
501+ } ) ;
502+ expect ( papi . commands . sendCommand ) . toHaveBeenCalledWith (
503+ 'interlinearizer.createProject' ,
504+ 'source-proj' ,
505+ [ 'en' ] ,
506+ undefined ,
507+ 'New' ,
508+ 'Desc' ,
509+ ) ;
510+ expect ( resetActiveProject ) . not . toHaveBeenCalled ( ) ;
511+ expect ( setModal ) . toHaveBeenCalledWith ( 'none' ) ;
512+ } ) ;
513+
514+ it ( 'falls back to resetActiveProject and closes when backend project creation fails' , async ( ) => {
515+ // Default mock returns undefined; JSON.parse(undefined) throws into the catch block.
456516 const newDraft = jest . fn ( ) ;
457517 const setModal = jest . fn ( ) ;
458518 const resetActiveProject = jest . fn ( ) ;
@@ -469,23 +529,45 @@ describe('ProjectModals', () => {
469529
470530 await userEvent . click ( screen . getByTestId ( 'create-submit' ) ) ;
471531
472- // New starts a draft locally (carrying the typed name/description for the Save As prefill) and
473- // clears the active project so Save routes to Save As; no backend project is created.
532+ await waitFor ( ( ) => expect ( setModal ) . toHaveBeenCalledWith ( 'none' ) ) ;
474533 expect ( newDraft ) . toHaveBeenCalledWith ( {
475534 analysisLanguages : [ 'en' ] ,
476535 suggestedName : 'New' ,
477536 suggestedDescription : 'Desc' ,
478537 } ) ;
479- expect ( papi . commands . sendCommand ) . not . toHaveBeenCalledWith (
538+ expect ( papi . commands . sendCommand ) . toHaveBeenCalledWith (
480539 'interlinearizer.createProject' ,
481- expect . anything ( ) ,
482- expect . anything ( ) ,
483- expect . anything ( ) ,
484- expect . anything ( ) ,
485- expect . anything ( ) ,
540+ 'source-proj' ,
541+ [ 'en' ] ,
542+ undefined ,
543+ 'New' ,
544+ 'Desc' ,
486545 ) ;
487546 expect ( resetActiveProject ) . toHaveBeenCalledTimes ( 1 ) ;
488- expect ( setModal ) . toHaveBeenCalledWith ( 'none' ) ;
547+ } ) ;
548+
549+ it ( 'notifies and falls back to resetActiveProject when backend returns a non-project shape' , async ( ) => {
550+ jest . mocked ( papi . commands . sendCommand ) . mockResolvedValueOnce ( JSON . stringify ( { bad : true } ) ) ;
551+ const setModal = jest . fn ( ) ;
552+ const resetActiveProject = jest . fn ( ) ;
553+ render (
554+ < ProjectModals
555+ { ...buildProps ( {
556+ modal : 'create' ,
557+ setModal,
558+ useWebViewState : makeWebViewStateWithResetSpy ( resetActiveProject ) ,
559+ } ) }
560+ /> ,
561+ ) ;
562+
563+ await userEvent . click ( screen . getByTestId ( 'create-submit' ) ) ;
564+
565+ await waitFor ( ( ) => expect ( setModal ) . toHaveBeenCalledWith ( 'none' ) ) ;
566+ expect ( papi . notifications . send ) . toHaveBeenCalledWith ( {
567+ message : '%interlinearizer_error_create_project_failed%' ,
568+ severity : 'error' ,
569+ } ) ;
570+ expect ( resetActiveProject ) . toHaveBeenCalledTimes ( 1 ) ;
489571 } ) ;
490572
491573 it ( 'calls setModal with none when the create modal closes without a select source' , async ( ) => {
@@ -540,14 +622,22 @@ describe('ProjectModals', () => {
540622 expect ( loadFromProject ) . not . toHaveBeenCalled ( ) ;
541623 } ) ;
542624
543- it ( 'confirms before starting a new draft when the draft is dirty' , async ( ) => {
625+ it ( 'confirms before creating a project when the draft is dirty' , async ( ) => {
544626 const newDraft = jest . fn ( ) ;
545627 render ( < ProjectModals { ...buildProps ( { modal : 'create' , dirty : true , newDraft } ) } /> ) ;
546628
547629 await userEvent . click ( screen . getByTestId ( 'create-submit' ) ) ;
548630 expect ( screen . getByTestId ( 'discard-modal' ) ) . toBeInTheDocument ( ) ;
549- // The new draft must not start until the user confirms discarding the current one .
631+ // Neither draft nor project creation should start until the user confirms discarding.
550632 expect ( newDraft ) . not . toHaveBeenCalled ( ) ;
633+ expect ( papi . commands . sendCommand ) . not . toHaveBeenCalledWith (
634+ 'interlinearizer.createProject' ,
635+ expect . anything ( ) ,
636+ expect . anything ( ) ,
637+ expect . anything ( ) ,
638+ expect . anything ( ) ,
639+ expect . anything ( ) ,
640+ ) ;
551641
552642 await userEvent . click ( screen . getByTestId ( 'discard-confirm' ) ) ;
553643 await waitFor ( ( ) =>
@@ -557,6 +647,14 @@ describe('ProjectModals', () => {
557647 suggestedDescription : 'Desc' ,
558648 } ) ,
559649 ) ;
650+ expect ( papi . commands . sendCommand ) . toHaveBeenCalledWith (
651+ 'interlinearizer.createProject' ,
652+ 'source-proj' ,
653+ [ 'en' ] ,
654+ undefined ,
655+ 'New' ,
656+ 'Desc' ,
657+ ) ;
560658 } ) ;
561659
562660 it ( 'disables the discard-confirm button while an open is in flight' , async ( ) => {
0 commit comments