mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-08-03 20:59:03 +00:00
Refactor + more tests
This commit is contained in:
40
ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx
Normal file
40
ui/src/Pages/PeoplePage/FaceCircleImage.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FaceCircleImage from './FaceCircleImage'
|
||||
import { myFaces_myFaceGroups_imageFaces } from './__generated__/myFaces'
|
||||
|
||||
require('../../localization').setupLocalization()
|
||||
|
||||
test('face circle image', () => {
|
||||
const imageFace: myFaces_myFaceGroups_imageFaces = {
|
||||
__typename: 'ImageFace',
|
||||
id: '3',
|
||||
media: {
|
||||
id: '1',
|
||||
__typename: 'Media',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: 'http://localhost:4001/photo/thumbnail_my_image_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
},
|
||||
},
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.27,
|
||||
maxX: 0.34,
|
||||
minY: 0.76,
|
||||
maxY: 0.88,
|
||||
},
|
||||
}
|
||||
|
||||
render(<FaceCircleImage imageFace={imageFace} selectable={true} />)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
imageFace.media.thumbnail!.url
|
||||
)
|
||||
})
|
||||
115
ui/src/Pages/PeoplePage/FaceCircleImage.tsx
Normal file
115
ui/src/Pages/PeoplePage/FaceCircleImage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
|
||||
import { myFaces_myFaceGroups_imageFaces } from './__generated__/myFaces'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const FaceImage = styled(({ origin, selectable, scale, ...rest }) => (
|
||||
<ProtectedImage {...rest} />
|
||||
))<{ origin: { x: number; y: number }; selectable: boolean; scale: number }>`
|
||||
position: absolute;
|
||||
transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`};
|
||||
object-fit: cover;
|
||||
|
||||
transition: transform 250ms ease-out;
|
||||
`
|
||||
|
||||
const FaceImagePortrait = styled(FaceImage)`
|
||||
width: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%)
|
||||
${({ origin, scale }) =>
|
||||
`translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.8, 1)})`};
|
||||
|
||||
${({ selectable, origin, scale }) =>
|
||||
selectable
|
||||
? `
|
||||
&:hover {
|
||||
transform: translateY(-50%) translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.85, 1)})
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
|
||||
const FaceImageLandscape = styled(FaceImage)`
|
||||
height: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%)
|
||||
${({ origin, scale }) =>
|
||||
`translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.8, 1)})`};
|
||||
|
||||
${({ selectable, origin, scale }) =>
|
||||
selectable
|
||||
? `
|
||||
&:hover {
|
||||
transform: translateX(-50%) translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.85, 1)})
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
|
||||
const CircleImageWrapper = styled.div<{ size: string }>`
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
width: ${({ size }) => size};
|
||||
height: ${({ size }) => size};
|
||||
object-fit: fill;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
type FaceCircleImageProps = {
|
||||
imageFace: myFaces_myFaceGroups_imageFaces
|
||||
selectable: boolean
|
||||
size?: string
|
||||
}
|
||||
|
||||
const FaceCircleImage = ({
|
||||
imageFace,
|
||||
selectable,
|
||||
size = '150px',
|
||||
}: FaceCircleImageProps) => {
|
||||
if (!imageFace) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = imageFace.rectangle
|
||||
|
||||
const scale = Math.min(
|
||||
1 / (rect.maxX - rect.minX),
|
||||
1 / (rect.maxY - rect.minY)
|
||||
)
|
||||
|
||||
const origin = {
|
||||
x: (rect.minX + rect.maxX) / 2,
|
||||
y: (rect.minY + rect.maxY) / 2,
|
||||
}
|
||||
|
||||
let SpecificFaceImage: typeof FaceImageLandscape | typeof FaceImagePortrait =
|
||||
FaceImageLandscape
|
||||
if (imageFace.media.thumbnail) {
|
||||
SpecificFaceImage =
|
||||
imageFace.media.thumbnail.width > imageFace.media.thumbnail.height
|
||||
? FaceImageLandscape
|
||||
: FaceImagePortrait
|
||||
}
|
||||
|
||||
return (
|
||||
<CircleImageWrapper size={size}>
|
||||
<SpecificFaceImage
|
||||
selectable={selectable}
|
||||
scale={scale}
|
||||
origin={origin}
|
||||
src={imageFace.media.thumbnail?.url}
|
||||
/>
|
||||
</CircleImageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default FaceCircleImage
|
||||
@@ -1,10 +1,15 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import PeoplePage, { MY_FACES_QUERY } from './PeoplePage'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import PeoplePage, {
|
||||
FaceDetails,
|
||||
MY_FACES_QUERY,
|
||||
SET_GROUP_LABEL_MUTATION,
|
||||
} from './PeoplePage'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { myFaces_myFaceGroups } from './__generated__/myFaces'
|
||||
|
||||
require('../../localization').setupLocalization()
|
||||
|
||||
@@ -15,88 +20,202 @@ jest.mock('../../hooks/useScrollPagination', () =>
|
||||
}))
|
||||
)
|
||||
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: MY_FACES_QUERY,
|
||||
variables: {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
describe('PeoplePage component', () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: MY_FACES_QUERY,
|
||||
variables: {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
myFaceGroups: [
|
||||
{
|
||||
__typename: 'FaceGroup',
|
||||
id: '3',
|
||||
label: 'Person A',
|
||||
imageFaceCount: 2,
|
||||
imageFaces: [
|
||||
{
|
||||
__typename: 'ImageFace',
|
||||
id: '3',
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.2705079913139343,
|
||||
maxX: 0.3408200144767761,
|
||||
minY: 0.7691109776496887,
|
||||
maxY: 0.881434977054596,
|
||||
},
|
||||
media: {
|
||||
__typename: 'Media',
|
||||
id: '63',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: 'http://localhost:4001/photo/thumbnail_L%C3%B8berute_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
result: {
|
||||
data: {
|
||||
myFaceGroups: [
|
||||
{
|
||||
__typename: 'FaceGroup',
|
||||
id: '3',
|
||||
label: 'Person A',
|
||||
imageFaceCount: 2,
|
||||
imageFaces: [
|
||||
{
|
||||
__typename: 'ImageFace',
|
||||
id: '3',
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.2705079913139343,
|
||||
maxX: 0.3408200144767761,
|
||||
minY: 0.7691109776496887,
|
||||
maxY: 0.881434977054596,
|
||||
},
|
||||
media: {
|
||||
__typename: 'Media',
|
||||
id: '63',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: 'http://localhost:4001/photo/thumbnail_L%C3%B8berute_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
__typename: 'FaceGroup',
|
||||
id: '1',
|
||||
label: 'Person B',
|
||||
imageFaceCount: 1,
|
||||
imageFaces: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
__typename: 'FaceGroup',
|
||||
id: '1',
|
||||
label: 'Person B',
|
||||
imageFaceCount: 1,
|
||||
imageFaces: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
]
|
||||
|
||||
test('people page', async () => {
|
||||
const matchMock = {
|
||||
params: {
|
||||
person: undefined,
|
||||
},
|
||||
test('people page', async () => {
|
||||
const matchMock = {
|
||||
params: {
|
||||
person: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/people']}>
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={false}>
|
||||
<PeoplePage match={matchMock} />
|
||||
</MockedProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recognize unlabeled faces')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Person A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Person B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('link')
|
||||
.some(x => x.getAttribute('href') == '/people/1')
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('link')
|
||||
.some(x => x.getAttribute('href') == '/people/3')
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FaceDetails component', () => {
|
||||
const faceGroup: myFaces_myFaceGroups = {
|
||||
id: '3',
|
||||
label: null,
|
||||
imageFaceCount: 2,
|
||||
imageFaces: [
|
||||
{
|
||||
id: '3',
|
||||
rectangle: {
|
||||
minX: 0.2705079913139343,
|
||||
maxX: 0.3408200144767761,
|
||||
minY: 0.7691109776496887,
|
||||
maxY: 0.881434977054596,
|
||||
__typename: 'FaceRectangle',
|
||||
},
|
||||
media: {
|
||||
id: '63',
|
||||
thumbnail: {
|
||||
url: 'http://localhost:4001/photo/thumbnail_image_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
__typename: 'MediaURL',
|
||||
},
|
||||
__typename: 'Media',
|
||||
},
|
||||
__typename: 'ImageFace',
|
||||
},
|
||||
],
|
||||
__typename: 'FaceGroup',
|
||||
}
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/people']}>
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={false}>
|
||||
<PeoplePage match={matchMock} />
|
||||
test('unlabeled, no images', () => {
|
||||
const emptyFaceGroup: myFaces_myFaceGroups = {
|
||||
...faceGroup,
|
||||
imageFaces: [],
|
||||
}
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<FaceDetails group={emptyFaceGroup} />
|
||||
</MockedProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recognize unlabeled faces')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Person A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Person B')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unlabeled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('link').some(x => x.getAttribute('href') == '/people/1')
|
||||
).toBeTruthy()
|
||||
test('labeled, with thumbnail', () => {
|
||||
const labeledFaceGroup: myFaces_myFaceGroups = {
|
||||
...faceGroup,
|
||||
label: 'Some label',
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('link').some(x => x.getAttribute('href') == '/people/3')
|
||||
).toBeTruthy()
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<FaceDetails group={labeledFaceGroup} />
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText(labeledFaceGroup.label!)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unlabeled')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('add label to face group', async () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: SET_GROUP_LABEL_MUTATION,
|
||||
variables: {
|
||||
groupID: '3',
|
||||
label: 'John Doe',
|
||||
},
|
||||
},
|
||||
newData: jest.fn(() => ({
|
||||
data: {
|
||||
setFaceGroupLabel: {
|
||||
__typename: 'FaceGroup',
|
||||
id: '3',
|
||||
label: 'John Doe',
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
render(
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={false}>
|
||||
<FaceDetails group={faceGroup} />
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button')
|
||||
expect(btn).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(btn)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'John Doe' } })
|
||||
fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(graphqlMocks[0].newData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ type FaceDetailsProps = {
|
||||
group: myFaces_myFaceGroups
|
||||
}
|
||||
|
||||
const FaceDetails = ({ group }: FaceDetailsProps) => {
|
||||
export const FaceDetails = ({ group }: FaceDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(group.label ?? '')
|
||||
|
||||
104
ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx
Normal file
104
ui/src/Pages/PeoplePage/SingleFaceGroup/SingleFaceGroup.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { gql, useQuery } from '@apollo/client'
|
||||
import React, { useEffect, useReducer } from 'react'
|
||||
import PaginateLoader from '../../../components/PaginateLoader'
|
||||
import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
|
||||
import { photoGalleryReducer } from '../../../components/photoGallery/photoGalleryReducer'
|
||||
import useScrollPagination from '../../../hooks/useScrollPagination'
|
||||
import FaceGroupTitle from './FaceGroupTitle'
|
||||
import {
|
||||
singleFaceGroup,
|
||||
singleFaceGroupVariables,
|
||||
} from './__generated__/singleFaceGroup'
|
||||
|
||||
export const SINGLE_FACE_GROUP = gql`
|
||||
query singleFaceGroup($id: ID!, $limit: Int!, $offset: Int!) {
|
||||
faceGroup(id: $id) {
|
||||
id
|
||||
label
|
||||
imageFaces(paginate: { limit: $limit, offset: $offset }) {
|
||||
id
|
||||
rectangle {
|
||||
minX
|
||||
maxX
|
||||
minY
|
||||
maxY
|
||||
}
|
||||
media {
|
||||
id
|
||||
type
|
||||
title
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
}
|
||||
favorite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type SingleFaceGroupProps = {
|
||||
faceGroupID: string
|
||||
}
|
||||
|
||||
const SingleFaceGroup = ({ faceGroupID }: SingleFaceGroupProps) => {
|
||||
const { data, error, loading, fetchMore } = useQuery<
|
||||
singleFaceGroup,
|
||||
singleFaceGroupVariables
|
||||
>(SINGLE_FACE_GROUP, {
|
||||
variables: {
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
id: faceGroupID,
|
||||
},
|
||||
})
|
||||
|
||||
const [mediaState, dispatchMedia] = useReducer(photoGalleryReducer, {
|
||||
presenting: false,
|
||||
activeIndex: -1,
|
||||
media: [],
|
||||
})
|
||||
|
||||
const { containerElem, finished: finishedLoadingMore } =
|
||||
useScrollPagination<singleFaceGroup>({
|
||||
loading,
|
||||
fetchMore,
|
||||
data,
|
||||
getItems: data => data.faceGroup.imageFaces,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const media = data?.faceGroup.imageFaces.map(x => x.media) || []
|
||||
dispatchMedia({ type: 'replaceMedia', media })
|
||||
}, [data])
|
||||
|
||||
const faceGroup = data?.faceGroup
|
||||
|
||||
if (error) {
|
||||
return <div>{error.message}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerElem}>
|
||||
<FaceGroupTitle faceGroup={faceGroup} />
|
||||
<div>
|
||||
<PhotoGallery
|
||||
loading={loading}
|
||||
dispatchMedia={dispatchMedia}
|
||||
mediaState={mediaState}
|
||||
/>
|
||||
<PaginateLoader
|
||||
active={!finishedLoadingMore && !loading}
|
||||
text="Loading more photos"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleFaceGroup
|
||||
Reference in New Issue
Block a user