|
@@ -1,8 +1,6 @@
|
|
|
-import type { IUserHasId } from '@growi/core/dist/interfaces';
|
|
|
|
|
-
|
|
|
|
|
|
|
+import type { SearchCandidate } from '../../interfaces/suggest-path-types';
|
|
|
import {
|
|
import {
|
|
|
- extractTopLevelSegment,
|
|
|
|
|
- generateCategoryDescription,
|
|
|
|
|
|
|
+ extractTopLevelSegmentName,
|
|
|
generateCategorySuggestion,
|
|
generateCategorySuggestion,
|
|
|
} from './generate-category-suggestion';
|
|
} from './generate-category-suggestion';
|
|
|
|
|
|
|
@@ -19,58 +17,33 @@ vi.mock('./resolve-parent-grant', () => ({
|
|
|
const GRANT_PUBLIC = 1;
|
|
const GRANT_PUBLIC = 1;
|
|
|
const GRANT_OWNER = 4;
|
|
const GRANT_OWNER = 4;
|
|
|
|
|
|
|
|
-function createSearchResult(pages: { path: string; score: number }[]) {
|
|
|
|
|
- return {
|
|
|
|
|
- data: pages.map((p) => ({
|
|
|
|
|
- _id: `id-${p.path}`,
|
|
|
|
|
- _score: p.score,
|
|
|
|
|
- _source: { path: p.path },
|
|
|
|
|
- })),
|
|
|
|
|
- meta: { total: pages.length, hitsCount: pages.length },
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function createMockSearchService(
|
|
|
|
|
- result: ReturnType<typeof createSearchResult>,
|
|
|
|
|
-) {
|
|
|
|
|
- return {
|
|
|
|
|
- searchKeyword: vi.fn().mockResolvedValue([result, 'DEFAULT']),
|
|
|
|
|
- };
|
|
|
|
|
|
|
+function createCandidates(
|
|
|
|
|
+ pages: { path: string; score: number }[],
|
|
|
|
|
+): SearchCandidate[] {
|
|
|
|
|
+ return pages.map((p) => ({
|
|
|
|
|
+ pagePath: p.path,
|
|
|
|
|
+ snippet: '',
|
|
|
|
|
+ score: p.score,
|
|
|
|
|
+ }));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-const mockUser = { _id: 'user1', username: 'alice' } as unknown as IUserHasId;
|
|
|
|
|
-
|
|
|
|
|
-describe('extractTopLevelSegment', () => {
|
|
|
|
|
- it('should extract top-level segment from nested path', () => {
|
|
|
|
|
- expect(extractTopLevelSegment('/tech-notes/React/hooks')).toBe(
|
|
|
|
|
- '/tech-notes/',
|
|
|
|
|
|
|
+describe('extractTopLevelSegmentName', () => {
|
|
|
|
|
+ it('should extract segment name from nested path', () => {
|
|
|
|
|
+ expect(extractTopLevelSegmentName('/tech-notes/React/hooks')).toBe(
|
|
|
|
|
+ 'tech-notes',
|
|
|
);
|
|
);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('should extract top-level segment from two-level path', () => {
|
|
|
|
|
- expect(extractTopLevelSegment('/tech-notes/React')).toBe('/tech-notes/');
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it('should extract top-level segment from single-level path', () => {
|
|
|
|
|
- expect(extractTopLevelSegment('/tech-notes')).toBe('/tech-notes/');
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it('should return root for root path', () => {
|
|
|
|
|
- expect(extractTopLevelSegment('/')).toBe('/');
|
|
|
|
|
|
|
+ it('should extract segment name from two-level path', () => {
|
|
|
|
|
+ expect(extractTopLevelSegmentName('/tech-notes/React')).toBe('tech-notes');
|
|
|
});
|
|
});
|
|
|
-});
|
|
|
|
|
|
|
|
|
|
-describe('generateCategoryDescription', () => {
|
|
|
|
|
- it('should generate description from segment name', () => {
|
|
|
|
|
- expect(generateCategoryDescription('tech-notes')).toBe(
|
|
|
|
|
- 'Top-level category: tech-notes',
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ it('should extract segment name from single-level path', () => {
|
|
|
|
|
+ expect(extractTopLevelSegmentName('/tech-notes')).toBe('tech-notes');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('should handle single word segment', () => {
|
|
|
|
|
- expect(generateCategoryDescription('guides')).toBe(
|
|
|
|
|
- 'Top-level category: guides',
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ it('should return null for root path', () => {
|
|
|
|
|
+ expect(extractTopLevelSegmentName('/')).toBeNull();
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -80,118 +53,76 @@ describe('generateCategorySuggestion', () => {
|
|
|
mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
|
|
mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- describe('when search returns results', () => {
|
|
|
|
|
|
|
+ describe('when candidates are provided', () => {
|
|
|
it('should return a suggestion with type "category"', async () => {
|
|
it('should return a suggestion with type "category"', async () => {
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['React', 'hooks'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result).not.toBeNull();
|
|
|
expect(result?.type).toBe('category');
|
|
expect(result?.type).toBe('category');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('should extract top-level segment from top result path', async () => {
|
|
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ it('should extract top-level segment from top candidate path', async () => {
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
|
{ path: '/guides/TypeScript/basics', score: 8 },
|
|
{ path: '/guides/TypeScript/basics', score: 8 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['React'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result?.path).toBe('/tech-notes/');
|
|
expect(result?.path).toBe('/tech-notes/');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('should return path with trailing slash', async () => {
|
|
it('should return path with trailing slash', async () => {
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['React'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result?.path).toMatch(/\/$/);
|
|
expect(result?.path).toMatch(/\/$/);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('should extract top-level even from deeply nested path', async () => {
|
|
it('should extract top-level even from deeply nested path', async () => {
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/guides/a/b/c/d', score: 10 },
|
|
{ path: '/guides/a/b/c/d', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['keyword'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result?.path).toBe('/guides/');
|
|
expect(result?.path).toBe('/guides/');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('should generate description from top-level segment name', async () => {
|
|
it('should generate description from top-level segment name', async () => {
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['React'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result?.description).toBe('Top-level category: tech-notes');
|
|
expect(result?.description).toBe('Top-level category: tech-notes');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('should have label "Save under category"', async () => {
|
|
it('should have label "Save under category"', async () => {
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['React'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result?.label).toBe('Save under category');
|
|
expect(result?.label).toBe('Save under category');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('should resolve grant from top-level directory', async () => {
|
|
it('should resolve grant from top-level directory', async () => {
|
|
|
mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
|
|
mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
{ path: '/tech-notes/React/hooks', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['React'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/tech-notes/');
|
|
expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/tech-notes/');
|
|
|
expect(result?.grant).toBe(GRANT_PUBLIC);
|
|
expect(result?.grant).toBe(GRANT_PUBLIC);
|
|
@@ -199,80 +130,23 @@ describe('generateCategorySuggestion', () => {
|
|
|
|
|
|
|
|
it('should return GRANT_OWNER when parent page not found', async () => {
|
|
it('should return GRANT_OWNER when parent page not found', async () => {
|
|
|
mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
|
|
mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/nonexistent/page', score: 10 },
|
|
{ path: '/nonexistent/page', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['keyword'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result?.grant).toBe(GRANT_OWNER);
|
|
expect(result?.grant).toBe(GRANT_OWNER);
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
- it('should join keywords with spaces for search query', async () => {
|
|
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
- { path: '/tech-notes/React/hooks', score: 10 },
|
|
|
|
|
- ]);
|
|
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
-
|
|
|
|
|
- await generateCategorySuggestion(
|
|
|
|
|
- ['React', 'hooks', 'useState'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- expect(searchService.searchKeyword).toHaveBeenCalledWith(
|
|
|
|
|
- 'React hooks useState',
|
|
|
|
|
- null,
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- expect.objectContaining({ limit: expect.any(Number) }),
|
|
|
|
|
- );
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it('should pass user and userGroups to searchKeyword', async () => {
|
|
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
- { path: '/tech-notes/React/hooks', score: 10 },
|
|
|
|
|
- ]);
|
|
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
- const mockUserGroups = ['group1', 'group2'];
|
|
|
|
|
-
|
|
|
|
|
- await generateCategorySuggestion(
|
|
|
|
|
- ['React'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- mockUserGroups,
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- expect(searchService.searchKeyword).toHaveBeenCalledWith(
|
|
|
|
|
- expect.any(String),
|
|
|
|
|
- null,
|
|
|
|
|
- mockUser,
|
|
|
|
|
- mockUserGroups,
|
|
|
|
|
- expect.any(Object),
|
|
|
|
|
- );
|
|
|
|
|
- });
|
|
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
describe('when top result is a single-segment page', () => {
|
|
describe('when top result is a single-segment page', () => {
|
|
|
it('should return the page path as category', async () => {
|
|
it('should return the page path as category', async () => {
|
|
|
- const searchResult = createSearchResult([
|
|
|
|
|
|
|
+ const candidates = createCandidates([
|
|
|
{ path: '/engineering', score: 10 },
|
|
{ path: '/engineering', score: 10 },
|
|
|
]);
|
|
]);
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['keyword'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion(candidates);
|
|
|
|
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result).not.toBeNull();
|
|
|
expect(result?.path).toBe('/engineering/');
|
|
expect(result?.path).toBe('/engineering/');
|
|
@@ -280,31 +154,15 @@ describe('generateCategorySuggestion', () => {
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- describe('when search returns no results', () => {
|
|
|
|
|
|
|
+ describe('when candidates are empty', () => {
|
|
|
it('should return null', async () => {
|
|
it('should return null', async () => {
|
|
|
- const searchResult = createSearchResult([]);
|
|
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
-
|
|
|
|
|
- const result = await generateCategorySuggestion(
|
|
|
|
|
- ['nonexistent'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const result = await generateCategorySuggestion([]);
|
|
|
|
|
|
|
|
expect(result).toBeNull();
|
|
expect(result).toBeNull();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('should not call resolveParentGrant', async () => {
|
|
it('should not call resolveParentGrant', async () => {
|
|
|
- const searchResult = createSearchResult([]);
|
|
|
|
|
- const searchService = createMockSearchService(searchResult);
|
|
|
|
|
-
|
|
|
|
|
- await generateCategorySuggestion(
|
|
|
|
|
- ['nonexistent'],
|
|
|
|
|
- mockUser,
|
|
|
|
|
- [],
|
|
|
|
|
- searchService,
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ await generateCategorySuggestion([]);
|
|
|
|
|
|
|
|
expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
|
|
expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
|
|
|
});
|
|
});
|