From 4fe1ddfbd2f455e643fcdae1a8a402fe8d3360c6 Mon Sep 17 00:00:00 2001 From: maxime Date: Sat, 9 Mar 2024 01:08:23 +0100 Subject: [PATCH] fix step tree and step update --- .env.TEST | 2 + package.json | 8 +-- src/Fetcher.ts | 3 +- src/components/Rack.tsx | 27 ++++---- src/components/editor/StepsTree.tsx | 27 ++++---- src/editor/StepsDomain.ts | 17 +++++ src/editor/TacticContentDomains.ts | 79 ++++++++++++---------- src/pages/Editor.tsx | 101 +++++++++------------------- test/api/tactics.test.ts | 40 +++++++++++ vite.config.ts | 3 + 10 files changed, 172 insertions(+), 135 deletions(-) create mode 100644 .env.TEST create mode 100644 test/api/tactics.test.ts diff --git a/.env.TEST b/.env.TEST new file mode 100644 index 0000000..5fc3483 --- /dev/null +++ b/.env.TEST @@ -0,0 +1,2 @@ +VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master +#VITE_API_ENDPOINT=http://localhost:5254 diff --git a/package.json b/package.json index 7eb6cfb..d89f82b 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,6 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^27.5.2", - "@types/node": "^16.18.59", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "eslint-plugin-react-refresh": "^0.4.5", @@ -23,7 +21,7 @@ "scripts": { "start": "vite --host", "build": "vite build", - "test": "vite test", + "test": "vitest", "format": "prettier --config .prettierrc '.' --write", "tsc": "tsc" }, @@ -34,8 +32,10 @@ "eslint": "^8.53.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^24.0.0", "prettier": "^3.1.0", "typescript": "^5.2.2", - "vite-plugin-svgr": "^4.1.0" + "vite-plugin-svgr": "^4.1.0", + "vitest": "^1.3.1" } } diff --git a/src/Fetcher.ts b/src/Fetcher.ts index 08872c2..a44022f 100644 --- a/src/Fetcher.ts +++ b/src/Fetcher.ts @@ -61,7 +61,8 @@ async function handleResponse( const expirationDate = Date.parse( response.headers.get("Next-Authorization-Expiration-Date")!, ) - saveSession({ ...session, auth: { token: nextToken, expirationDate } }) + if (nextToken && expirationDate) + saveSession({ ...session, auth: { token: nextToken, expirationDate } }) return response } diff --git a/src/components/Rack.tsx b/src/components/Rack.tsx index 2a7511f..fac282f 100644 --- a/src/components/Rack.tsx +++ b/src/components/Rack.tsx @@ -4,7 +4,7 @@ import Draggable from "react-draggable" export interface RackProps { id: string objects: E[] - onChange: (objects: E[]) => void + onChange?: (objects: E[]) => void canDetach: (ref: HTMLDivElement) => boolean onElementDetached: (ref: HTMLDivElement, el: E) => void render: (e: E) => ReactElement @@ -20,13 +20,13 @@ interface RackItemProps { * A container of draggable objects * */ export function Rack({ - id, - objects, - onChange, - canDetach, - onElementDetached, - render, -}: RackProps) { + id, + objects, + onChange, + canDetach, + onElementDetached, + render, + }: RackProps) { return (
({ const index = objects.findIndex( (o) => o.key === element.key, ) - onChange(objects.toSpliced(index, 1)) + if (onChange) + onChange(objects.toSpliced(index, 1)) onElementDetached(ref, element) }} @@ -55,10 +56,10 @@ export function Rack({ } function RackItem({ - item, - onTryDetach, - render, -}: RackItemProps) { + item, + onTryDetach, + render, + }: RackItemProps) { const divRef = useRef(null) return ( diff --git a/src/components/editor/StepsTree.tsx b/src/components/editor/StepsTree.tsx index c019aab..5a4af5e 100644 --- a/src/components/editor/StepsTree.tsx +++ b/src/components/editor/StepsTree.tsx @@ -4,6 +4,7 @@ import BendableArrow from "../arrows/BendableArrow" import { useRef } from "react" import AddSvg from "../../assets/icon/add.svg?react" import RemoveSvg from "../../assets/icon/remove.svg?react" +import { getStepName } from "../../editor/StepsDomain.ts" export interface StepsTreeProps { root: StepInfoNode @@ -24,7 +25,7 @@ export default function StepsTree({
void onRemoveNode: (node: StepInfoNode) => void @@ -45,7 +47,7 @@ interface StepsTreeContentProps { function StepsTreeNode({ node, - isNodeRoot, + rootNode, selectedStepId, onAddChildren, onRemoveNode, @@ -53,14 +55,15 @@ function StepsTreeNode({ }: StepsTreeContentProps) { const ref = useRef(null) + const stepId = getStepName(rootNode, node.id) return (
{node.children.map((child) => ( {}} forceStraight={true} wavy={false} @@ -70,11 +73,11 @@ function StepsTreeNode({ /> ))} onAddChildren(node)} onRemoveButtonClicked={ - isNodeRoot ? undefined : () => onRemoveNode(node) + rootNode.id === node.id ? undefined : () => onRemoveNode(node) } onSelected={() => onStepSelected(node)} /> @@ -82,7 +85,7 @@ function StepsTreeNode({ {node.children.map((child) => ( void onRemoveButtonClicked?: () => void @@ -104,7 +107,7 @@ interface StepPieceProps { } function StepPiece({ - id, + name, isSelected, onAddButtonClicked, onRemoveButtonClicked, @@ -112,7 +115,7 @@ function StepPiece({ }: StepPieceProps) { return (
)}
-

{id}

+

{name}

) } diff --git a/src/editor/StepsDomain.ts b/src/editor/StepsDomain.ts index 88904e0..5ae681e 100644 --- a/src/editor/StepsDomain.ts +++ b/src/editor/StepsDomain.ts @@ -18,6 +18,23 @@ export function addStepNode( } } +export function getStepName(root: StepInfoNode, step: number): string { + + let ord = 1 + const nodes = [root] + while (nodes.length > 0) { + const node = nodes.pop()! + + if (node.id === step) + break + + ord++ + nodes.push(...[...node.children].reverse()) + } + + return ord.toString() +} + export function getStepNode( root: StepInfoNode, stepId: number, diff --git a/src/editor/TacticContentDomains.ts b/src/editor/TacticContentDomains.ts index 34dd0f3..9f05e14 100644 --- a/src/editor/TacticContentDomains.ts +++ b/src/editor/TacticContentDomains.ts @@ -200,9 +200,9 @@ export function moveComponent( phantomIdx == 0 ? origin : getComponent( - originPathItems[phantomIdx - 1], - content.components, - ) + originPathItems[phantomIdx - 1], + content.components, + ) // detach the action from the screen target and transform it to a regular move action to the phantom. content = updateComponent( { @@ -210,18 +210,18 @@ export function moveComponent( actions: playerBeforePhantom.actions.map((a) => a.target === referent ? { - ...a, - segments: a.segments.toSpliced( - a.segments.length - 2, - 1, - { - ...a.segments[a.segments.length - 1], - next: component.id, - }, - ), - target: component.id, - type: ActionKind.MOVE, - } + ...a, + segments: a.segments.toSpliced( + a.segments.length - 2, + 1, + { + ...a.segments[a.segments.length - 1], + next: component.id, + }, + ), + target: component.id, + type: ActionKind.MOVE, + } : a, ), }, @@ -234,9 +234,9 @@ export function moveComponent( ...component, pos: isPhantom ? { - type: "fixed", - ...newPos, - } + type: "fixed", + ...newPos, + } : newPos, }, content, @@ -315,15 +315,15 @@ export function computeTerminalState( content.components.filter((c) => c.type !== "phantom") as ( | Player | CourtObject - )[] + )[] const componentsTargetedState = nonPhantomComponents.map((comp) => comp.type === "player" ? getPlayerTerminalState(comp, content, computedPositions) : { - ...comp, - frozen: true, - }, + ...comp, + frozen: true, + }, ) return { @@ -399,20 +399,12 @@ export function drainTerminalStateOnChildContent( parentTerminalState: StepContent, childContent: StepContent, ): StepContent | null { - let gotUpdated = false - //filter out all frozen components that are not present on the parent's terminal state anymore - childContent = { - components: childContent.components.filter( - (comp) => - comp.type === "phantom" || - (comp.frozen && - tryGetComponent(comp.id, parentTerminalState.components)), - ), - } + + let gotUpdated = false for (const parentComponent of parentTerminalState.components) { - const childComponent = tryGetComponent( + let childComponent = tryGetComponent( parentComponent.id, childContent.components, ) @@ -443,13 +435,16 @@ export function drainTerminalStateOnChildContent( if (newContentResult) { gotUpdated = true childContent = newContentResult + childComponent = getComponent(childComponent.id, newContentResult?.components) } - // also update the position of the player if it has been moved - if (!equals(childComponent.pos, parentComponent.pos)) { + // update the position of the player if it has been moved + // also force update if the child component is not frozen (the component was introduced previously by the child step but the parent added it afterward) + if (!childComponent.frozen || !equals(childComponent.pos, parentComponent.pos)) { gotUpdated = true childContent = updateComponent( { ...childComponent, + frozen: true, pos: parentComponent.pos, }, childContent, @@ -457,5 +452,19 @@ export function drainTerminalStateOnChildContent( } } + const initialChildCompsCount = childContent.components.length + + //filter out all frozen components that are not present on the parent's terminal state anymore + childContent = { + components: childContent.components.filter( + (comp) => + comp.type === "phantom" || + !comp.frozen || + tryGetComponent(comp.id, parentTerminalState.components) + ), + } + + gotUpdated ||= childContent.components.length !== initialChildCompsCount + return gotUpdated ? childContent : null } diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 23e276b..da6a3d2 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -102,7 +102,7 @@ const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree" const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title" // The step identifier the editor will always open on -const ROOT_STEP_ID = 1 +const GUEST_MODE_ROOT_STEP_ID = 1 type ComputedRelativePositions = Map @@ -131,7 +131,7 @@ function EditorPortal({ guestMode }: EditorPageProps) { function GuestModeEditor() { const storageContent = localStorage.getItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, ) const stepInitialContent: StepContent = { @@ -148,10 +148,10 @@ function GuestModeEditor() { if (storageContent == null) { localStorage.setItem( GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY, - JSON.stringify({ id: ROOT_STEP_ID, children: [] }), + JSON.stringify({ id: GUEST_MODE_ROOT_STEP_ID, children: [] }), ) localStorage.setItem( - GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID, + GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID, JSON.stringify(stepInitialContent), ) } @@ -161,7 +161,7 @@ function GuestModeEditor() { "Nouvelle Tactique" const courtRef = useRef(null) - const [stepId, setStepId] = useState(ROOT_STEP_ID) + const [stepId, setStepId] = useState(GUEST_MODE_ROOT_STEP_ID) const [stepContent, setStepContent, saveState] = useContentState( stepInitialContent, SaveStates.Guest, @@ -281,7 +281,7 @@ function GuestModeEditor() { function UserModeEditor() { const [tactic, setTactic] = useState(null) const [stepsTree, setStepsTree] = useState({ - id: ROOT_STEP_ID, + id: -1, children: [], }) const { tacticId: idStr } = useParams() @@ -289,7 +289,7 @@ function UserModeEditor() { const navigation = useNavigate() const courtRef = useRef(null) - const [stepId, setStepId] = useState(1) + const [stepId, setStepId] = useState(-1) const saveContent = useCallback( async (content: StepContent) => { @@ -353,29 +353,38 @@ function UserModeEditor() { async function initialize() { const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`) const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`) - const contentResponsePromise = fetchAPIGet( - `tactics/${tacticId}/steps/${ROOT_STEP_ID}`, - ) const infoResponse = await infoResponsePromise const treeResponse = await treeResponsePromise - const contentResponse = await contentResponsePromise + + const { name, courtType } = await infoResponse.json() + const { root } = await treeResponse.json() if ( infoResponse.status == 401 || - treeResponse.status == 401 || - contentResponse.status == 401 + treeResponse.status == 401 ) { navigation("/login") return } - const { name, courtType } = await infoResponse.json() + const contentResponsePromise = fetchAPIGet( + `tactics/${tacticId}/steps/${root.id}`, + ) + + + const contentResponse = await contentResponsePromise + + if (contentResponse.status == 401) { + navigation("/login") + return + } + const content = await contentResponse.json() - const { root } = await treeResponse.json() setTactic({ id: tacticId, name, courtType }) setStepsTree(root) + setStepId(root.id) setStepContent(content, false) } @@ -492,12 +501,8 @@ function EditorPage({ const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode) - const [allies, setAllies] = useState(() => - getRackPlayers(PlayerTeam.Allies, content.components), - ) - const [opponents, setOpponents] = useState(() => - getRackPlayers(PlayerTeam.Opponents, content.components), - ) + const allies = getRackPlayers(PlayerTeam.Allies, content.components) + const opponents = getRackPlayers(PlayerTeam.Opponents, content.components) const [objects, setObjects] = useState(() => isBallOnCourt(content) ? [] : [{ key: "ball" }], @@ -521,25 +526,6 @@ function EditorPage({ : new Map() }, [content, courtRef]) - // const setContent = useCallback( - // (newState: SetStateAction) => { - // setCurrentStepContent((c) => { - // const state = - // typeof newState === "function" - // ? newState(c.content) - // : newState - // - // const courtBounds = courtRef.current?.getBoundingClientRect() - // const relativePositions: ComputedRelativePositions = courtBounds - // ? computeRelativePositions(courtBounds, state) - // : new Map() - // - // return state - // }) - // }, - // [setCurrentStepContent], - // ) - const setComponents = (action: SetStateAction) => { setContent((c) => ({ ...c, @@ -553,25 +539,9 @@ function EditorPage({ }, [setObjects, content]) const insertRackedPlayer = (player: Player) => { - let setter - switch (player.team) { - case PlayerTeam.Opponents: - setter = setOpponents - break - case PlayerTeam.Allies: - setter = setAllies - } if (player.ballState == BallState.HOLDS_BY_PASS) { setObjects([{ key: "ball" }]) } - setter((players) => [ - ...players, - { - team: player.team, - pos: player.role, - key: player.role, - }, - ]) } const doRemovePlayer = useCallback( @@ -676,7 +646,7 @@ function EditorPage({ ), ] }, - [content, doMoveBall, previewAction?.isInvalid, setContent], + [content, courtRef, doMoveBall, previewAction?.isInvalid, setContent], ) const renderPlayer = useCallback( @@ -730,14 +700,7 @@ function EditorPage({ /> ) }, - [ - content, - relativePositions, - courtBounds, - validatePlayerPosition, - doRemovePlayer, - renderAvailablePlayerActions, - ], + [courtRef, content, relativePositions, courtBounds, renderAvailablePlayerActions, validatePlayerPosition, doRemovePlayer], ) const doDeleteAction = useCallback( @@ -811,7 +774,7 @@ function EditorPage({ /> ) }), - [doDeleteAction, doUpdateAction], + [courtRef, doDeleteAction, doUpdateAction], ) return ( @@ -836,7 +799,7 @@ function EditorPage({
@@ -846,7 +809,6 @@ function EditorPage({ @@ -881,7 +843,6 @@ function EditorPage({ @@ -983,7 +944,7 @@ function EditorStepsTree({ interface PlayerRackProps { id: string objects: RackedPlayer[] - setObjects: (state: RackedPlayer[]) => void + setObjects?: (state: RackedPlayer[]) => void setComponents: ( f: (components: TacticComponent[]) => TacticComponent[], ) => void diff --git a/test/api/tactics.test.ts b/test/api/tactics.test.ts new file mode 100644 index 0000000..552bfaa --- /dev/null +++ b/test/api/tactics.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, expect, test } from "vitest" +import { fetchAPI } from "../../src/Fetcher" +import { saveSession } from "../../src/api/session" + +async function login() { + const response = await fetchAPI("auth/token/", { email: "maxime@mail.com", password: "123456" }) + expect(response.status).toBe(200) + const { token, expirationDate } = await response.json() + saveSession({ auth: { token, expirationDate: Date.parse(expirationDate) } }) +} + +beforeAll(login) + +test("create tactic", async () => { + await login() + const response = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" }) + expect(response.status).toBe(200) +}) + +test("spam step creation test", async () => { + const createTacticResponse = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" }) + expect(createTacticResponse.status).toBe(200) + const { id } = await createTacticResponse.json() + + const tasks = Array.from({length: 200}) + .map(async () => { + const response = await fetchAPI(`tactics/${id}/steps`, { parentId: 1, content: { components: [] } }) + expect(response.status).toBe(200) + const { stepId } = await response.json() + return stepId + }) + + const steps = [] + for (const task of tasks) { + steps.push(await task) + } + steps.sort((a, b) => a - b) + const expected = Array.from({length: 200}, (_, i) => i + 2) + expect(steps).toEqual(expected) +}) \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 214327e..4126e3b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ build: { target: "es2021", }, + test: { + environment: "jsdom" + }, plugins: [ react(), cssInjectedByJsPlugin({