Skip to content
Merged
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
17 changes: 16 additions & 1 deletion frontend/src/api/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,22 @@ export const 기본접근성: 접근성 = {
staffAssistancePreferred: false,
};

export const 기본도움설정: 도움설정 = { ...기본접근성, voiceGuide: false, language: "ko-KR" };
/**
* 소리로 듣고 답하기를 **처음부터 켜 둔다.**
*
* 여태 꺼짐이었다. 그런데 이 스위치는 화면을 못 보는 분에게 가장 필요한
* 것인데, 켜려면 화면에서 그 스위치를 찾아 눌러야 했다 — **읽어 줘야 읽을
* 수 있는 사람은 켜러 갈 수가 없다.** 첫 화면으로 옮겨 놓은 것도 그래서였고,
* 이번에 그 마지막 한 걸음까지 없앤다.
*
* 맞바꾸는 것이 있다. 눈으로 보는 분에게는 열자마자 화면을 읽는 소리가 난다.
* 그 대신 끄는 길은 늘 첫 화면 맨 위에 있고, 한 번 끄면 그 설정이 남는다.
* 소리를 못 내는 기기에서는 스위치 자체가 안 보이므로 달라지는 것이 없다.
*
* 마이크가 저절로 열리지는 않는다. 브라우저가 이미 허용해 둔 자리에서만
* 부르는 말을 기다리고(api/wake.ts), 그 전에는 아무것도 듣지 않는다.
*/
export const 기본도움설정: 도움설정 = { ...기본접근성, voiceGuide: true, language: "ko-KR" };

let 값: 도움설정 = { ...기본도움설정 };
const 듣는이 = new Set<() => void>();
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/api/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,41 @@ describe("일회용 연결 (팀 #108)", () => {
.then(() => null, (err: KioBridgeError) => err);
expect(e?.code).toBe("CLAIM_EXPIRED");
});

it("취소한 뒤 다른 주문표를 고르면 매핑에서 먼저 막는다 (팀 #146)", async () => {
/*
* 서버는 거절도 승인과 같은 경로로 처리해 pairing 을 폐기한다. 그래서
* 취소하고 나온 뒤 다른 주문표로 들어가면 죽은 값으로 bind 를 시도하고,
* 서버의 PAIRING_NOT_FOUND 가 "연결 정보를 찾을 수 없습니다" 라는
* 개발자 말로 화면에 그대로 떴다.
*
* 승인 경로에는 이 검사가 있었는데 매핑 경로에는 없었다. 화면은 매핑부터
* 부르므로, 정작 사용자가 먼저 닿는 쪽이 안 막혀 있었다.
*/
const api = createApi(가짜백엔드());
await api.claimPairing("kb");
await api.requestMapping("s1", "p1");
await api.reject({ pairingId: "s1", sheetId: "p1" });

const e = await api
.requestMapping("s1", "p2")
.then(() => null, (err: KioBridgeError) => err);
expect(e?.code).toBe("CLAIM_EXPIRED");
// 되돌릴 수 있는 오류여야 화면이 'QR 다시 찍기' 로 안내한다.
expect(e?.recoverable).toBe(true);
// 개발자 말이 새어 나가면 안 된다.
expect(e?.message).not.toContain("PAIRING_NOT_FOUND");
expect(e?.message).toContain("QR");
});

it("취소하지 않았으면 다른 주문표로 계속 갈 수 있다", async () => {
// 위 시험이 '언제나 막는다' 로 헛통과하지 않게 지킨다. 연결이 살아 있는
// 동안 주문표를 바꿔 보는 것은 정상 흐름이다.
const api = createApi(가짜백엔드());
await api.claimPairing("kb");
await api.requestMapping("s1", "p1");
await expect(api.requestMapping("s1", "p2")).resolves.toBeTruthy();
});
});

