Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';

import { type paths } from '@schema';
import { client, TanstackQueryClient } from '@/apis/client';
import { client } from '@/apis/client';

type UpdateHandwritingRequest =
paths['/api/student/scrap/{scrapId}/handwriting']['put']['requestBody']['content']['application/json'];
Expand All @@ -14,28 +14,21 @@ interface UpdateHandwritingParams {
}

export const useUpdateHandwriting = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({
scrapId,
request,
}: UpdateHandwritingParams): Promise<UpdateHandwritingResponse> => {
const { data } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', {
const { data, response } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', {
params: {
path: { scrapId },
},
body: request,
});
if (!response.ok) {
throw new Error(`handwriting PUT failed: ${response.status}`);
}
return data as UpdateHandwritingResponse;
},
onSuccess: (response, { scrapId }) => {
queryClient.setQueryData(
TanstackQueryClient.queryOptions('get', '/api/student/scrap/{scrapId}/handwriting', {
params: { path: { scrapId } },
}).queryKey,
response
);
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
runExplicitFlushLoop,
type FlushPendingQueue,
type RunExplicitFlushLoopDeps,
} from '../handwritingFlushPending';
import type { ExplicitFlushResult } from '../../services/handwritingSaveQueue';

const SCRAP_ID = 100;
const DATA = '{"strokes":[],"texts":[]}';

const makeQueue = (
outcomes: ExplicitFlushResult['outcome'][]
): FlushPendingQueue & {
flushExplicit: jest.Mock;
dequeue: jest.Mock;
} => {
const queue = {
flushExplicit: jest.fn(),
dequeue: jest.fn(),
};
outcomes.forEach((outcome, i) => {
queue.flushExplicit.mockResolvedValueOnce({ outcome, version: i + 1 });
});
return queue;
};

describe('runExplicitFlushLoop', () => {
it('success → return "success", alert 호출 0', async () => {
const queue = makeQueue(['success']);
const showRetryAlert = jest.fn();
const onDiscard = jest.fn();

const result = await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
});

expect(result).toBe('success');
expect(queue.flushExplicit).toHaveBeenCalledTimes(1);
expect(queue.flushExplicit).toHaveBeenCalledWith(SCRAP_ID, DATA);
expect(showRetryAlert).not.toHaveBeenCalled();
expect(queue.dequeue).not.toHaveBeenCalled();
expect(onDiscard).not.toHaveBeenCalled();
});

it('retry → 사용자 "다시 시도" → 두번째 success → return "success"', async () => {
const queue = makeQueue(['retry', 'success']);
const showRetryAlert = jest.fn().mockResolvedValueOnce('retry');
const onDiscard = jest.fn();

const result = await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
});

expect(result).toBe('success');
expect(queue.flushExplicit).toHaveBeenCalledTimes(2);
expect(showRetryAlert).toHaveBeenCalledTimes(1);
expect(queue.dequeue).not.toHaveBeenCalled();
expect(onDiscard).not.toHaveBeenCalled();
});

it('retry → 사용자 "확인" (discard) → dequeue + onDiscard → return "discard"', async () => {
const queue = makeQueue(['retry']);
const showRetryAlert = jest.fn().mockResolvedValue('discard');
const onDiscard = jest.fn();

const result = await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
});

expect(result).toBe('discard');
expect(queue.flushExplicit).toHaveBeenCalledTimes(1);
expect(showRetryAlert).toHaveBeenCalledTimes(1);
expect(queue.dequeue).toHaveBeenCalledWith(SCRAP_ID);
expect(onDiscard).toHaveBeenCalledTimes(1);
});

it('hold → 사용자 "다시 시도" → success → return "success"', async () => {
const queue = makeQueue(['hold', 'success']);
const showRetryAlert = jest.fn().mockResolvedValueOnce('retry');
const onDiscard = jest.fn();

const result = await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
});

expect(result).toBe('success');
expect(showRetryAlert).toHaveBeenCalledTimes(1);
});

it('timeout → 사용자 "확인" → discard', async () => {
const queue = makeQueue(['timeout']);
const showRetryAlert = jest.fn().mockResolvedValue('discard');
const onDiscard = jest.fn();

const result = await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
});

expect(result).toBe('discard');
expect(queue.dequeue).toHaveBeenCalledWith(SCRAP_ID);
expect(onDiscard).toHaveBeenCalledTimes(1);
});

it('연속 retry 3회 → 마지막 discard → dequeue + onDiscard', async () => {
const queue = makeQueue(['retry', 'retry', 'retry']);
const showRetryAlert = jest
.fn()
.mockResolvedValueOnce('retry')
.mockResolvedValueOnce('retry')
.mockResolvedValueOnce('discard');
const onDiscard = jest.fn();

const result = await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
});

expect(result).toBe('discard');
expect(queue.flushExplicit).toHaveBeenCalledTimes(3);
expect(showRetryAlert).toHaveBeenCalledTimes(3);
expect(queue.dequeue).toHaveBeenCalledTimes(1);
expect(onDiscard).toHaveBeenCalledTimes(1);
});

