Commit cad802b0 authored by kourser's avatar kourser
Browse files

Correctif : la sonnerie ne s'arrêtait pas sur les autres onglets du joueur

Signalé à l'usage : décrocher un appel laissait sonner l'autre navigateur.

La cause

La salle Socket.IO d'un joueur est `ex:{exerciseId}:p:{participantId}` — indexée
sur le PARTICIPANT, pas sur l'onglet. Le même lien joueur ouvert dans deux
navigateurs place donc les deux sockets dans la même salle, et `voice:incoming`
est diffusé à toute la salle : tous les onglets sonnent, ce qui est voulu.

Mais le décrochage n'était annoncé qu'à l'animateur, sur `call.staffSocketId`.
Rien ne revenait vers la salle du participant. L'onglet où l'on cliquait se
taisait bien — `stopRing()` est appelé en première ligne d'`accept()` — et les
autres n'avaient aucun moyen de savoir.

Sans filet, de surcroît : `answer()` désarme le minuteur d'abandon des 45 s
introduit par 1b339706, précisément pour ne pas couper une conversation décrochée.
Les autres onglets sonnaient donc pendant TOUTE la conversation, et non 45 s.

Le correctif

Un événement `voice:taken`, symétrique de `announceEnded` qui prévient déjà les
deux côtés d'une fin d'appel. C'est l'absence de cette symétrie au décrochage qui
faisait le défaut.

Émis depuis `client` et non depuis `this.server` : Socket.IO exclut alors
l'émetteur de sa propre diffusion. L'onglet qui vient de décrocher n'est donc pas
averti de son propre décrochage — sinon il raccrocherait l'appel qu'il vient de
prendre. Cela évite aussi de faire circuler un identifiant de socket dans la
charge pour que chaque client se reconnaisse.

Côté joueur, les autres onglets se taisent et rendent l'écran par
`closeWith('ANSWERED')`, chemin déjà existant : l'appel a bien été pris, et
l'historique local de ces onglets doit le lire ainsi. Leur `incomingRef` devient
nul, donc le `voice:ended` diffusé plus tard au raccrochage est ignoré par le
garde d'identifiant — pas de double écriture dans l'historique.

Tests

Deux tests de passerelle. Le premier tombe si l'annonce est retirée, et tombe
AUSSI si elle est émise depuis `this.server` au lieu de `client` — c'est ce
second cas qui compte, une diffusion trop large raccrochant l'appel de celui qui
vient de le prendre. Le second est une contre-épreuve : un décrochage refusé
(`NOT_RINGING`) n'annonce rien, ni au joueur ni à l'animateur.

Le harnais de test des sockets gagne un `to()`, la passerelle diffusant
désormais AUSSI depuis la socket et non seulement depuis le serveur.

Vérifications

NON EXÉCUTÉES : ni node ni pnpm n'étaient disponibles dans l'environnement de
travail, et node_modules n'y est pas installé. Tests, eslint et typechecks
restent donc à passer avant fusion, et le trajet complet à reprendre à deux
navigateurs. La relecture statique est faite : formatage prettier conforme,
`participantRoom` déjà importé dans la passerelle, aucun test existant
n'atteignait le chemin nominal d'`onVoiceAnswer`.

Défauts voisins relevés, NON traités

Micro laissé ouvert : `accept()` ne lit pas l'accusé de réception de
`voice:answer`. Si deux onglets décrochent au même instant, le perdant reçoit
`NOT_RINGING`, l'ignore, et affiche « appel en cours » avec un micro capté pour
rien. Ce correctif réduit la fenêtre sans la fermer.

Socket orpheline : dans Player.tsx, `connectSocket()` est appelé après plusieurs
`await` sans nouveau contrôle de `cancelled`. Un démontage pendant le chargement
laisse une socket jamais fermée, qui reste dans la salle du participant et gonfle
le décompte servant à `playerOnline`.

