11// @vitest -environment jsdom
2- import { cleanup , render , screen } from "@testing-library/react" ;
3- import { afterEach , beforeEach , describe , expect , it } from "vitest" ;
2+ import { cleanup , fireEvent , render , screen , waitFor , within } from "@testing-library/react" ;
3+ import { toast } from "sonner" ;
4+ import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
45import { I18nProvider } from "@/contexts/I18nContext" ;
56import { LOCALE_STORAGE_KEY } from "@/i18n/config" ;
6- import { CURSOR_THEMES } from "@/lib/cursor/cursorThemes" ;
7+ import { createEmptyDocument } from "@/lib/ai-edition/schema" ;
8+ import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings" ;
9+ import { useProjectStore } from "@/lib/ai-edition/store/projectStore" ;
10+ import { CURSOR_THEMES , DEFAULT_CURSOR_SPRITES } from "@/lib/cursor/cursorThemes" ;
11+ import { nativeBridgeClient } from "@/native" ;
712import { CursorPane } from "./RightPanes" ;
813
914function stubStorage ( ) {
@@ -30,12 +35,28 @@ function stubStorage() {
3035beforeEach ( ( ) => {
3136 stubStorage ( ) ;
3237 window . localStorage . setItem ( LOCALE_STORAGE_KEY , "en" ) ;
38+ vi . spyOn ( nativeBridgeClient . aiEdition , "save" ) . mockImplementation ( async ( document ) => ( {
39+ success : true ,
40+ document,
41+ } ) ) ;
3342} ) ;
3443
3544afterEach ( ( ) => {
3645 cleanup ( ) ;
46+ useProjectStore . getState ( ) . clear ( ) ;
47+ vi . restoreAllMocks ( ) ;
3748} ) ;
3849
50+ function renderWithProject ( legacyEditor : Record < string , unknown > = { } ) {
51+ const document = { ...createEmptyDocument ( { projectId : "p" , title : "t" } ) , legacyEditor } ;
52+ useProjectStore . setState ( { projectId : "p" , document } ) ;
53+ return render (
54+ < I18nProvider >
55+ < CursorPane />
56+ </ I18nProvider > ,
57+ ) ;
58+ }
59+
3960describe ( "CursorPane theme picker" , ( ) => {
4061 it ( "shows the original cursor themes beside the default" , ( ) => {
4162 expect ( CURSOR_THEMES ) . toHaveLength ( 6 ) ;
@@ -50,4 +71,157 @@ describe("CursorPane theme picker", () => {
5071 expect ( button . querySelectorAll ( "img" ) ) . toHaveLength ( 1 ) ;
5172 }
5273 } ) ;
74+
75+ it ( "opens a three-slot custom cursor upload panel from the plus button" , ( ) => {
76+ renderWithProject ( ) ;
77+ fireEvent . click ( screen . getByRole ( "button" , { name : "Add custom cursor" } ) ) ;
78+ const panel = screen . getByRole ( "group" , { name : "Custom cursor" } ) ;
79+ expect ( within ( panel ) . getByRole ( "button" , { name : "Arrow" } ) ) . toBeTruthy ( ) ;
80+ expect ( within ( panel ) . getByRole ( "button" , { name : "Hand" } ) ) . toBeTruthy ( ) ;
81+ expect ( within ( panel ) . getByRole ( "button" , { name : "Text" } ) ) . toBeTruthy ( ) ;
82+ expect ( panel . textContent ) . toBe ( "" ) ;
83+ for ( const [ label , kind ] of [
84+ [ "Arrow" , "arrow" ] ,
85+ [ "Hand" , "pointer" ] ,
86+ [ "Text" , "text" ] ,
87+ ] as const ) {
88+ expect (
89+ within ( panel )
90+ . getByRole ( "button" , { name : label } )
91+ . querySelector ( "img" )
92+ ?. getAttribute ( "src" ) ,
93+ ) . toContain ( DEFAULT_CURSOR_SPRITES [ kind ] . assetPath ) ;
94+ }
95+ fireEvent . pointerDown ( within ( panel ) . getByRole ( "button" , { name : "Text" } ) ) ;
96+ expect ( screen . getByRole ( "group" , { name : "Custom cursor" } ) ) . toBeTruthy ( ) ;
97+ fireEvent . pointerDown ( document . body ) ;
98+ expect ( screen . queryByRole ( "group" , { name : "Custom cursor" } ) ) . toBeNull ( ) ;
99+ } ) ;
100+
101+ it ( "closes with Escape and toggles from the plus button" , ( ) => {
102+ renderWithProject ( ) ;
103+ const add = screen . getByRole ( "button" , { name : "Add custom cursor" } ) ;
104+ fireEvent . click ( add ) ;
105+ fireEvent . keyDown ( document , { key : "Escape" } ) ;
106+ expect ( screen . queryByRole ( "group" , { name : "Custom cursor" } ) ) . toBeNull ( ) ;
107+ fireEvent . click ( add ) ;
108+ fireEvent . pointerDown ( add ) ;
109+ fireEvent . click ( add ) ;
110+ expect ( screen . queryByRole ( "group" , { name : "Custom cursor" } ) ) . toBeNull ( ) ;
111+ } ) ;
112+
113+ it ( "uploads one image with a blank MIME type, then adds other states without losing it" , async ( ) => {
114+ const { container } = renderWithProject ( ) ;
115+ fireEvent . click ( screen . getByRole ( "button" , { name : "Add custom cursor" } ) ) ;
116+ const panel = screen . getByRole ( "group" , { name : "Custom cursor" } ) ;
117+ const input = container . querySelector < HTMLInputElement > ( 'input[type="file"]' ) ;
118+ if ( ! input ) throw new Error ( "Missing cursor upload input" ) ;
119+ let uploads = 0 ;
120+ const upload = async ( label : string , name : string , contents : string ) => {
121+ uploads += 1 ;
122+ fireEvent . click ( within ( panel ) . getByRole ( "button" , { name : label } ) ) ;
123+ fireEvent . change ( input , { target : { files : [ new File ( [ contents ] , name ) ] } } ) ;
124+ await waitFor ( ( ) => expect ( nativeBridgeClient . aiEdition . save ) . toHaveBeenCalledTimes ( uploads ) ) ;
125+ } ;
126+ await upload ( "Arrow" , "arrow.PNG" , "arrow-image" ) ;
127+ const arrow = getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorCustomTheme . arrow ;
128+ expect ( arrow ) . toMatch ( / ^ d a t a : i m a g e \/ p n g ; b a s e 6 4 , / ) ;
129+ expect ( screen . getByRole ( "button" , { name : "Custom" } ) . getAttribute ( "aria-pressed" ) ) . toBe (
130+ "true" ,
131+ ) ;
132+ await upload ( "Hand" , "hand.png" , "hand-image" ) ;
133+ await upload ( "Text" , "text.jpg" , "text-image" ) ;
134+ const theme = getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorCustomTheme ;
135+ expect ( theme . arrow ) . toBe ( arrow ) ;
136+ expect ( theme . pointer ) . toMatch ( / ^ d a t a : i m a g e \/ p n g ; b a s e 6 4 , / ) ;
137+ expect ( theme . text ) . toMatch ( / ^ d a t a : i m a g e \/ j p e g ; b a s e 6 4 , / ) ;
138+ await upload ( "Replace Arrow image" , "replacement.png" , "replacement-image" ) ;
139+ const replaced = getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorCustomTheme ;
140+ expect ( replaced . arrow ) . not . toBe ( arrow ) ;
141+ expect ( replaced . pointer ) . toBe ( theme . pointer ) ;
142+ expect ( replaced . text ) . toBe ( theme . text ) ;
143+ expect ( screen . getAllByRole ( "button" , { name : "Custom" } ) ) . toHaveLength ( 1 ) ;
144+ fireEvent . click ( screen . getByRole ( "button" , { name : "Default" } ) ) ;
145+ await waitFor ( ( ) =>
146+ expect ( getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorTheme ) . toBe ( "default" ) ,
147+ ) ;
148+ fireEvent . click ( screen . getByRole ( "button" , { name : "Custom" } ) ) ;
149+ await waitFor ( ( ) =>
150+ expect ( getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorTheme ) . toBe ( "custom" ) ,
151+ ) ;
152+ expect ( getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorCustomTheme ) . toEqual (
153+ replaced ,
154+ ) ;
155+ } ) ;
156+
157+ it ( "removes one custom image while keeping the other images and the selected style" , async ( ) => {
158+ const arrow = "data:image/png;base64,YXJyb3c=" ;
159+ const pointer = "data:image/png;base64,aGFuZA==" ;
160+ renderWithProject ( { cursorTheme : "custom" , cursorCustomTheme : { arrow, pointer } } ) ;
161+ fireEvent . click ( screen . getByRole ( "button" , { name : "Add custom cursor" } ) ) ;
162+ fireEvent . click ( screen . getByRole ( "button" , { name : "Remove Hand image" } ) ) ;
163+ await waitFor ( ( ) => {
164+ const settings = getEditorSettings ( useProjectStore . getState ( ) . document ) ;
165+ expect ( settings . cursorCustomTheme ) . toEqual ( { arrow } ) ;
166+ expect ( settings . cursorTheme ) . toBe ( "custom" ) ;
167+ } ) ;
168+ expect ( screen . queryByRole ( "button" , { name : "Remove Hand image" } ) ) . toBeNull ( ) ;
169+ expect (
170+ within ( screen . getByRole ( "group" , { name : "Custom cursor" } ) ) . getByRole ( "button" , {
171+ name : "Hand" ,
172+ } ) ,
173+ ) . toBeTruthy ( ) ;
174+ } ) ;
175+
176+ it . each ( [
177+ "custom" ,
178+ "studio-ink" ,
179+ ] ) ( "removes the last custom image safely while %s is selected" , async ( selected ) => {
180+ renderWithProject ( {
181+ cursorTheme : selected ,
182+ cursorCustomTheme : { text : "data:image/png;base64,dGV4dA==" } ,
183+ } ) ;
184+ fireEvent . click ( screen . getByRole ( "button" , { name : "Add custom cursor" } ) ) ;
185+ fireEvent . click ( screen . getByRole ( "button" , { name : "Remove Text image" } ) ) ;
186+ await waitFor ( ( ) => {
187+ const settings = getEditorSettings ( useProjectStore . getState ( ) . document ) ;
188+ expect ( settings . cursorCustomTheme ) . toEqual ( { } ) ;
189+ expect ( settings . cursorTheme ) . toBe ( selected === "custom" ? "default" : selected ) ;
190+ } ) ;
191+ expect ( screen . queryByRole ( "button" , { name : "Custom" } ) ) . toBeNull ( ) ;
192+ expect ( screen . queryByRole ( "button" , { name : "Delete custom cursor set" } ) ) . toBeNull ( ) ;
193+ } ) ;
194+
195+ it ( "deletes the whole custom cursor set without closing the upload panel" , async ( ) => {
196+ renderWithProject ( {
197+ cursorTheme : "custom" ,
198+ cursorCustomTheme : {
199+ arrow : "data:image/png;base64,YXJyb3c=" ,
200+ pointer : "data:image/png;base64,aGFuZA==" ,
201+ } ,
202+ } ) ;
203+ fireEvent . click ( screen . getByRole ( "button" , { name : "Add custom cursor" } ) ) ;
204+ fireEvent . click ( screen . getByRole ( "button" , { name : "Delete custom cursor set" } ) ) ;
205+ await waitFor ( ( ) =>
206+ expect ( getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorCustomTheme ) . toEqual ( { } ) ,
207+ ) ;
208+ expect ( getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorTheme ) . toBe ( "default" ) ;
209+ expect ( screen . queryByRole ( "button" , { name : "Custom" } ) ) . toBeNull ( ) ;
210+ expect (
211+ within ( screen . getByRole ( "group" , { name : "Custom cursor" } ) ) . getAllByRole ( "button" ) ,
212+ ) . toHaveLength ( 3 ) ;
213+ } ) ;
214+
215+ it ( "rejects unsupported files without changing the project" , ( ) => {
216+ const error = vi . spyOn ( toast , "error" ) ;
217+ const { container } = renderWithProject ( ) ;
218+ const input = container . querySelector < HTMLInputElement > ( 'input[type="file"]' ) ;
219+ if ( ! input ) throw new Error ( "Missing cursor upload input" ) ;
220+ fireEvent . change ( input , {
221+ target : { files : [ new File ( [ "text" ] , "fake.png" , { type : "text/plain" } ) ] } ,
222+ } ) ;
223+ expect ( error ) . toHaveBeenCalled ( ) ;
224+ expect ( nativeBridgeClient . aiEdition . save ) . not . toHaveBeenCalled ( ) ;
225+ expect ( getEditorSettings ( useProjectStore . getState ( ) . document ) . cursorCustomTheme ) . toEqual ( { } ) ;
226+ } ) ;
53227} ) ;
0 commit comments