it('discard 시 dequeue 가 onDiscard 보다 먼저 호출됨 (순서 보장)', async () => {
const queue = makeQueue(['retry']);
const calls: string[] = [];
queue.dequeue.mockImplementation(() => calls.push('dequeue'));
const showRetryAlert = jest.fn().mockResolvedValue('discard');
const onDiscard = jest.fn().mockImplementation(() => calls.push('onDiscard'));

await runExplicitFlushLoop({
scrapId: SCRAP_ID,
dataJson: DATA,
queue,
showRetryAlert,
onDiscard,
} satisfies RunExplicitFlushLoopDeps);

expect(calls).toEqual(['dequeue', 'onDiscard']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* explicit flush 의 핵심 분기 로직을 hook 외부 pure 함수로 추출.
*
* 책임:
* - 큐 flushExplicit 호출 → outcome 분기
* - success → return
* - 그 외 (retry/hold/timeout) → showRetryAlert 호출 → 사용자 결정에 따라 retry 또는 discard
*
* hook 외부 pure 함수라 jest 로 직접 단위 테스트 가능.
*/
import type { ExplicitFlushResult } from '../services/handwritingSaveQueue';

export interface FlushPendingQueue {
flushExplicit: (scrapId: number, data: string) => Promise<ExplicitFlushResult>;
dequeue: (scrapId: number) => void;
}

export interface RunExplicitFlushLoopDeps {
scrapId: number;
dataJson: string;
queue: FlushPendingQueue;
/** 사용자에게 "재시도/확인" Alert 표시. 'retry' / 'discard' 반환. */
showRetryAlert: () => Promise<'retry' | 'discard'>;
/** discard 결정 시 호출 (예: queryClient.removeQueries 등 cache cleanup). */
onDiscard: () => void;
}

/**
* @returns 'success' | 'discard'
*/
export async function runExplicitFlushLoop(
deps: RunExplicitFlushLoopDeps
): Promise<'success' | 'discard'> {
while (true) {
const result = await deps.queue.flushExplicit(deps.scrapId, deps.dataJson);
if (result.outcome === 'success') {
return 'success';
}
const action = await deps.showRetryAlert();
if (action === 'retry') continue;
deps.queue.dequeue(deps.scrapId);
deps.onDiscard();
return 'discard';
}
}
21 changes: 1 addition & 20 deletions apps/native/src/features/student/scrap/hooks/useDrawingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,13 @@ export interface DrawingState {
eraserSize: number;
canUndo: boolean;
canRedo: boolean;
hasUnsavedChanges: boolean;
}

type DrawingAction =
| { type: 'SET_MODE'; mode: DrawingMode }
| { type: 'SET_STROKE_WIDTH'; width: number }
| { type: 'SET_ERASER_SIZE'; size: number }
| { type: 'SET_HISTORY_STATE'; canUndo: boolean; canRedo: boolean }
| { type: 'SET_UNSAVED_CHANGES'; hasChanges: boolean }
| { type: 'MARK_AS_SAVED' }
| { type: 'RESET' };

const initialState: DrawingState = {
Expand All @@ -26,7 +23,6 @@ const initialState: DrawingState = {
eraserSize: 22,
canUndo: false,
canRedo: false,
hasUnsavedChanges: false,
};

function drawingReducer(state: DrawingState, action: DrawingAction): DrawingState {
Expand All @@ -38,16 +34,7 @@ function drawingReducer(state: DrawingState, action: DrawingAction): DrawingStat
case 'SET_ERASER_SIZE':
return { ...state, eraserSize: action.size };
case 'SET_HISTORY_STATE':
return {
...state,
canUndo: action.canUndo,
canRedo: action.canRedo,
hasUnsavedChanges: true,
};
case 'SET_UNSAVED_CHANGES':
return { ...state, hasUnsavedChanges: action.hasChanges };
case 'MARK_AS_SAVED':
return { ...state, hasUnsavedChanges: false };
return { ...state, canUndo: action.canUndo, canRedo: action.canRedo };
case 'RESET':
return initialState;
default:
Expand Down Expand Up @@ -82,10 +69,6 @@ export function useDrawingState() {
dispatch({ type: 'SET_HISTORY_STATE', canUndo, canRedo });
}, []);

const markAsSaved = useCallback(() => {
dispatch({ type: 'MARK_AS_SAVED' });
}, []);

const reset = useCallback(() => {
dispatch({ type: 'RESET' });
}, []);
Expand All @@ -100,7 +83,6 @@ export function useDrawingState() {
eraserSize: state.eraserSize,
canUndo: state.canUndo,
canRedo: state.canRedo,
hasUnsavedChanges: state.hasUnsavedChanges,

// Actions
setPenMode,
Expand All @@ -109,7 +91,6 @@ export function useDrawingState() {
setStrokeWidth,
setEraserSize,
setHistoryState,
markAsSaved,
reset,
};
}
Loading