describe("주문 입력을 pairing 에 고정하지 못하면 승인까지 가지 않는다 (팀 #108)", () => {
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/api/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,23 @@ export function createApi(
},

async requestMapping(pairingId, sheetId) {
/*
* 이미 다 쓴 연결이면 여기서 먼저 접는다(팀 #146).
*
* 승인이든 거절이든 서버는 끝난 pairing 을 폐기한다(OrchestratorController
* 의 finally 에서 close). 그래서 취소하고 나온 뒤 다른 주문표로 들어가면
* 죽은 값으로 bind 를 시도하게 되고, 서버가 돌려주는 PAIRING_NOT_FOUND 가
* **"연결 정보를 찾을 수 없습니다"** 라는 개발자 말로 화면에 그대로 떴다.
* 무엇이 잘못됐는지도, 무엇을 해야 하는지도 알 수 없는 안내다.
*
* 승인 경로(approve)에는 이 검사가 이미 있었는데 매핑 경로에는 없었다.
* 화면은 매핑부터 부르므로, 정작 사용자가 먼저 닿는 쪽이 안 막혀 있었다.
*
* 되돌릴 수 있는 오류로 던진다(true) — 화면이 QR 다시 찍기로 안내한다.
*/
if (연결끝남.has(pairingId)) {
throw new KioBridgeError("CLAIM_EXPIRED", "이 연결은 이미 사용했어요. QR 을 다시 찍어 주세요", true);
}
// 서버에 주문표 저장소가 없으면 내용을 함께 보내야 한다. 있으면 id 만으로 충분하다.
const profile = getSheet?.(sheetId);
const env = 환경.get(pairingId) ?? environmentId;
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/api/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
* speech.ts 의 `소리를낼수있나` 와 같은 판단이다.
*/

import { 말끝지켜보기 } from "@/api/vad";

export type 못들은이유 = "권한없음" | "소리없음" | "안됨";

/** 이 기기에서 마이크를 녹음할 수 있는가. 화면이 단추를 내밀지 말지 이걸로 정한다. */
Expand Down Expand Up @@ -113,6 +115,16 @@ const 서버로보내기 = async (
export const 들어보기 = (
언어: string,
받기: (결과: { 들은말: string } | { 못들은이유: 못들은이유 }) => void,
/**
* `스스로끝내기` — 말이 끝나면 '그만 듣기' 없이 알아서 보낸다(api/vad.ts).
*
* 끄면 지금까지와 똑같다(사람이 눌러야 끝난다). 켜면 소리의 크기만 기기
* 안에서 지켜보다가, 말이 있었고 그 뒤 조용해진 순간 녹음을 끝낸다.
*
* 지켜보기가 안 되는 브라우저에서도 아무것도 깨지지 않는다 — 그때는 vad 가
* 조용히 물러나고, 사람이 누르는 길과 최대 녹음 시간이 그대로 남는다.
*/
{ 스스로끝내기 = false }: { 스스로끝내기?: boolean } = {},
): { 그만두기: (보내기?: boolean) => void } => {
if (!들을수있나()) {
받기({ 못들은이유: "안됨" });
Expand All @@ -133,8 +145,11 @@ export const 들어보기 = (
let 보낼지 = true;
const 조각들: BlobPart[] = [];
let 최대시간표: ReturnType<typeof setTimeout> | undefined;
let 지켜보기: { 그만보기: () => void } | null = null;

const 마이크끄기 = () => {
지켜보기?.그만보기();
지켜보기 = null;
스트림?.getTracks().forEach((트랙) => 트랙.stop());
};

Expand Down Expand Up @@ -176,10 +191,33 @@ export const 들어보기 = (
* 안 누르고 계속 말해도 무한정 녹음하지 않는다. 15초면 접근성 설정의
* 예/아니오 하나, 주문표 한 축을 답하기에 넉넉하고, Whisper 호출 하나가
* 지나치게 길어지는 것도 막는다.
*
* 스스로끝내기를 켜도 이 그물은 그대로 둔다 — 지켜보기가 못 도는 기기나
* 소리가 계속 나는 곳(식당 앞)에서 끝을 못 잡을 수 있다.
*/
최대시간표 = setTimeout(() => {
try { 레코더?.stop(); } catch { /* 이미 끝났다 */ }
}, 15000);

if (!스스로끝내기) return;
지켜보기 = 말끝지켜보기(스트림, {
// 말이 끝났다. 사람이 '그만 듣기' 를 누른 것과 똑같이 처리한다 —
// 지금까지 녹음한 것을 보낸다.
말이끝나면: () => {
try { 레코더?.stop(); } catch { /* 이미 끝났다 */ }
},
/*
* 기다리는 동안 아무 말도 없었다. 녹음을 버리고 접는다.
*
* 보내면 Whisper 가 빈 소리를 받아 아무 글도 못 내놓고, 사용자는 그
* 왕복 시간만큼 '인식 중…' 을 보고 있다가 같은 안내를 듣는다. 여기서
* 접으면 바로 "잘 안 들렸어요" 로 간다.
*/
아무말도없으면: () => {
보낼지 = false;
try { 레코더?.stop(); } catch { /* 이미 끝났다 */ }
},
});
})();

return {
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/api/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,26 @@ describe("남길 것이 없으면 아무것도 안 쓴다", () => {
expect(이어쓰기.읽기()!.a11y.language).toBe("en-US");
});

it("기본 켜짐인 설정을 안 건드린 것도 남길 것으로 세지 않는다", () => {
/*
* '건드렸다' 는 기본과 다르다는 뜻이지 '켜져 있다' 는 뜻이 아니다.
*
* voiceGuide 가 기본 켜짐이 되면서 이 자리가 깨졌다 — 값이 true 인지로
* 보고 있었던 탓에, 아무것도 안 한 사람의 빈 이용이 전부 저장됐다.
* language 때와 같은 원인이라(기본값이 falsy 하다는 가정) 예외를 더하는
* 대신 기준을 바꿨다.
*/
expect(기본도움설정.voiceGuide).toBe(true);
이어쓰기.쓰기({ ...빈값, a11y: { ...기본도움설정 } });
expect(저장소.length).toBe(0);
});

it("기본 켜짐인 것을 끄면 그 선택은 남긴다", () => {
// 끈 사람은 건드린 것이다. 다음에 열었을 때 다시 켜져 있으면 안 된다.
이어쓰기.쓰기({ ...빈값, a11y: { ...기본도움설정, voiceGuide: false } });
expect(이어쓰기.읽기()!.a11y.voiceGuide).toBe(false);
});

it("언어가 기본값이면 남길 것으로 세지 않는다", () => {
// "ko-KR" 은 truthy 라, 값이 있는지만 보면 아무것도 안 건드린 사람도
// 저장 대상이 된다. 켠 것과 고른 것을 나눠 봐야 한다.
Expand Down
17 changes: 11 additions & 6 deletions frontend/src/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,15 +317,20 @@ const 남길것이있나 = (v: 이어쓸것): boolean =>
/**
* 도움 설정을 하나라도 기본값에서 바꿨는가.
*
* Object.values(...).some(Boolean) 로 훑던 것을 칸별로 본다. language 가 들어오면서
* 그 방식이 깨졌다 — 기본값 "ko-KR" 이 truthy 라, 아무것도 안 건드린 사람도
* '남길 것이 있다' 가 되어 빈 이용이 전부 저장됐다.
* **기본값과 다른지만 본다.** 값이 true 인지로 보면 안 된다.
*
* 켠 것과 고른 것을 나눠 본다. 켜는 값은 true 인지, 고르는 값은 기본과 다른지.
* 이 자리는 두 번 깨졌고 원인이 같았다 — 기본값이 falsy 라는 가정이다.
* 처음에는 Object.values(...).some(Boolean) 이었는데 language 가 들어오면서
* 깨졌고("ko-KR" 이 truthy 라 아무것도 안 건드린 사람이 전부 저장됐다),
* 그것을 language 만 예외로 두어 막았다. 그 뒤 voiceGuide 가 기본 켜짐이 되자
* 같은 일이 다시 일어났다.
*
* 예외를 하나씩 더하는 대신 기준을 바꾼다. '건드렸다' 는 **기본과 다르다** 는
* 뜻이지 '켜져 있다' 는 뜻이 아니다 — 기본 켜짐인 것을 끈 사람도 건드린 것이고,
* 그 선택은 남겨 두어야 한다.
*/
const 도움설정을건드렸나 = (a: 도움설정): boolean =>
(Object.keys(기본도움설정) as (keyof 도움설정)[]).some((칸) =>
칸 === "language" ? a.language !== 기본도움설정.language : a[칸] === true);
(Object.keys(기본도움설정) as (keyof 도움설정)[]).some((칸) => a[칸] !== 기본도움설정[칸]);

export const 이어쓰기 = {
/**
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/api/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,51 @@ export const 화면글 = (뿌리: HTMLElement, { 바뀌는것빼고 = false } =
return 줄;
};

/**
* 읽고 있는 것이 다 끝날 때까지 기다린다.
*
* 다음 질문을 스스로 시작할 때 꼭 필요하다. 안 기다리면 마이크가 안내를 읽는
* 도중에 열리고, 두 가지가 한꺼번에 어긋난다.
*
* ① 듣기시작() 이 그만읽기() 를 부르므로 안내가 중간에서 잘린다. 화면을 못
* 보는 분은 무엇을 묻는지 못 들은 채 답해야 한다.
* ② 자르지 않더라도 스피커 소리를 마이크가 그대로 주워듣는다. 그 소리가
* 녹음에 실려 인식을 망친다(읽어주기 주석의 같은 얘기).
*
* 최대 기다릴 시간을 둔다. 읽기가 끝났는데 speaking 이 안 내려가는 브라우저가
* 있고, 거기서 영원히 멈추면 사용자는 아무 일도 안 일어나는 화면을 본다.
*
* ── '아직 안 읽음' 과 '다 읽음' 은 겉보기가 같다 ────────────────────────────
*
* 둘 다 speaking·pending 이 false 다. 그래서 읽기가 걸리기 **전에** 물어보면
* 곧바로 "다 읽었다" 고 답해 버린다 — 부르는 쪽은 마이크를 열고, 그 직후 안내
* 읽기가 시작됐다가 듣기시작() 의 그만읽기() 에 잘린다. 화면을 못 보는 분은
* 무엇을 묻는지 못 들은 채 답해야 한다.
*
* `시작기다림` 을 주면 그 동안은 접지 않는다. 한 번이라도 읽는 것을 본 뒤에는
* 바로 끝난다 — 짧은 안내를 읽는 동안 헛되이 기다리지 않는다.
*/
export const 다읽을때까지 = (최대 = 8000, { 시작기다림 = 0 } = {}): Promise<void> =>
new Promise((끝) => {
if (!소리를낼수있나()) { 끝(); return; }
const 시작 = Date.now();
let 읽기를봤나 = false;
let 시계: ReturnType<typeof setInterval>;
const 보기 = () => {
let 읽는중 = false;
try {
읽는중 = globalThis.speechSynthesis.speaking || globalThis.speechSynthesis.pending;
} catch { /* 못 물어보면 안 읽는 것으로 본다 */ }
if (읽는중) 읽기를봤나 = true;
const 지난 = Date.now() - 시작;
// 아직 시작도 안 했을 수 있다. 시작기다림 이 지나기 전에는 안 접는다.
const 접어도되나 = 읽기를봤나 || 지난 >= 시작기다림;
if ((!읽는중 && 접어도되나) || 지난 >= 최대) { clearInterval(시계); 끝(); }
};
시계 = setInterval(보기, 120);
보기();
});

/** 읽던 것을 멈춘다. 스위치를 끄거나 화면을 떠날 때 부른다. */
export const 그만읽기 = (): void => {
try {
Expand Down
Loading