Skip to content
3 changes: 3 additions & 0 deletions docs/live-share-timer-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
- 정지 상태, 정지 값에서 재개, 기준 시각이 미래이거나 2초 넘게 지난 경우는 보정하지 않습니다.
4. 사회자는 남은 시간을 **올림한 정수 초**로 보내므로 실행 중 스냅샷은 실제보다 최대 1초 큽니다. 실행 중 재동기화 차이가 1초 미만이면 기존 목표 시각과 새 목표 시각 중 **더 이른 쪽**을 택해, 5초 주기 SYNC마다 표시가 튀지 않게 합니다.

5. 자유토론(1회 발언 시간 사용)에서 `PLAY`/`STOP` 등은 발언 시간만 보내므로, 전체 시간을 따로 카운트다운하면 지연 보정이 발언 시간에만 적용되어 두 값의 초 경계가 어긋납니다. 사회자는 두 시간을 같이 시작·정지하므로, 청중은 `전체 − 발언` 차이를 오프셋으로 두고 **전체 시간 = 발언 시간 + 오프셋**으로 파생해 두 값이 같은 틱에 바뀌게 합니다. 오프셋은 설정 변경·`SYNC`·`RESET`·`TEAM_SWITCH`(새 팀)에서만 다시 계산합니다. (`useAudienceTimeBasedCountdown`)
6. 자유토론 초기화(`RESET`)는 순서 설정값이 아니라 **현재 턴을 시작했던 시간**(순서 진입 또는 팀 전환 시점의 전체/발언 시간)으로 되돌립니다. 이벤트 발행 시점의 React 상태는 아직 초기화 전 값이므로, 사회자는 턴 시작 시간을 직접 읽어 `remainingTime`과 양 팀 전체 시간(`prosRemainingTime`/`consRemainingTime`)에 담아 보냅니다. 청중은 이 값으로 현재 팀을 되돌리고, 양 팀 전체 시간이 없으면(이전 버전 사회자) 설정값으로 초기화합니다. (`useTimeBasedTimer.getTurnStartTimes`, `useAudienceTimeBasedCountdown`)

- 보정하지 않는 구간: 사회자 → 서버 구간 지연, 1초 이상의 비정상 지연.
- 청중 간 차이는 정수 초 표시 특성상 최대 1초까지 날 수 있습니다.

Expand Down
20 changes: 20 additions & 0 deletions src/page/AudienceSharePage/hooks/EventInterpreter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,26 @@ describe('eventInterpreter 순수 함수', () => {
sequence: 0,
});
});

it('TIME_BASED는 초기화된 현재 팀 시간과 양 팀 전체 시간을 담는다', () => {
const payload: TimerDataPayload = {
timerType: 'TIME_BASED',
remainingTime: 30,
sequence: 1,
currentTeam: 'CONS',
prosRemainingTime: 80,
consRemainingTime: 94,
};

const result = getDisplayDataByEvent('RESET', payload, null, mockTable);
expect(result).toMatchObject({
timerType: 'TIME_BASED',
currentTeam: 'CONS',
isRunning: false,
consTime: 30,
teamTotalTimes: { pros: 80, cons: 94 },
});
});
});

describe('BEFORE 이벤트', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/page/AudienceSharePage/hooks/EventInterpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function createDisplayData(
}

/**
* 자유토론 SYNC 페이로드에서 양 팀의 총 남은 시간을 꺼냅니다.
* 자유토론 SYNC/RESET 페이로드에서 양 팀의 총 남은 시간을 꺼냅니다.
* 둘 중 하나라도 없으면 `undefined`를 반환합니다.
*/
function getTeamTotalTimes(
Expand Down Expand Up @@ -211,6 +211,8 @@ export function getDisplayDataByEvent(
data.sequence,
data.remainingTime,
),
// 자유토론은 사회자가 현재 턴 시작 시간으로 되돌린 값을 보냄
teamTotalTimes: getTeamTotalTimes(data),
});