Co-Authored-By: Claude (RCA)
parent 1b339706
Loading
Loading
Loading
Loading
+9 −0
Original line number Diff line number Diff line
@@ -82,6 +82,15 @@ export const RT = {
  voiceIncoming: 'voice:incoming',
  /** Vers l'animateur : le joueur a decroche (porte la reponse SDP). */
  voiceAnswered: 'voice:answered',
  /**
   * Vers les AUTRES onglets du joueur : l'appel a ete pris ailleurs.
   *
   * `voiceIncoming` est diffuse a la salle du participant, donc a chacune de ses
   * sockets : sans cette annonce, les onglets ou personne n'a clique sonnaient
   * jusqu'a la fin de la conversation, le minuteur d'abandon etant desarme par
   * le decrochage.
   */
  voiceTaken: 'voice:taken',
  /** Vers l'animateur : le joueur a refuse. */
  voiceDeclined: 'voice:declined',
  /** Vers les deux : l'appel est termine. */
+62 −3
Original line number Diff line number Diff line
@@ -20,9 +20,23 @@ const APPEL: CallRecord = {
  ringingAtMs: 1_000,
};

/** Socket minimale : seul `data.ctx` compte pour l'autorisation. */
function socket(id: string, ctx: unknown) {
  return { id, data: { ctx } } as never;
/**
 * Socket minimale : seul `data.ctx` compte pour l'autorisation.
 *
 * `to()` est fourni parce que la passerelle diffuse aussi DEPUIS la socket, et
 * non seulement depuis le serveur : c'est ce qui exclut l'emetteur de sa propre
 * diffusion. `diffuses` recueille ces envois quand un test s'y interesse.
 */
function socket(id: string, ctx: unknown, diffuses: { room: string; event: string }[] = []) {
  return {
    id,
    data: { ctx },
    to: (room: string) => ({
      emit: (event: string) => {
        diffuses.push({ room, event });
      },
    }),
  } as never;
}

function build(
@@ -240,3 +254,48 @@ describe('handleDisconnect — décompte des onglets du joueur', () => {
    expect(fins).toEqual([]);
  });
});

/*
 * Decrochage et sonneries des AUTRES onglets (correctif de la sonnerie qui ne
 * s'arretait pas).
 *
 * `voice:incoming` est diffuse a la salle du participant, laquelle est indexee
 * sur le participant et non sur l'onglet : tous les onglets du joueur sonnent,
 * y compris ceux ouverts dans un autre navigateur. Le decrochage n'etait annonce
 * qu'a l'animateur, donc les autres sonnaient jusqu'a la fin de la conversation
 * — le minuteur d'abandon ayant justement ete desarme par ce decrochage.
 */
describe('voice:answer — arret de la sonnerie sur les autres onglets', () => {
  const JOUEUR = { kind: 'player', pid: 'p-1', eid: 'ex-1' };

  it('annonce la prise d’appel dans la salle du participant', async () => {
    const { gateway, emitted } = build();
    const diffuses: { room: string; event: string }[] = [];

    const res = await gateway.onVoiceAnswer(socket('sock-onglet-1', JOUEUR, diffuses), {
      callId: 'appel-1',
      sdp: OFFRE,
    });

    expect(res).toEqual({ ok: true });
    // Emis DEPUIS la socket : l'onglet qui vient de decrocher est exclu, il ne
    // doit pas raccrocher son propre appel en recevant son propre decrochage.
    expect(diffuses).toEqual([{ room: 'ex:ex-1:p:p-1', event: 'voice:taken' }]);
    // L'animateur, lui, recoit toujours la reponse SDP et rien d'autre.
    expect(emitted).toEqual([{ room: 'sock-animateur', event: 'voice:answered' }]);
  });

  it('n’annonce rien quand le decrochage est refuse', async () => {
    const { gateway, emitted } = build({ answer: () => undefined });
    const diffuses: { room: string; event: string }[] = [];

    const res = await gateway.onVoiceAnswer(socket('sock-onglet-1', JOUEUR, diffuses), {
      callId: 'appel-1',
      sdp: OFFRE,
    });

    expect(res).toEqual({ ok: false, reason: 'NOT_RINGING' });
    expect(diffuses).toEqual([]);
    expect(emitted).toEqual([]);
  });
});
+14 −0
Original line number Diff line number Diff line
@@ -268,6 +268,20 @@ export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect
    this.server
      .to(call.staffSocketId)
      .emit(RT.voiceAnswered, { callId: call.callId, sdp: body.sdp });

    /*
     * Les AUTRES sockets du joueur sonnent encore : `voiceIncoming` est diffuse a
     * la salle du participant, laquelle est indexee sur le participant et non sur
     * l'onglet. `client.to(...)` exclut l'emetteur, donc l'onglet qui vient de
     * decrocher n'est pas averti de son propre decrochage.
     *
     * Sans cela, la sonnerie ne s'arretait que la ou l'on avait clique : ailleurs
     * elle durait toute la conversation, le decrochage ayant desarme le minuteur
     * d'abandon.
     */
    client
      .to(participantRoom(call.exerciseId, call.participantId))
      .emit(RT.voiceTaken, { callId: call.callId });
    return { ok: true };
  }

+12 −0
Original line number Diff line number Diff line
@@ -157,6 +157,16 @@ export function usePlayerVoice(socket: Socket | null, enabled: boolean): PlayerV
      else pendingIceRef.current.push(p.candidate);
    };

    /*
     * L'appel a ete pris sur un AUTRE onglet du meme joueur. Le serveur n'envoie
     * cet evenement qu'aux onglets qui n'ont pas decroche : il n'y a donc rien a
     * distinguer ici, il faut se taire et rendre l'ecran.
     */
    const onTaken = (p: { callId: string }): void => {
      if (incomingRef.current?.callId !== p.callId) return;
      closeWith('ANSWERED');
    };

    const onEnded = (p: { callId: string }): void => {
      if (incomingRef.current?.callId !== p.callId) return;
      closeWith(phaseRef.current === 'ACTIVE' ? 'ANSWERED' : 'MISSED');
@@ -164,10 +174,12 @@ export function usePlayerVoice(socket: Socket | null, enabled: boolean): PlayerV

    socket.on(RT.voiceIncoming, onIncoming);
    socket.on(RT.voiceIce, onIce);
    socket.on(RT.voiceTaken, onTaken);
    socket.on(RT.voiceEnded, onEnded);
    return () => {
      socket.off(RT.voiceIncoming, onIncoming);
      socket.off(RT.voiceIce, onIce);
      socket.off(RT.voiceTaken, onTaken);
      socket.off(RT.voiceEnded, onEnded);
    };
  }, [socket, enabled, closeWith]);
+2 −0
Original line number Diff line number Diff line
@@ -27,6 +27,8 @@ export const RT = {
  // --- Appels vocaux 1-pour-1 (EV-15) ---
  voiceIncoming: 'voice:incoming',
  voiceAnswered: 'voice:answered',
  /** L'appel a ete pris sur un autre onglet du meme joueur : cesser de sonner. */
  voiceTaken: 'voice:taken',
  voiceDeclined: 'voice:declined',
  voiceEnded: 'voice:ended',
  voiceIce: 'voice:ice',