diff --git a/clean-architecture-visualizer/README.md b/clean-architecture-visualizer/README.md index ad948a4..7d60ecf 100644 --- a/clean-architecture-visualizer/README.md +++ b/clean-architecture-visualizer/README.md @@ -13,15 +13,15 @@ npm install -g clean-architecture-visualizer ## Usage ```bash -cave init # scaffold a new CSC207-style CA project -cave module_init # scaffold a new project, organized by feature module -cave usecase # generate the boilerplate for a new use case -cave feature # add a new feature to the project -cave module_usecase # add a use case to an existing feature -cave verify # check that use cases follow Clean Architecture rules -cave start # start the backend server and frontend visualizer -cave start --backend-only # start only the backend server -cave end # stop the server and clean up temp files +cave init # scaffold a new CSC207-style CA project in the chosen programming language +cave module_init # scaffold a new project, organized by feature module in the chosen language +cave usecase # generate the boilerplate for a new use case +cave feature # add a new feature to the project +cave module_usecase # add a use case to an existing feature +cave verify # check that use cases follow Clean Architecture rules +cave start # start the backend server and frontend visualizer +cave start --backend-only # start only the backend server +cave end # stop the server and clean up temp files ``` `cave start` launches a local web UI for browsing use case diagrams and CA layer violations in your project. diff --git a/clean-architecture-visualizer/frontend/src/actions/useTemplate.ts b/clean-architecture-visualizer/frontend/src/actions/useTemplate.ts index 239852c..572837c 100644 --- a/clean-architecture-visualizer/frontend/src/actions/useTemplate.ts +++ b/clean-architecture-visualizer/frontend/src/actions/useTemplate.ts @@ -11,7 +11,7 @@ export const useGenerateProject = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: generateProject, + mutationFn: (language: string) => generateProject(language), onSuccess: () => { // invalidate data here queryClient.invalidateQueries({ queryKey: ['file-tree'] }); @@ -23,7 +23,7 @@ export const useGenerateModuleProject = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: generateModuleProject, + mutationFn: (language: string) => generateModuleProject(language), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['file-tree'] }); }, diff --git a/clean-architecture-visualizer/frontend/src/api/template.api.ts b/clean-architecture-visualizer/frontend/src/api/template.api.ts index 54e08d6..9c8093e 100644 --- a/clean-architecture-visualizer/frontend/src/api/template.api.ts +++ b/clean-architecture-visualizer/frontend/src/api/template.api.ts @@ -3,9 +3,9 @@ import apiClient from './apiClient'; /** * Initiates the base folder structure for a Clean Architecture project. */ -export const generateProject = async () => { +export const generateProject = async (language: string) => { const { data } = await apiClient.post<{ message: string }>( - '/template/generate' + `/template/generate/${encodeURIComponent(language)}` ); return data; }; @@ -13,9 +13,9 @@ export const generateProject = async () => { /** * Initiates the base folder structure for a Clean Architecture project packaged by module. */ -export const generateModuleProject = async () => { +export const generateModuleProject = async (language: string) => { const { data } = await apiClient.post<{ message: string }>( - '/template/module_generate' + `/template/module_generate/${encodeURIComponent(language)}` ); return data; }; diff --git a/clean-architecture-visualizer/frontend/src/pages/ProjectStarter/index.tsx b/clean-architecture-visualizer/frontend/src/pages/ProjectStarter/index.tsx index b5b0832..1d67948 100644 --- a/clean-architecture-visualizer/frontend/src/pages/ProjectStarter/index.tsx +++ b/clean-architecture-visualizer/frontend/src/pages/ProjectStarter/index.tsx @@ -46,8 +46,9 @@ const ProjectStarter = () => { const handleCloseSnackbar = () => setSnackbar({ ...snackbar, open: false }); + // The 'java' is temporary until an option to select programming language is added const handleCreateProject = () => { - triggerGenerate(undefined, { + triggerGenerate('java', { onSuccess: (data) => { setSnackbar({ open: true, diff --git a/clean-architecture-visualizer/frontend/tests/unit/actions/useTemplate.test.tsx b/clean-architecture-visualizer/frontend/tests/unit/actions/useTemplate.test.tsx index 93d6511..0e1f2e9 100644 --- a/clean-architecture-visualizer/frontend/tests/unit/actions/useTemplate.test.tsx +++ b/clean-architecture-visualizer/frontend/tests/unit/actions/useTemplate.test.tsx @@ -16,13 +16,13 @@ describe('Template Hooks', () => { vi.restoreAllMocks(); }); - it('useGenerateProject resolves the template response and invalidates file-tree queries', async () => { + it('useGenerateProject resolves the template response and invalidates file-tree queries for java', async () => { const invalidateSpy = vi .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined); server.use( - http.post('*/api/template/generate', () => { + http.post(`*/api/template/generate/${encodeURIComponent('java')}`, () => { return HttpResponse.json( { message: 'Project initiated successfully' }, { status: 201 } @@ -32,30 +32,189 @@ describe('Template Hooks', () => { const { result } = renderHook(() => useGenerateProject()); - result.current.mutate(); + result.current.mutate('java'); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.message).toBe('Project initiated successfully'); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); }); - it('useGenerateModuleProject resolves the template response and invalidates file-tree queries', async () => { + it('useGenerateProject resolves the template response and invalidates file-tree queries for python', async () => { const invalidateSpy = vi .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined); server.use( - http.post('*/api/template/module_generate', () => { - return HttpResponse.json( - { message: 'Project initiated successfully' }, - { status: 201 } - ); - }) + http.post( + `*/api/template/generate/${encodeURIComponent('python')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) + ); + + const { result } = renderHook(() => useGenerateProject()); + + result.current.mutate('python'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe('Project initiated successfully'); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); + }); + + it('useGenerateProject resolves the template response and invalidates file-tree queries for javascript', async () => { + const invalidateSpy = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined); + + server.use( + http.post( + `*/api/template/generate/${encodeURIComponent('javascript')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) + ); + + const { result } = renderHook(() => useGenerateProject()); + + result.current.mutate('javascript'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe('Project initiated successfully'); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); + }); + + it('useGenerateProject resolves the template response and invalidates file-tree queries for typescript', async () => { + const invalidateSpy = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined); + + server.use( + http.post( + `*/api/template/generate/${encodeURIComponent('typescript')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) + ); + + const { result } = renderHook(() => useGenerateProject()); + + result.current.mutate('typescript'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe('Project initiated successfully'); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); + }); + + it('useGenerateModuleProject resolves the template response and invalidates file-tree queries for java', async () => { + const invalidateSpy = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined); + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('java')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) + ); + + const { result } = renderHook(() => useGenerateModuleProject()); + + result.current.mutate('java'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe('Project initiated successfully'); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); + }); + + it('useGenerateModuleProject resolves the template response and invalidates file-tree queries for python', async () => { + const invalidateSpy = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined); + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('python')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) + ); + + const { result } = renderHook(() => useGenerateModuleProject()); + + result.current.mutate('python'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe('Project initiated successfully'); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); + }); + + it('useGenerateModuleProject resolves the template response and invalidates file-tree queries for javascript', async () => { + const invalidateSpy = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined); + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('javascript')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) + ); + + const { result } = renderHook(() => useGenerateModuleProject()); + + result.current.mutate('javascript'); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.message).toBe('Project initiated successfully'); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['file-tree'] }); + }); + + it('useGenerateModuleProject resolves the template response and invalidates file-tree queries for typescript', async () => { + const invalidateSpy = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined); + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('typescript')}`, + () => { + return HttpResponse.json( + { message: 'Project initiated successfully' }, + { status: 201 } + ); + } + ) ); const { result } = renderHook(() => useGenerateModuleProject()); - result.current.mutate(); + result.current.mutate('typescript'); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.message).toBe('Project initiated successfully'); diff --git a/clean-architecture-visualizer/frontend/tests/unit/api/template.api.test.ts b/clean-architecture-visualizer/frontend/tests/unit/api/template.api.test.ts index 248ed94..65d2fcb 100644 --- a/clean-architecture-visualizer/frontend/tests/unit/api/template.api.test.ts +++ b/clean-architecture-visualizer/frontend/tests/unit/api/template.api.test.ts @@ -10,30 +10,119 @@ import { } from '@/api/template.api'; describe('Template API', () => { - it('generateProject posts to /template/generate and returns the response data', async () => { + it('generateProject posts to /template/generate/java and returns the response data', async () => { const mockData = { message: 'Project initiated successfully' }; - server.use( - http.post('*/api/template/generate', () => + http.post(`*/api/template/generate/${encodeURIComponent('java')}`, () => HttpResponse.json(mockData, { status: 201 }) ) ); - const result = await generateProject(); + const result = await generateProject('java'); expect(result).toEqual(mockData); }); - it('generateModuleProject posts to /template/module_generate and returns the response data', async () => { + it('generateProject posts to /template/generate/python and returns the response data', async () => { const mockData = { message: 'Project initiated successfully' }; server.use( - http.post('*/api/template/module_generate', () => + http.post(`*/api/template/generate/${encodeURIComponent('python')}`, () => HttpResponse.json(mockData, { status: 201 }) ) ); - const result = await generateModuleProject(); + const result = await generateProject('python'); + + expect(result).toEqual(mockData); + }); + + it('generateProject posts to /template/generate/javascript and returns the response data', async () => { + const mockData = { message: 'Project initiated successfully' }; + + server.use( + http.post( + `*/api/template/generate/${encodeURIComponent('javascript')}`, + () => HttpResponse.json(mockData, { status: 201 }) + ) + ); + + const result = await generateProject('javascript'); + + expect(result).toEqual(mockData); + }); + + it('generateProject posts to /template/generate/typescript and returns the response data', async () => { + const mockData = { message: 'Project initiated successfully' }; + + server.use( + http.post( + `*/api/template/generate/${encodeURIComponent('typescript')}`, + () => HttpResponse.json(mockData, { status: 201 }) + ) + ); + + const result = await generateProject('typescript'); + + expect(result).toEqual(mockData); + }); + + it('generateModuleProject posts to /template/module_generate/java and returns the response data', async () => { + const mockData = { message: 'Project initiated successfully' }; + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('java')}`, + () => HttpResponse.json(mockData, { status: 201 }) + ) + ); + + const result = await generateModuleProject('java'); + + expect(result).toEqual(mockData); + }); + + it('generateModuleProject posts to /template/module_generate/python and returns the response data', async () => { + const mockData = { message: 'Project initiated successfully' }; + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('python')}`, + () => HttpResponse.json(mockData, { status: 201 }) + ) + ); + + const result = await generateModuleProject('python'); + + expect(result).toEqual(mockData); + }); + + it('generateModuleProject posts to /template/module_generate/javascript and returns the response data', async () => { + const mockData = { message: 'Project initiated successfully' }; + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('javascript')}`, + () => HttpResponse.json(mockData, { status: 201 }) + ) + ); + + const result = await generateModuleProject('javascript'); + + expect(result).toEqual(mockData); + }); + + it('generateModuleProject posts to /template/module_generate/typescript and returns the response data', async () => { + const mockData = { message: 'Project initiated successfully' }; + + server.use( + http.post( + `*/api/template/module_generate/${encodeURIComponent('typescript')}`, + () => HttpResponse.json(mockData, { status: 201 }) + ) + ); + + const result = await generateModuleProject('typescript'); expect(result).toEqual(mockData); }); diff --git a/clean-architecture-visualizer/frontend/tests/unit/pages/ProjectStarter.test.tsx b/clean-architecture-visualizer/frontend/tests/unit/pages/ProjectStarter.test.tsx index 962d473..81f175a 100644 --- a/clean-architecture-visualizer/frontend/tests/unit/pages/ProjectStarter.test.tsx +++ b/clean-architecture-visualizer/frontend/tests/unit/pages/ProjectStarter.test.tsx @@ -51,13 +51,13 @@ describe('ProjectStarter Page', () => { ).toBeInTheDocument(); }); - it('shows a success snackbar after generating a project', async () => { + it('shows a success snackbar after generating a project in java', async () => { render(); fireEvent.click(screen.getByRole('button', { name: 'startNew.button' })); expect(templateMock.generateMutation).toHaveBeenCalledWith( - undefined, + 'java', expect.any(Object) ); expect( diff --git a/clean-architecture-visualizer/src/app/appBuilder.ts b/clean-architecture-visualizer/src/app/appBuilder.ts index aef24a1..bae9663 100644 --- a/clean-architecture-visualizer/src/app/appBuilder.ts +++ b/clean-architecture-visualizer/src/app/appBuilder.ts @@ -305,8 +305,8 @@ export class AppBuilder { await this.graphVerificationController?.execute(formatForCLI); } - async runInitProject() { - await this.initProjectController?.execute(); + async runInitProject(language: string) { + await this.initProjectController?.execute(language); if (this.initProjectOutputData?.getOutputData()) { console.log(chalk.green('Your project has been initialized.')); } else { @@ -318,8 +318,8 @@ export class AppBuilder { } } - async runInitModuleProject() { - await this.initModuleProjectController?.execute(); + async runInitModuleProject(language: string) { + await this.initModuleProjectController?.execute(language); if (this.initModuleProjectOutputData?.getOutputData()) { console.log( chalk.green('Your project packaged by module has been initialized.') diff --git a/clean-architecture-visualizer/src/app/index.ts b/clean-architecture-visualizer/src/app/index.ts index 62b36ad..a8f82a0 100755 --- a/clean-architecture-visualizer/src/app/index.ts +++ b/clean-architecture-visualizer/src/app/index.ts @@ -64,19 +64,19 @@ program }); program - .command('init') + .command('init [language]') .description('Create the template for a new CSC207 project') - .action(async () => { - app.runInitProject(); + .action(async (language: string = 'java') => { + app.runInitProject(language); }); program - .command('module_init') + .command('module_init [language]') .description( 'Create the template for a new CSC207 project, packaged by module.' ) - .action(async () => { - app.runInitModuleProject(); + .action(async (language: string = 'java') => { + app.runInitModuleProject(language); }); program diff --git a/clean-architecture-visualizer/src/interface_adapter/initModuleProject/initModuleProjectController.ts b/clean-architecture-visualizer/src/interface_adapter/initModuleProject/initModuleProjectController.ts index f3ae9a2..e63f8ea 100644 --- a/clean-architecture-visualizer/src/interface_adapter/initModuleProject/initModuleProjectController.ts +++ b/clean-architecture-visualizer/src/interface_adapter/initModuleProject/initModuleProjectController.ts @@ -1,9 +1,11 @@ import type { InitModuleProjectInputBoundary } from '../../use_case/initModuleProject/initModuleProjectInputBoundary.js'; +import { InitModuleProjectInputData } from '../../use_case/initModuleProject/initModuleProjectInputData.js'; export class InitModuleProjectController { constructor(private readonly inputBoundary: InitModuleProjectInputBoundary) {} - async execute() { - await this.inputBoundary.execute(); + async execute(language: string) { + const initModuleProjectInputData = new InitModuleProjectInputData(language); + await this.inputBoundary.execute(initModuleProjectInputData); } } diff --git a/clean-architecture-visualizer/src/interface_adapter/initProject/initProjectController.ts b/clean-architecture-visualizer/src/interface_adapter/initProject/initProjectController.ts index f8fe94b..06149ed 100644 --- a/clean-architecture-visualizer/src/interface_adapter/initProject/initProjectController.ts +++ b/clean-architecture-visualizer/src/interface_adapter/initProject/initProjectController.ts @@ -1,9 +1,11 @@ import type { InitProjectInputBoundary } from '../../use_case/initProject/initProjectInputBoundary.js'; +import { InitProjectInputData } from '../../use_case/initProject/initProjectInputData.js'; export class InitProjectController { constructor(private readonly inputBoundary: InitProjectInputBoundary) {} - async execute(): Promise { - await this.inputBoundary.execute(); + async execute(language: string): Promise { + const initProjectInputData = new InitProjectInputData(language); + await this.inputBoundary.execute(initProjectInputData); } } diff --git a/clean-architecture-visualizer/src/server/routes/template.ts b/clean-architecture-visualizer/src/server/routes/template.ts index 073cd95..7ec361c 100644 --- a/clean-architecture-visualizer/src/server/routes/template.ts +++ b/clean-architecture-visualizer/src/server/routes/template.ts @@ -22,13 +22,13 @@ const router = Router(); const fileAccess = new FileAccess(); -router.post('/template/generate', async (_req, res) => { +router.post('/template/generate/:language', async (req, res) => { const outputData = new InitProjectOutputData(); const interactor = new InitProjectInteractor(fileAccess, outputData); const controller = new InitProjectController(interactor); const presenter = new InitProjectPresenter(outputData); - await controller.execute(); + await controller.execute(req.params.language); const result = presenter.getOutputData(); if (!result) { @@ -39,13 +39,13 @@ router.post('/template/generate', async (_req, res) => { res.status(201).json({ message: `Project initiated successfully` }); }); -router.post('/template/module_generate', async (_req, res) => { +router.post('/template/module_generate/:language', async (req, res) => { const outputData = new InitModuleProjectOutputData(); const interactor = new InitModuleProjectInteractor(fileAccess, outputData); const controller = new InitModuleProjectController(interactor); const presenter = new InitModuleProjectPresenter(outputData); - await controller.execute(); + await controller.execute(req.params.language); const result = presenter.getOutputData(); if (!result) { diff --git a/clean-architecture-visualizer/src/use_case/createModuleUseCase/createModuleUseCaseInteractor.ts b/clean-architecture-visualizer/src/use_case/createModuleUseCase/createModuleUseCaseInteractor.ts index 4c61fc0..3b1148d 100644 --- a/clean-architecture-visualizer/src/use_case/createModuleUseCase/createModuleUseCaseInteractor.ts +++ b/clean-architecture-visualizer/src/use_case/createModuleUseCase/createModuleUseCaseInteractor.ts @@ -16,8 +16,30 @@ export class CreateModuleUseCaseInteractor implements CreateModuleUseCaseInputBo // remove all spaces from use case name and feature name const feature = inputData.getFeatureName().split(' ').join(''); const usecase = inputData.getUseCaseName().split(' ').join(''); - // check if features directory exists const currPath = await this.fileAccess.getCurrentPath(); + // Find the language directory -- makes assumption only one directory is named after language + let extension: string | undefined = undefined; + const languageToExtension = new Map([ + ['python', 'py'], + ['java', 'java'], + ['javascript', 'js'], + ['typescript', 'ts'], + ]); + for (const [language, ext] of languageToExtension) { + if (await this.fileAccess.bfsFindDir(currPath, language)) { + extension = ext; + break; + } + } + + if (extension === undefined) { + this.presenter.showFailView( + 'Your project does not have a specified programming language. You must create a directory that has the name: java, python, typescript, or javascript.' + ); + return; + } + + // check if features directory exists const featuresDirectory = await this.fileAccess.bfsFindDir( currPath, 'features' @@ -36,14 +58,16 @@ export class CreateModuleUseCaseInteractor implements CreateModuleUseCaseInputBo ); if (!currFeatureDirectory) { this.presenter.showFailView( - 'The input feature does not exist in the features directory.' + 'The input feature does not exist in the features directory. Please choose a feature that does exist or create this feature.' ); return; } // check if usecase already exists. if (await this.fileAccess.bfsFindDir(featuresDirectory, usecase)) { - this.presenter.showFailView('The input usecase already exists.'); + this.presenter.showFailView( + 'The input usecase already exists. Please choose a different name.' + ); return; } @@ -57,8 +81,8 @@ export class CreateModuleUseCaseInteractor implements CreateModuleUseCaseInputBo await this.fileAccess.createDirectory(ucPath); // Create all files. - const createJavaFile = async (dir: string, suffix: string) => { - const fileName = `${usecase}${suffix}.java`; + const createFile = async (dir: string, suffix: string) => { + const fileName = `${usecase}${suffix}.${extension}`; const fullPath = path.join(dir, fileName); return await this.fileAccess.createFile(fullPath); }; @@ -72,11 +96,11 @@ export class CreateModuleUseCaseInteractor implements CreateModuleUseCaseInputBo ]; for (const ucFile of ucFiles) { - await createJavaFile(ucPath, ucFile); + await createFile(ucPath, ucFile); } - await createJavaFile(iaPath, 'Controller'); - await createJavaFile(iaPath, 'Presenter'); + await createFile(iaPath, 'Controller'); + await createFile(iaPath, 'Presenter'); const outputData = new CreateModuleUseCaseOutputData(feature, usecase); this.presenter.showSuccessView(outputData); diff --git a/clean-architecture-visualizer/src/use_case/createUseCase/createUseCaseInteractor.ts b/clean-architecture-visualizer/src/use_case/createUseCase/createUseCaseInteractor.ts index b0e97e4..a31f79a 100644 --- a/clean-architecture-visualizer/src/use_case/createUseCase/createUseCaseInteractor.ts +++ b/clean-architecture-visualizer/src/use_case/createUseCase/createUseCaseInteractor.ts @@ -19,6 +19,28 @@ export class CreateUseCaseInteractor implements CreateUseCaseInputBoundary { .join(''); const currPath = await this.fileAccess.getCurrentPath(); + // Find the language directory -- makes assumption only one directory is named after language + let extension: string | undefined = undefined; + const languageToExtension = new Map([ + ['python', 'py'], + ['java', 'java'], + ['javascript', 'js'], + ['typescript', 'ts'], + ]); + for (const [language, ext] of languageToExtension) { + if (await this.fileAccess.bfsFindDir(currPath, language)) { + extension = ext; + break; + } + } + + if (extension === undefined) { + this.presenter.showFailView( + 'Your project does not have a specified programming language. You must create a directory that has the name: java, python, typescript, or javascript.' + ); + return; + } + // Find base directories const useCaseDir = await this.fileAccess.bfsFindDir(currPath, 'use_case'); const interfaceAdapterDir = await this.fileAccess.bfsFindDir( @@ -40,7 +62,9 @@ export class CreateUseCaseInteractor implements CreateUseCaseInputBoundary { const useCaseExists = await this.fileAccess.exists(targetUseCasePath); const interfaceExists = await this.fileAccess.exists(targetInterfacePath); if (useCaseExists || interfaceExists) { - this.presenter.showFailView(`Usecase ${useCaseName} already exists.`); + this.presenter.showFailView( + `Usecase ${useCaseName} already exists. Please choose a different name.` + ); return; } @@ -48,22 +72,22 @@ export class CreateUseCaseInteractor implements CreateUseCaseInputBoundary { await this.fileAccess.createDirectory(targetUseCasePath); await this.fileAccess.createDirectory(targetInterfacePath); - const createJavaFile = async (dir: string, suffix: string) => { - const fileName = `${useCaseName}${suffix}.java`; + const createFile = async (dir: string, suffix: string) => { + const fileName = `${useCaseName}${suffix}.${extension}`; const fullPath = path.join(dir, fileName); return await this.fileAccess.createFile(fullPath); }; // Use Case Layer Files - await createJavaFile(targetUseCasePath, 'InputBoundary'); - await createJavaFile(targetUseCasePath, 'InputData'); - await createJavaFile(targetUseCasePath, 'UseCaseInteractor'); - await createJavaFile(targetUseCasePath, 'OutputData'); - await createJavaFile(targetUseCasePath, 'OutputBoundary'); + await createFile(targetUseCasePath, 'InputBoundary'); + await createFile(targetUseCasePath, 'InputData'); + await createFile(targetUseCasePath, 'UseCaseInteractor'); + await createFile(targetUseCasePath, 'OutputData'); + await createFile(targetUseCasePath, 'OutputBoundary'); // Interface Adapter Layer Files - await createJavaFile(targetInterfacePath, 'Controller'); - await createJavaFile(targetInterfacePath, 'Presenter'); + await createFile(targetInterfacePath, 'Controller'); + await createFile(targetInterfacePath, 'Presenter'); const createUseCaseOutputData = new CreateUseCaseOutputData(useCaseName); this.presenter.showSuccessView(createUseCaseOutputData); diff --git a/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputBoundary.ts b/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputBoundary.ts index c1267ab..e053cea 100644 --- a/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputBoundary.ts +++ b/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputBoundary.ts @@ -1,3 +1,7 @@ +import { InitModuleProjectInputData } from './initModuleProjectInputData.js'; + export interface InitModuleProjectInputBoundary { - execute(): Promise; + execute( + initModuleProjectInputData: InitModuleProjectInputData + ): Promise; } diff --git a/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputData.ts b/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputData.ts new file mode 100644 index 0000000..8307876 --- /dev/null +++ b/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInputData.ts @@ -0,0 +1,7 @@ +export class InitModuleProjectInputData { + constructor(private readonly language: string) {} + + getLanguage(): string { + return this.language; + } +} diff --git a/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInteractor.ts b/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInteractor.ts index 56f0b8c..57665fa 100644 --- a/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInteractor.ts +++ b/clean-architecture-visualizer/src/use_case/initModuleProject/initModuleProjectInteractor.ts @@ -1,6 +1,7 @@ import type { FileAccessInterface } from '../../data_access/fileAccessInterface.js'; import type { InitModuleProjectInputBoundary } from './initModuleProjectInputBoundary.js'; import { InitModuleProjectOutputData } from './initModuleProjectOutputData.js'; +import { InitModuleProjectInputData } from './initModuleProjectInputData.js'; import path from 'path'; export class InitModuleProjectInteractor implements InitModuleProjectInputBoundary { @@ -14,8 +15,18 @@ export class InitModuleProjectInteractor implements InitModuleProjectInputBounda this.outputData = outputData; } - async execute(): Promise { + async execute( + initModuleProjectInputData: InitModuleProjectInputData + ): Promise { try { + const acceptedLanguage = ['typescript', 'javascript', 'java', 'python']; + let language = initModuleProjectInputData.getLanguage().trim(); + if (!acceptedLanguage.includes(language.toLowerCase())) { + throw new Error( + 'You must enter a valid programming language of: java, python, typescript, or javascript. Blank defaults to Java.' + ); + } + let currPath = await this.fileAccess.getCurrentPath(); currPath = path.join(currPath, 'src'); // Create the src directory if it does not exist. @@ -31,10 +42,14 @@ export class InitModuleProjectInteractor implements InitModuleProjectInputBounda throw new Error('project already initialized.'); } - // The project does support TS, JS, Python, and Java, but will do Java for now for consistency - const javaPath = path.join(currPath, 'main', 'java'); - const testPath = path.join(currPath, 'test', 'java'); - await this.fileAccess.createDirectory(javaPath); + // The project does support TS, JS, Python, and Java + const programmingPath = path.join( + currPath, + 'main', + language.toLowerCase() + ); + const testPath = path.join(currPath, 'test', language.toLowerCase()); + await this.fileAccess.createDirectory(programmingPath); await this.fileAccess.createDirectory(testPath); // Create code structure for packaging by module. @@ -48,7 +63,7 @@ export class InitModuleProjectInteractor implements InitModuleProjectInputBounda ]; for (const directoryName of subDirectories) { await this.fileAccess.createDirectory( - path.join(javaPath, directoryName) + path.join(programmingPath, directoryName) ); } diff --git a/clean-architecture-visualizer/src/use_case/initProject/initProjectInputBoundary.ts b/clean-architecture-visualizer/src/use_case/initProject/initProjectInputBoundary.ts index 21bde71..7e68bcf 100644 --- a/clean-architecture-visualizer/src/use_case/initProject/initProjectInputBoundary.ts +++ b/clean-architecture-visualizer/src/use_case/initProject/initProjectInputBoundary.ts @@ -1,3 +1,5 @@ +import { InitProjectInputData } from './initProjectInputData.js'; + export interface InitProjectInputBoundary { - execute(): Promise; + execute(initProjectInputData: InitProjectInputData): Promise; } diff --git a/clean-architecture-visualizer/src/use_case/initProject/initProjectInputData.ts b/clean-architecture-visualizer/src/use_case/initProject/initProjectInputData.ts new file mode 100644 index 0000000..6356e96 --- /dev/null +++ b/clean-architecture-visualizer/src/use_case/initProject/initProjectInputData.ts @@ -0,0 +1,7 @@ +export class InitProjectInputData { + constructor(private readonly language: string) {} + + getLanguage(): string { + return this.language; + } +} diff --git a/clean-architecture-visualizer/src/use_case/initProject/initProjectInteractor.ts b/clean-architecture-visualizer/src/use_case/initProject/initProjectInteractor.ts index bc242e0..d3f8a61 100644 --- a/clean-architecture-visualizer/src/use_case/initProject/initProjectInteractor.ts +++ b/clean-architecture-visualizer/src/use_case/initProject/initProjectInteractor.ts @@ -1,5 +1,6 @@ import type { FileAccessInterface } from '../../data_access/fileAccessInterface.js'; import type { InitProjectInputBoundary } from './initProjectInputBoundary.js'; +import { InitProjectInputData } from './initProjectInputData.js'; import { InitProjectOutputData } from './initProjectOutputData.js'; import path from 'path'; @@ -14,8 +15,16 @@ export class InitProjectInteractor implements InitProjectInputBoundary { ((this.fileAccess = fileAccess), (this.outputData = outputData)); } - async execute(): Promise { + async execute(initProjectInputData: InitProjectInputData): Promise { try { + const acceptedLanguage = ['typescript', 'javascript', 'java', 'python']; + let language = initProjectInputData.getLanguage().trim(); + if (!acceptedLanguage.includes(language.toLowerCase())) { + throw new Error( + 'You must enter a valid programming language of: java, python, typescript, or javascript. Blank defaults to Java.' + ); + } + let currPath = await this.fileAccess.getCurrentPath(); currPath = path.join(currPath, 'src'); const res = await this.fileAccess.exists(currPath); @@ -32,8 +41,12 @@ export class InitProjectInteractor implements InitProjectInputBoundary { } // 1. Define base paths using path.join for cross-platform support - const javaPath = path.join(currPath, 'main', 'java'); - const testPath = path.join(currPath, 'test', 'java'); + const programmingPath = path.join( + currPath, + 'main', + language.toLowerCase() + ); + const testPath = path.join(currPath, 'test', language.toLowerCase()); // 2. Define sub-directories within the java path const subDirs = [ @@ -46,11 +59,11 @@ export class InitProjectInteractor implements InitProjectInputBoundary { 'database', ]; - await this.fileAccess.createDirectory(javaPath); + await this.fileAccess.createDirectory(programmingPath); await this.fileAccess.createDirectory(testPath); for (const dirName of subDirs) { - const fullPath = path.join(javaPath, dirName); + const fullPath = path.join(programmingPath, dirName); await this.fileAccess.createDirectory(fullPath); } diff --git a/clean-architecture-visualizer/tests/backend/use_cases/createModuleUseCase.test.ts b/clean-architecture-visualizer/tests/backend/use_cases/createModuleUseCase.test.ts index 79bbb97..aea2b78 100644 --- a/clean-architecture-visualizer/tests/backend/use_cases/createModuleUseCase.test.ts +++ b/clean-architecture-visualizer/tests/backend/use_cases/createModuleUseCase.test.ts @@ -29,10 +29,11 @@ describe('CreateFeatureInteractor', () => { ); }); - it('Successfully creates files in specified directories in specified use case in specified feature.', async () => { + it('Successfully creates files in specified directories in specified use case in specified feature in java.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue('/root'); - // bfsFindDir runs twice and will need two different implementations - // The first will find the features directory and the second will find the specific feature + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/root/src/java'); mockFileAccess.bfsFindDir .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) .mockImplementationOnce( @@ -68,10 +69,133 @@ describe('CreateFeatureInteractor', () => { expect(mockPresenter.showFailView).not.toHaveBeenCalled(); }); + it('Successfully creates files in specified directories in specified use case in specified feature in python.', async () => { + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); + mockFileAccess.bfsFindDir + .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) + .mockImplementationOnce( + async (_, dirName) => `/root/src/features/${dirName}` + ) + .mockImplementationOnce(async (_, dirName) => null); + const inputData = new CreateModuleUseCaseInputData( + 'newFeature', + 'newUseCase' + ); + await interactor.execute(inputData); + + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/features/newFeature/newUseCase/interface_adapter' + ); + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/features/newFeature/newUseCase/use_case' + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseInputBoundary.py') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseUseCaseInteractor.py') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseController.py') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledTimes(7); + + expect(mockPresenter.showSuccessView).toHaveBeenCalledWith( + new CreateModuleUseCaseOutputData('newFeature', 'newUseCase') + ); + expect(mockPresenter.showFailView).not.toHaveBeenCalled(); + }); + + it('Successfully creates files in specified directories in specified use case in specified feature in javascript.', async () => { + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/root/src/javascript'); + mockFileAccess.bfsFindDir + .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) + .mockImplementationOnce( + async (_, dirName) => `/root/src/features/${dirName}` + ) + .mockImplementationOnce(async (_, dirName) => null); + const inputData = new CreateModuleUseCaseInputData( + 'newFeature', + 'newUseCase' + ); + await interactor.execute(inputData); + + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/features/newFeature/newUseCase/interface_adapter' + ); + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/features/newFeature/newUseCase/use_case' + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseInputBoundary.js') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseUseCaseInteractor.js') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseController.js') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledTimes(7); + + expect(mockPresenter.showSuccessView).toHaveBeenCalledWith( + new CreateModuleUseCaseOutputData('newFeature', 'newUseCase') + ); + expect(mockPresenter.showFailView).not.toHaveBeenCalled(); + }); + + it('Successfully creates files in specified directories in specified use case in specified feature in typescript.', async () => { + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('/root/src/typescript'); + mockFileAccess.bfsFindDir + .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) + .mockImplementationOnce( + async (_, dirName) => `/root/src/features/${dirName}` + ) + .mockImplementationOnce(async (_, dirName) => null); + const inputData = new CreateModuleUseCaseInputData( + 'newFeature', + 'newUseCase' + ); + await interactor.execute(inputData); + + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/features/newFeature/newUseCase/interface_adapter' + ); + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/features/newFeature/newUseCase/use_case' + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseInputBoundary.ts') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseUseCaseInteractor.ts') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('newUseCaseController.ts') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledTimes(7); + + expect(mockPresenter.showSuccessView).toHaveBeenCalledWith( + new CreateModuleUseCaseOutputData('newFeature', 'newUseCase') + ); + expect(mockPresenter.showFailView).not.toHaveBeenCalled(); + }); + it('Fails to create files because features directory does not exist.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue('/root'); // bfsFindDir will only run once when trying to find features - mockFileAccess.bfsFindDir.mockResolvedValue(null); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce('/root/src/python') + .mockResolvedValueOnce(null); const inputData = new CreateModuleUseCaseInputData( 'newFeature', 'newUseCase' @@ -85,6 +209,12 @@ describe('CreateFeatureInteractor', () => { it('Fails to create files because input feature does not exist.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('root/src/typescript'); + mockFileAccess.bfsFindDir .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) .mockImplementationOnce(async (_, dirName) => null); @@ -95,12 +225,13 @@ describe('CreateFeatureInteractor', () => { await interactor.execute(inputData); expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); expect(mockPresenter.showFailView).toHaveBeenCalledWith( - 'The input feature does not exist in the features directory.' + 'The input feature does not exist in the features directory. Please choose a feature that does exist or create this feature.' ); }); it('Fails to create files because use case already exists.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); mockFileAccess.bfsFindDir .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) .mockImplementationOnce( @@ -116,11 +247,12 @@ describe('CreateFeatureInteractor', () => { await interactor.execute(inputData); expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); expect(mockPresenter.showFailView).toHaveBeenCalledWith( - 'The input usecase already exists.' + 'The input usecase already exists. Please choose a different name.' ); }); it('Fails to create files because use case already exists in another directory.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); mockFileAccess.bfsFindDir .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) .mockImplementationOnce( @@ -136,13 +268,14 @@ describe('CreateFeatureInteractor', () => { await interactor.execute(inputData); expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); expect(mockPresenter.showFailView).toHaveBeenCalledWith( - 'The input usecase already exists.' + 'The input usecase already exists. Please choose a different name.' ); }); it('Fails if an unexpected error occurs during directory creation.', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); mockFileAccess.bfsFindDir .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) .mockImplementationOnce( @@ -160,6 +293,7 @@ describe('CreateFeatureInteractor', () => { it('Fails if an unexpected error occurs during file creation.', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); mockFileAccess.bfsFindDir .mockImplementationOnce(async (_, dirName) => `/root/src/${dirName}`) .mockImplementationOnce( @@ -175,4 +309,19 @@ describe('CreateFeatureInteractor', () => { expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); expect(mockPresenter.showFailView).toHaveBeenCalled(); }); + + it('Fails if there is no specified language.', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValue(null) + .mockResolvedValue(null) + .mockResolvedValue(null) + .mockResolvedValue(null); + await interactor.execute( + new CreateModuleUseCaseInputData('newFeature', 'newUseCase') + ); + expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); + expect(mockPresenter.showFailView).toHaveBeenCalled(); + }); }); diff --git a/clean-architecture-visualizer/tests/backend/use_cases/createUseCase.test.ts b/clean-architecture-visualizer/tests/backend/use_cases/createUseCase.test.ts index 9f9d221..febada7 100644 --- a/clean-architecture-visualizer/tests/backend/use_cases/createUseCase.test.ts +++ b/clean-architecture-visualizer/tests/backend/use_cases/createUseCase.test.ts @@ -30,9 +30,12 @@ describe('CreateUseCaseInteractor', () => { // TODO: Add a separate test case checking if use case names with spaces wer properly sanitized - it('successfully creates directories and files for a valid use case name', async () => { + it('successfully creates directories and files for a valid use case name in java', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('root/src/java'); mockFileAccess.bfsFindDir.mockImplementation( async (path, dirName) => `/root/src/${dirName}` ); @@ -68,9 +71,134 @@ describe('CreateUseCaseInteractor', () => { expect(mockPresenter.showFailView).not.toHaveBeenCalled(); }); + it('successfully creates directories and files for a valid use case name in python', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('root/src/python'); + mockFileAccess.bfsFindDir.mockImplementation( + async (path, dirName) => `/root/src/${dirName}` + ); + + // Act + const inputData = new CreateUseCaseInputData('LoginUser'); + await interactor.execute(inputData); + + // Assert + // Verify directory creation + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/use_case/LoginUser' + ); + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/interface_adapter/LoginUser' + ); + + // Verify key files were created + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserInputBoundary.py') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserUseCaseInteractor.py') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserController.py') + ); + + // Verify success signal + expect(mockPresenter.showSuccessView).toHaveBeenCalledWith( + new CreateUseCaseOutputData('LoginUser') + ); + expect(mockPresenter.showFailView).not.toHaveBeenCalled(); + }); + + it('successfully creates directories and files for a valid use case name in javascript', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('root/src/javascript'); + mockFileAccess.bfsFindDir.mockImplementation( + async (path, dirName) => `/root/src/${dirName}` + ); + + // Act + const inputData = new CreateUseCaseInputData('LoginUser'); + await interactor.execute(inputData); + + // Assert + // Verify directory creation + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/use_case/LoginUser' + ); + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/interface_adapter/LoginUser' + ); + + // Verify key files were created + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserInputBoundary.js') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserUseCaseInteractor.js') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserController.js') + ); + + // Verify success signal + expect(mockPresenter.showSuccessView).toHaveBeenCalledWith( + new CreateUseCaseOutputData('LoginUser') + ); + expect(mockPresenter.showFailView).not.toHaveBeenCalled(); + }); + + it('successfully creates directories and files for a valid use case name in typescript', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('root/src/typescript'); + mockFileAccess.bfsFindDir.mockImplementation( + async (path, dirName) => `/root/src/${dirName}` + ); + + // Act + const inputData = new CreateUseCaseInputData('LoginUser'); + await interactor.execute(inputData); + + // Assert + // Verify directory creation + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/use_case/LoginUser' + ); + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith( + '/root/src/interface_adapter/LoginUser' + ); + + // Verify key files were created + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserInputBoundary.ts') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserUseCaseInteractor.ts') + ); + expect(mockFileAccess.createFile).toHaveBeenCalledWith( + expect.stringContaining('LoginUserController.ts') + ); + + // Verify success signal + expect(mockPresenter.showSuccessView).toHaveBeenCalledWith( + new CreateUseCaseOutputData('LoginUser') + ); + expect(mockPresenter.showFailView).not.toHaveBeenCalled(); + }); + it('fails and sets output data to false if directories are not found', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); // Simulate missing directory mockFileAccess.bfsFindDir.mockResolvedValue(null); @@ -89,6 +217,7 @@ describe('CreateUseCaseInteractor', () => { it('fails and sets output data to false if directories are already present', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir.mockResolvedValueOnce('/root/src/python'); mockFileAccess.bfsFindDir.mockImplementation( async (path, dirName) => `/root/src/${dirName}` ); @@ -101,7 +230,7 @@ describe('CreateUseCaseInteractor', () => { // Assert expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); expect(mockPresenter.showFailView).toHaveBeenCalledWith( - 'Usecase Test already exists.' + 'Usecase Test already exists. Please choose a different name.' ); // Ensure no files or directories were attempted to be created expect(mockFileAccess.createFile).not.toHaveBeenCalled(); @@ -111,7 +240,9 @@ describe('CreateUseCaseInteractor', () => { it('fails if an unexpected error occurs during file creation', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue('/root'); - mockFileAccess.bfsFindDir.mockResolvedValue('/root/dir'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce('/root/src/python') + .mockResolvedValueOnce('/root/dir'); mockFileAccess.createFile.mockRejectedValue(new Error('Disk Full')); // Act @@ -121,4 +252,21 @@ describe('CreateUseCaseInteractor', () => { expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); expect(mockPresenter.showFailView).toHaveBeenCalled(); }); + + it('fails if there is no programming language directory', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue('/root'); + mockFileAccess.bfsFindDir + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + // Act + await interactor.execute(new CreateUseCaseInputData('Test')); + + // Assert + expect(mockPresenter.showSuccessView).not.toHaveBeenCalled(); + expect(mockPresenter.showFailView).toHaveBeenCalled(); + }); }); diff --git a/clean-architecture-visualizer/tests/backend/use_cases/initModuleProject.test.ts b/clean-architecture-visualizer/tests/backend/use_cases/initModuleProject.test.ts index e16a798..2e23b8f 100644 --- a/clean-architecture-visualizer/tests/backend/use_cases/initModuleProject.test.ts +++ b/clean-architecture-visualizer/tests/backend/use_cases/initModuleProject.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import type { FileAccessInterface } from '../../../src/data_access/fileAccessInterface.js'; import type { InitModuleProjectOutputData } from '../../../src/use_case/initModuleProject/initModuleProjectOutputData.js'; import { InitModuleProjectInteractor } from '../../../src/use_case/initModuleProject/initModuleProjectInteractor.js'; +import { InitModuleProjectInputData } from '../../../src/use_case/initModuleProject/initModuleProjectInputData.js'; describe('InitModuleProjectInteractor', () => { let mockFileAccess: jest.Mocked; @@ -29,10 +30,10 @@ describe('InitModuleProjectInteractor', () => { ); }); - it('Creates CA directory that is packaged by module (src exists).', async () => { + it('Creates CA directory that is packaged by module (src exists) in java.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); mockFileAccess.exists.mockResolvedValue(true); - await interactor.execute(); + await interactor.execute(new InitModuleProjectInputData('Java')); const expectedDirs = [ `${ROOT_PATH}/src/main/java`, @@ -56,10 +57,96 @@ describe('InitModuleProjectInteractor', () => { expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); }); + it('Creates CA directory that is packaged by module (src exists) in python.', async () => { + mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); + mockFileAccess.exists.mockResolvedValue(true); + await interactor.execute(new InitModuleProjectInputData('python')); + + const expectedDirs = [ + `${ROOT_PATH}/src/main/python`, + `${ROOT_PATH}/src/test/python`, + `${ROOT_PATH}/src/main/python/features`, + `${ROOT_PATH}/src/main/python/data_access`, + `${ROOT_PATH}/src/main/python/entity`, + `${ROOT_PATH}/src/main/python/app`, + `${ROOT_PATH}/src/main/python/views`, + `${ROOT_PATH}/src/main/python/database`, + ]; + + expectedDirs.forEach((dir) => { + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith(dir); + }); + + expect(mockFileAccess.createDirectory).toHaveBeenCalledTimes( + expectedDirs.length + ); + + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); + }); + + it('Creates CA directory that is packaged by module (src exists) in javascript.', async () => { + mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); + mockFileAccess.exists.mockResolvedValue(true); + await interactor.execute(new InitModuleProjectInputData('javascript')); + + const expectedDirs = [ + `${ROOT_PATH}/src/main/javascript`, + `${ROOT_PATH}/src/test/javascript`, + `${ROOT_PATH}/src/main/javascript/features`, + `${ROOT_PATH}/src/main/javascript/data_access`, + `${ROOT_PATH}/src/main/javascript/entity`, + `${ROOT_PATH}/src/main/javascript/app`, + `${ROOT_PATH}/src/main/javascript/views`, + `${ROOT_PATH}/src/main/javascript/database`, + ]; + + expectedDirs.forEach((dir) => { + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith(dir); + }); + + expect(mockFileAccess.createDirectory).toHaveBeenCalledTimes( + expectedDirs.length + ); + + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); + }); + + it('Creates CA directory that is packaged by module (src exists) in typescript.', async () => { + mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); + mockFileAccess.exists.mockResolvedValue(true); + await interactor.execute(new InitModuleProjectInputData('typescript')); + + const expectedDirs = [ + `${ROOT_PATH}/src/main/typescript`, + `${ROOT_PATH}/src/test/typescript`, + `${ROOT_PATH}/src/main/typescript/features`, + `${ROOT_PATH}/src/main/typescript/data_access`, + `${ROOT_PATH}/src/main/typescript/entity`, + `${ROOT_PATH}/src/main/typescript/app`, + `${ROOT_PATH}/src/main/typescript/views`, + `${ROOT_PATH}/src/main/typescript/database`, + ]; + + expectedDirs.forEach((dir) => { + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith(dir); + }); + + expect(mockFileAccess.createDirectory).toHaveBeenCalledTimes( + expectedDirs.length + ); + + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); + }); + + it('Error occurs because the programming language is invalid.', async () => { + await interactor.execute(new InitModuleProjectInputData('blah blah blah')); + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); + }); + it('Error occurs because src directory does not exist.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); mockFileAccess.exists.mockResolvedValue(false); - await interactor.execute(); + await interactor.execute(new InitModuleProjectInputData('java')); expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); }); @@ -69,13 +156,13 @@ describe('InitModuleProjectInteractor', () => { new Error('Permission denied') ); - await interactor.execute(); + await interactor.execute(new InitModuleProjectInputData('java')); expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); }); it('Handles errors if getCurrentPath fails.', async () => { mockFileAccess.getCurrentPath.mockRejectedValue(new Error('Path unknown')); - await interactor.execute(); + await interactor.execute(new InitModuleProjectInputData('java')); // Assert expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); @@ -87,7 +174,7 @@ describe('InitModuleProjectInteractor', () => { mockFileAccess.bfsFindDir.mockResolvedValue(`${ROOT_PATH}/src/main`); mockFileAccess.exists.mockResolvedValue(true); - await interactor.execute(); + await interactor.execute(new InitModuleProjectInputData('java')); expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); expect(mockFileAccess.createDirectory).not.toHaveBeenCalled(); }); @@ -97,7 +184,7 @@ describe('InitModuleProjectInteractor', () => { mockFileAccess.bfsFindDir.mockResolvedValue(`${ROOT_PATH}/src/test`); mockFileAccess.exists.mockResolvedValue(true); - await interactor.execute(); + await interactor.execute(new InitModuleProjectInputData('java')); expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); expect(mockFileAccess.createDirectory).not.toHaveBeenCalled(); }); diff --git a/clean-architecture-visualizer/tests/backend/use_cases/intiProject.test.ts b/clean-architecture-visualizer/tests/backend/use_cases/intiProject.test.ts index 4420a43..4fbbb70 100644 --- a/clean-architecture-visualizer/tests/backend/use_cases/intiProject.test.ts +++ b/clean-architecture-visualizer/tests/backend/use_cases/intiProject.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { InitProjectInteractor } from '../../../src/use_case/initProject/initProjectInteractor.js'; import type { FileAccessInterface } from '../../../src/data_access/fileAccessInterface.js'; import type { InitProjectOutputData } from '../../../src/use_case/initProject/initProjectOutputData.js'; +import { InitProjectInputData } from '../../../src/use_case/initProject/initProjectInputData.js'; describe('InitProjectInteractor', () => { let mockFileAccess: jest.Mocked; @@ -28,13 +29,13 @@ describe('InitProjectInteractor', () => { interactor = new InitProjectInteractor(mockFileAccess, mockOutputData); }); - it('successfully creates the entire Clean Architecture directory structure', async () => { + it('successfully creates the entire Clean Architecture directory structure in java', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); mockFileAccess.exists.mockResolvedValue(true); // Act - await interactor.execute(); + await interactor.execute(new InitProjectInputData('java')); // Assert const expectedDirs = [ @@ -63,6 +64,111 @@ describe('InitProjectInteractor', () => { expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); }); + it('successfully creates the entire Clean Architecture directory structure in python', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); + mockFileAccess.exists.mockResolvedValue(true); + + // Act + await interactor.execute(new InitProjectInputData('python')); + + // Assert + const expectedDirs = [ + `${ROOT_PATH}/src/main/python`, + `${ROOT_PATH}/src/test/python`, + `${ROOT_PATH}/src/main/python/app`, + `${ROOT_PATH}/src/main/python/use_case`, + `${ROOT_PATH}/src/main/python/entity`, + `${ROOT_PATH}/src/main/python/interface_adapter`, + `${ROOT_PATH}/src/main/python/data_access`, + `${ROOT_PATH}/src/main/python/database`, + `${ROOT_PATH}/src/main/python/view`, + ]; + + // Check that each directory was called + expectedDirs.forEach((dir) => { + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith(dir); + }); + + // Verify total number of calls matches the number of directories + expect(mockFileAccess.createDirectory).toHaveBeenCalledTimes( + expectedDirs.length + ); + + // Verify success signal + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); + }); + + it('successfully creates the entire Clean Architecture directory structure in javascript', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); + mockFileAccess.exists.mockResolvedValue(true); + + // Act + await interactor.execute(new InitProjectInputData('Javascript')); + + // Assert + const expectedDirs = [ + `${ROOT_PATH}/src/main/javascript`, + `${ROOT_PATH}/src/test/javascript`, + `${ROOT_PATH}/src/main/javascript/app`, + `${ROOT_PATH}/src/main/javascript/use_case`, + `${ROOT_PATH}/src/main/javascript/entity`, + `${ROOT_PATH}/src/main/javascript/interface_adapter`, + `${ROOT_PATH}/src/main/javascript/data_access`, + `${ROOT_PATH}/src/main/javascript/database`, + `${ROOT_PATH}/src/main/javascript/view`, + ]; + + // Check that each directory was called + expectedDirs.forEach((dir) => { + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith(dir); + }); + + // Verify total number of calls matches the number of directories + expect(mockFileAccess.createDirectory).toHaveBeenCalledTimes( + expectedDirs.length + ); + + // Verify success signal + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); + }); + + it('successfully creates the entire Clean Architecture directory structure in typescript', async () => { + // Arrange + mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); + mockFileAccess.exists.mockResolvedValue(true); + + // Act + await interactor.execute(new InitProjectInputData('typescript')); + + // Assert + const expectedDirs = [ + `${ROOT_PATH}/src/main/typescript`, + `${ROOT_PATH}/src/test/typescript`, + `${ROOT_PATH}/src/main/typescript/app`, + `${ROOT_PATH}/src/main/typescript/use_case`, + `${ROOT_PATH}/src/main/typescript/entity`, + `${ROOT_PATH}/src/main/typescript/interface_adapter`, + `${ROOT_PATH}/src/main/typescript/data_access`, + `${ROOT_PATH}/src/main/typescript/database`, + `${ROOT_PATH}/src/main/typescript/view`, + ]; + + // Check that each directory was called + expectedDirs.forEach((dir) => { + expect(mockFileAccess.createDirectory).toHaveBeenCalledWith(dir); + }); + + // Verify total number of calls matches the number of directories + expect(mockFileAccess.createDirectory).toHaveBeenCalledTimes( + expectedDirs.length + ); + + // Verify success signal + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(true); + }); + it('sets output data to false if the file system fails', async () => { // Arrange mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); @@ -72,7 +178,7 @@ describe('InitProjectInteractor', () => { ); // Act - await interactor.execute(); + await interactor.execute(new InitProjectInputData('java')); // Assert expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); @@ -83,7 +189,7 @@ describe('InitProjectInteractor', () => { mockFileAccess.getCurrentPath.mockRejectedValue(new Error('Path unknown')); // Act - await interactor.execute(); + await interactor.execute(new InitProjectInputData('java')); // Assert expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); @@ -97,7 +203,7 @@ describe('InitProjectInteractor', () => { mockFileAccess.bfsFindDir.mockResolvedValue(`${ROOT_PATH}/src/main`); // Act - await interactor.execute(); + await interactor.execute(new InitProjectInputData('java')); // Assert expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); @@ -107,7 +213,12 @@ describe('InitProjectInteractor', () => { it('Error occurs because src directory does not exist.', async () => { mockFileAccess.getCurrentPath.mockResolvedValue(ROOT_PATH); mockFileAccess.exists.mockResolvedValue(false); - await interactor.execute(); + await interactor.execute(new InitProjectInputData('java')); + expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); + }); + + it('Error occurs because programming language is invalid..', async () => { + await interactor.execute(new InitProjectInputData('blah blah blah')); expect(mockOutputData.setOutputData).toHaveBeenCalledWith(false); }); }); diff --git a/docs/users/intro.md b/docs/users/intro.md index 6c008ff..4c03437 100644 --- a/docs/users/intro.md +++ b/docs/users/intro.md @@ -15,12 +15,12 @@ Cave is a CLI (Command Line Interface) tool designed for CSC207 students to help ## Commands -### `cave init` +### `cave init ` -Initializes your CSC207 project with a sample clean architecture directory structure. +Initializes your CSC207 project with a sample clean architecture directory structure in the chosen programming language. ```bash -cave init +cave init ``` The created structure should like like: @@ -39,19 +39,46 @@ src/ └── java/ ``` -Run this first in your project folder. Cave will scaffold the recommended folder layout so you can start developing! These folders will not immediately be tracked by git as they are empty. If you want to +Run this first in your project folder. Cave will scaffold the recommended folder layout so you can start developing! These folders will not immediately be tracked by git as they are empty. + +--- + +### `cave module_init ` + +Initializes your CSC207 project with a sample clean architecture directory structure in the chosen programming language, packaged by module. + +```bash +cave module_init +``` + +The created structure should like like: + +``` +src/ +├── main/ +│ └── java/ +│ ├── app/ +│ ├── entity/ +│ ├── features/ +│ ├── data_access/ +│ └── view/ +└── test/ + └── java/ +``` + +Run this first in your project folder. Cave will scaffold the recommended folder layout so you can start developing! These folders will not immediately be tracked by git as they are empty. --- ### `cave usecase ` -Initializes the folders and Java file structure for a new use case. +Initializes the folders and file structure for a new use case. ```bash cave usecase ``` -Replace `` with the name of your use case. Cave will generate the appropriate directories and boilerplate Java files following clean architecture conventions similar to the structure shown below: +Replace `` with the name of your use case. Cave will generate the appropriate directories and boilerplate files following clean architecture conventions similar to the structure shown below (assume we initialize a Java project): ``` src/main/java/use_case/ @@ -70,6 +97,50 @@ src/main/java/interface_adapter/ --- +### `cave feature ` + +Initializes the folder for a new feature. This only works on projects packaged by module. + +```bash +cave module_usecase +``` + +Replace `` with the name of your feature. Cave will generate the appropriate directory as shown below (assume we initialize a Java project): + +``` +src/main/java/features +└── +``` + +--- + +### `cave module_usecase ` + +Initializes the folders and file structure for a new use case, in the specified feature, in a project packaged by module. + +```bash +cave module_usecase +``` + +Replace `` with the name of your feature. Replace `` with the name of your use case. Cave will generate the appropriate directories and boilerplate files following clean architecture conventions similar to the structure shown below (assume we initialize a Java project): + +``` +src/main/java/features//use_case/ +└── / + ├── InputBoundary.java + ├── InputData.java + ├── Interactor.java + ├── OutputData.java + └── OutputBoundary.java + +src/main/java/features//interface_adapter/ +└── / + ├── Controller.java + └── Presenter.java +``` + +--- + ### `cave start` Starts the front end visualizer so you can explore the interactions in your codebase.