case 'BEFORE': {
Expand Down
176 changes: 176 additions & 0 deletions src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,54 @@ describe('useAudienceTimeBasedCountdown', () => {
expect(result.current.pros.currentSpeakingRemainingTime).toBe(27);
});

it('네트워크 지연이 있는 재생에서도 전체 시간과 현재 시간이 같은 순간에 감소한다', () => {
vi.setSystemTime(new Date('2026-09-27T00:00:00Z'));
const { result, rerender } = renderHook(
({ displayData, syncedAt }) =>
useAudienceTimeBasedCountdown({
displayData,
timePerTeam: 120,
timePerSpeaking: 30,
syncedAt,
}),
{
initialProps: {
displayData: createDisplayData({
isRunning: false,
eventType: 'STOP',
prosTime: 30,
revision: 1,
}),
syncedAt: Date.now() as number | null,
},
},
);

rerender({
displayData: createDisplayData({
isRunning: true,
eventType: 'PLAY',
prosTime: 30,
revision: 2,
}),
syncedAt: Date.now() - 400,
});

for (let step = 0; step < 30; step += 1) {
act(() => {
vi.advanceTimersByTime(100);
});

expect(
(result.current.pros.totalRemainingTime ?? 0) -
(result.current.pros.currentSpeakingRemainingTime ?? 0),
).toBe(90);
}

expect(result.current.pros.currentSpeakingRemainingTime).toBe(27);
expect(result.current.pros.totalRemainingTime).toBe(117);
});

it('TEAM_SWITCH는 새 팀 현재 시간을 초기화하고 실행 상태를 유지한다', () => {
const { result, rerender } = renderHook(
({ displayData }) =>
Expand Down Expand Up @@ -172,6 +220,51 @@ describe('useAudienceTimeBasedCountdown', () => {
expect(result.current.cons.currentSpeakingRemainingTime).toBe(29);
});

it('설정 변경과 TEAM_SWITCH가 겹치면 새 팀 전체 시간을 새 설정값으로 표시한다', () => {
const { result, rerender } = renderHook(
({ displayData, timePerTeam }) =>
useAudienceTimeBasedCountdown({
displayData,
timePerTeam,
timePerSpeaking: 30,
}),
{
initialProps: {
displayData: createDisplayData({
currentTeam: 'CONS',
isRunning: true,
eventType: 'PLAY',
prosTime: null,
consTime: 30,
revision: 1,
}),
timePerTeam: 120,
},
},
);

act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current.cons.totalRemainingTime).toBe(118);

rerender({
displayData: createDisplayData({
currentTeam: 'CONS',
isRunning: true,
eventType: 'TEAM_SWITCH',
prosTime: 30,
consTime: null,
sequence: 1,
revision: 2,
}),
timePerTeam: 200,
});

expect(result.current.cons.totalRemainingTime).toBe(200);
expect(result.current.cons.currentSpeakingRemainingTime).toBe(30);
});

it('RESET은 현재 팀의 전체/현재 시간을 API 설정값으로 초기화한다', () => {
const { result, rerender } = renderHook(
({ displayData }) =>
Expand Down Expand Up @@ -208,6 +301,89 @@ describe('useAudienceTimeBasedCountdown', () => {
expect(result.current.pros.isRunning).toBe(false);
});

it('RESET은 받은 턴 시작 시간으로 현재 팀을 되돌린다', () => {
const { result, rerender } = renderHook(
({ displayData }) =>
useAudienceTimeBasedCountdown({
displayData,
timePerTeam: 120,
timePerSpeaking: 30,
}),
{
initialProps: {
displayData: createDisplayData({
currentTeam: 'CONS',
isRunning: true,
eventType: 'SYNC',
prosTime: null,
consTime: 30,
teamTotalTimes: { pros: 80, cons: 94 },
revision: 1,
}),
},
},
);

act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current.cons.totalRemainingTime).toBe(89);

rerender({
displayData: createDisplayData({
currentTeam: 'CONS',
isRunning: false,
eventType: 'RESET',
prosTime: null,
consTime: 30,
teamTotalTimes: { pros: 80, cons: 94 },
revision: 2,
}),
});

expect(result.current.cons.totalRemainingTime).toBe(94);
expect(result.current.cons.currentSpeakingRemainingTime).toBe(30);
expect(result.current.pros.totalRemainingTime).toBe(80);
expect(result.current.cons.isRunning).toBe(false);
});

it('전체 시간만 쓰는 RESET은 받은 턴 시작 전체 시간으로 되돌린다', () => {
const { result, rerender } = renderHook(
({ displayData }) =>
useAudienceTimeBasedCountdown({
displayData,
timePerTeam: 120,
timePerSpeaking: null,
}),
{
initialProps: {
displayData: createDisplayData({
isRunning: true,
eventType: 'PLAY',
prosTime: 94,
revision: 1,
}),
},
},
);

act(() => {
vi.advanceTimersByTime(5000);
});

rerender({
displayData: createDisplayData({
isRunning: false,
eventType: 'RESET',
prosTime: 94,
teamTotalTimes: { pros: 94, cons: 120 },
revision: 2,
}),
});

expect(result.current.pros.totalRemainingTime).toBe(94);
});

it('전체 시간 또는 현재 시간이 끝나면 0초에서 함께 정지한다', () => {
const { result } = renderHook(() =>
useAudienceTimeBasedCountdown({
Expand Down
Loading
Loading