diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index a4e637b..c99d61e 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useLayoutEffect, useId } from "react"; +import { useState, useRef, useEffect, useLayoutEffect, useId, useMemo } from "react"; import { ChevronLeft, Check } from "lucide-react"; import { Pictogram } from "@/design/Pictogram"; @@ -32,7 +32,7 @@ import type { AllergenId } from "@/api/canonical"; import { 이어쓰기 } from "@/api/session"; import { 영어로바꾸기, 되돌리기, 안바뀐것, 돈 } from "@/i18n/apply"; import { t, tf } from "@/i18n/t"; -import { 이유글 } from "@/i18n/reason"; +import { 이유글, 이유묶기 } from "@/i18n/reason"; import { 백엔드가아는장소 } from "@/api/canonical"; import BackendLog from "@/app/BackendLog"; @@ -3531,6 +3531,7 @@ function ReasonStep({ reasons, scoredAxes = [], onNext, 확인중 }: { /** 되묻는 상황이면 다음 화면에서 할 일을 미리 알려 준다. */ 확인중?: boolean; }) { + // 합치기는 부르는 쪽에서 이미 했다(OrderConfirmScreen 의 이유들). const 쓴것 = reasons.filter((r) => r.kind === "used"); const 못맞춘것 = reasons.filter((r) => r.kind === "unmet"); const 뺀것 = reasons.filter((r) => r.kind === "excluded"); @@ -4034,6 +4035,17 @@ function OrderConfirmScreen({ */ const 한도 = 가격한도.읽기(); const 수량 = Number((sheet.selections?.["수량"]?.[0] ?? "").replace(/[^0-9]/g, "")) || 1; + /* + * 이유는 여기서 한 번만 합쳐 아래로 내려보낸다. + * + * 서버가 맞은 축마다 한 줄씩 주는 탓에 같은 메뉴 이름이 되풀이됐다 + * (i18n/reason.ts 의 이유묶기). 화면마다 따로 합치면 접힌 한 줄이 세는 + * "외 N개" 와 펼친 목록의 줄 수가 어긋난다 — 둘이 같은 목록을 봐야 한다. + */ + const 이유들 = useMemo( + () => 이유묶기(mapping?.reasons ?? [], sheet.selections), + [mapping?.reasons, sheet.selections], + ); /* * 승인 버튼이 있는 화면은 셋이다 — exact · changed · low_confidence. 셋 다 * MappedItem 을 그리고 셋 다 담긴다. 한 곳에만 안내를 달면 나머지 둘에서는 @@ -4167,7 +4179,7 @@ function OrderConfirmScreen({ */} {mapping && 이유단계 && ( set이유먼저(false)} @@ -4193,13 +4205,13 @@ function OrderConfirmScreen({ * 목은 이제 그 경우를 not_found 로 답하지만, 화면이 서버를 믿고 단정할 이유는 없다. */} {mapping?.result === "exact" && mapping.item && ( - set이유먼저(true)} onApprove={() => approve()} onCancel={거절하기} /> + set이유먼저(true)} onApprove={() => approve()} onCancel={거절하기} /> )} {mapping?.result === "clarification" && ( set이유먼저(true)} options={mapping.sheetOptions} onApprove={(candidateId) => approve({ candidateId })} @@ -4211,7 +4223,7 @@ function OrderConfirmScreen({ set이유먼저(true)} onApprove={() => approve({ acknowledgedDiff: true })} @@ -4232,7 +4244,7 @@ function OrderConfirmScreen({ {mapping?.result === "low_confidence" && mapping.item && ( set이유먼저(true)} /* 사용자가 카드를 눌러 "이 메뉴가 맞다"고 짚어야만 여기까지 온다. 그 사실을 서버에도 알린다. */ diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 8cda252..f5eae5d 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -89,6 +89,17 @@ export const EN: Record = { "Some of your info needs another look — confidence is low.", "지금 시간대가 붐벼서, 매장에서 기다리지 않고 바로 받으실 수 있는 포장 메뉴를 먼저 보여드립니다.": "It's a busy hour, so takeout menus you can grab without waiting come first.", + /* + * 여러 축이 맞았을 때 우리가 한 줄로 합친 문장(i18n/reason.ts 의 이유묶기). + * 서버가 주는 말이 아니라 우리가 만든 틀이라 자리표시자가 들어 있다. + * + * 조사 때문에 열쇠가 둘이다 — "포장하기, 매운맛과" 는 받침이 있어 '과', + * "매운맛, 포장하기와" 는 없어서 '와' 다. 영어는 조사가 없어 같은 말이 된다. + */ + "선호하신 {것들}과 맞는 메뉴라 우선 추천드립니다.": + "Recommended first — it matches the {것들} you chose.", + "선호하신 {것들}와 맞는 메뉴라 우선 추천드립니다.": + "Recommended first — it matches the {것들} you chose.", "선호하신 이용 방식과 다릅니다.": "Differs from the service type you chose.", "선호하신 맵기와 다릅니다.": "Differs from the spice level you chose.", "선호하신 뼈/순살과 다릅니다.": "Differs from the bone type you chose.", diff --git a/frontend/src/i18n/reason.test.ts b/frontend/src/i18n/reason.test.ts index e88f746..f0b2b22 100644 --- a/frontend/src/i18n/reason.test.ts +++ b/frontend/src/i18n/reason.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { 이유글 } from "./reason"; +import { 이유글, 이유묶기 } from "./reason"; import { 접근성설정 } from "../api/a11y"; import type { RecommendationReason } from "../domain/types"; @@ -149,3 +149,118 @@ describe("이유글", () => { expect(이유글(r)).toBe("이어 붙여 둔 완성문"); }); }); + +/* + * 서버는 맞은 축마다 한 줄씩 따로 준다. 두 축이 맞으면 화면이 이렇게 됐다. + * + * 반영: 포장 전용 닭강정 — 선호하신 이용 방식과 일치하는 메뉴라 우선 추천드립니다. + * 반영: 포장 전용 닭강정 — 선호하신 맵기와 맞는 메뉴라 우선 추천드립니다. + * + * 메뉴 이름과 뒷말이 통째로 되풀이돼서, 두 줄을 다 읽어야 무엇이 다른지 알 수 + * 있다. 한 줄로 합치고, 축 이름 대신 사용자가 고른 값으로 부른다. + */ +describe("이유묶기", () => { + const 맞음 = (메뉴: string, 문장: string): RecommendationReason => + ({ kind: "used", text: `${메뉴} — ${문장}`, 메뉴, 문장 }) as RecommendationReason; + + const 이용방식 = "선호하신 이용 방식과 일치하는 메뉴라 우선 추천드립니다."; + const 맵기 = "선호하신 맵기와 맞는 메뉴라 우선 추천드립니다."; + const 형태 = "선호하신 뼈/순살과 일치하는 메뉴라 우선 추천드립니다."; + + const 주문표 = { "이용 방식": ["포장하기"], "맵기": ["매운맛"], "형태": ["순살"] }; + + it("같은 메뉴의 두 줄을 한 줄로 합치고, 고르신 값으로 부른다", () => { + const 합침 = 이유묶기([맞음("포장 전용 닭강정", 이용방식), 맞음("포장 전용 닭강정", 맵기)], 주문표); + expect(합침).toHaveLength(1); + expect(이유글(합침[0])).toBe( + "포장 전용 닭강정 — 선호하신 포장하기, 매운맛과 맞는 메뉴라 우선 추천드립니다.", + ); + }); + + it("조사를 마지막 값에 맞춘다 — '맵기과' 가 되면 안 된다", () => { + // 끝이 '기'(받침 없음)면 '와', '맛'(받침 있음)이면 '과'. + const 순서바꿈 = 이유묶기([맞음("A", 맵기), 맞음("A", 이용방식)], 주문표); + expect(이유글(순서바꿈[0])).toContain("매운맛, 포장하기와 맞는"); + }); + + it("주문표에 값이 없으면 축 이름 그대로 둔다 — 지어내지 않는다", () => { + const 합침 = 이유묶기([맞음("A", 이용방식), 맞음("A", 맵기)], {}); + expect(이유글(합침[0])).toBe("A — 선호하신 이용 방식, 맵기와 맞는 메뉴라 우선 추천드립니다."); + }); + + it("'상관없음' 은 값으로 안 쓴다 — 축 이름이 더 알려 준다", () => { + const 합침 = 이유묶기([맞음("A", 이용방식), 맞음("A", 맵기)], { "이용 방식": ["상관없음"], "맵기": ["매운맛"] }); + expect(이유글(합침[0])).toContain("이용 방식, 매운맛과"); + }); + + it("서버 축 이름이 주문표와 달라도 값을 찾는다 — 뼈/순살은 '형태' 다", () => { + const 합침 = 이유묶기([맞음("A", 형태), 맞음("A", 맵기)], 주문표); + expect(이유글(합침[0])).toContain("순살, 매운맛과"); + }); + + it("한 축만 맞은 줄은 손대지 않는다", () => { + // 되풀이가 없어 합칠 것이 없고, 이 문장은 표에 열쇠가 그대로 있다. + const 그대로 = 이유묶기([맞음("A", 맵기)], 주문표); + expect(그대로).toHaveLength(1); + expect(이유글(그대로[0])).toBe("A — 선호하신 맵기와 맞는 메뉴라 우선 추천드립니다."); + }); + + it("같은 축이 두 번 와도 한 번만 센다 — '매운맛·매운맛' 이 되면 안 된다", () => { + /* + * 서버는 축마다 한 줄씩 주지만 겹쳐 온 적이 있는 자리다. 그대로 세면 + * 같은 조건을 두 번 말하는 줄이 되어, 합치지 않느니만 못하다. + * 축이 하나뿐이므로 합치지 않고 첫 줄을 그대로 둔다. + */ + const 겹침 = 이유묶기([맞음("A", 맵기), 맞음("A", 맵기)], 주문표); + expect(이유글(겹침[0])).not.toContain("매운맛, 매운맛"); + expect(겹침).toHaveLength(2); + expect(이유글(겹침[0])).toBe("A — 선호하신 맵기와 맞는 메뉴라 우선 추천드립니다."); + }); + + it("겹친 축이 있어도 서로 다른 축이 둘이면 합친다", () => { + const 합침 = 이유묶기([맞음("A", 맵기), 맞음("A", 맵기), 맞음("A", 이용방식)], 주문표); + /* + * 길이를 먼저 본다. 첫 문장만 맞게 만들고 겹친 줄을 둘째 항목으로 남겨도 + * [0] 검사만으로는 통과한다 — 화면에는 같은 말이 한 줄 더 붙는데도. + */ + expect(합침).toHaveLength(1); + expect(이유글(합침[0])).toBe("A — 선호하신 매운맛, 포장하기와 맞는 메뉴라 우선 추천드립니다."); + }); + + it("메뉴가 다르면 안 합친다", () => { + const 둘 = 이유묶기([맞음("A", 이용방식), 맞음("B", 맵기)], 주문표); + expect(둘).toHaveLength(2); + }); + + it("못 맞춘 것과 뺀 것은 건드리지 않는다", () => { + const 원래 = [ + { kind: "unmet", text: "x", 문장: "선호하신 맵기와 다릅니다." }, + { kind: "excluded", text: "y", 문장: "지금은 품절이라 제외됐어요." }, + ] as RecommendationReason[]; + expect(이유묶기(원래, 주문표)).toEqual(원래); + }); + + it("합친 자리는 첫 줄 자리를 지킨다", () => { + const 못맞춤 = { kind: "unmet", text: "z", 문장: "선호하신 컵 옵션과 다릅니다." } as RecommendationReason; + const 나온것 = 이유묶기([맞음("A", 이용방식), 못맞춤, 맞음("A", 맵기)], 주문표); + expect(나온것).toHaveLength(2); + expect(나온것[1]).toBe(못맞춤); + }); + + it("합친 줄도 영어로 바꾸면 우리말이 안 남는다", () => { + 영어로(); + const 합침 = 이유묶기([맞음("포장 전용 닭강정", 이용방식), 맞음("포장 전용 닭강정", 맵기)], 주문표); + const 글 = 이유글(합침[0]); + expect(글).not.toMatch(/[가-힣]/); + // 값 이름도 옮겨져야 한다. 틀만 영어면 "Takeout-only ... 포장하기" 가 남는다. + // 표(en.ts)가 쓰는 말 그대로다 — 포장하기는 "Take out", 매운맛은 "Hot". + expect(글).toContain("Take out"); + expect(글).toContain("Hot"); + }); + + it("text 도 같이 고친다 — 화면이 이걸 목록 열쇠로 쓴다", () => { + // 합치기 전 문장이 text 에 남으면, 열쇠가 사라진 줄을 가리킨다. + const 합침 = 이유묶기([맞음("A", 이용방식), 맞음("A", 맵기)], 주문표); + expect(합침[0].text).toBe("A — 선호하신 포장하기·매운맛과 맞는 메뉴라 우선 추천드립니다."); + }); +}); diff --git a/frontend/src/i18n/reason.ts b/frontend/src/i18n/reason.ts index b169c22..0836104 100644 --- a/frontend/src/i18n/reason.ts +++ b/frontend/src/i18n/reason.ts @@ -43,7 +43,63 @@ const 우리말알레르기제외 = /^(.+?) 알레르기와 겹쳐서 제외됐 */ const 축제외 = /^(.+?)이\(가\) 맞지 않아서 뺐어요$/; +/** + * 서버가 "이 축이 맞았다" 고 말하는 네 문장. 축 이름만 다르고 틀은 같다. + * + * 선호하신 이용 방식과 일치하는 메뉴라 우선 추천드립니다. + * 선호하신 맵기와 맞는 메뉴라 우선 추천드립니다. + * 선호하신 뼈/순살과 일치하는 메뉴라 우선 추천드립니다. + * 선호하신 컵 옵션과 일치하는 메뉴라 우선 추천드립니다. + * + * 서버는 축마다 한 줄씩 따로 보낸다. 그래서 두 축이 맞으면 같은 메뉴 이름이 + * 두 줄에 걸쳐 되풀이됐다 — 아래 이유묶기() 가 이 틀을 알아보고 한 줄로 합친다. + */ +const 맞은축 = /^선호하신 (.+?)(?:과|와) (?:일치하는|맞는) 메뉴라 우선 추천드립니다\.?$/; + +/** + * 서버 이유 문장이 부르는 축 이름 → 주문표에서 쓰는 축 이름(domain/catalog.tsx). + * + * 두 이름이 늘 같지는 않다. 서버는 "뼈/순살"·"컵 옵션" 이라 부르고 주문표는 + * "형태"·"컵" 이다. 여기가 어긋나면 값을 못 찾아 축 이름으로 물러난다 — + * 틀린 값을 보여 주지는 않는다. + */ +const 주문표축: Record = { + "이용 방식": "이용 방식", + "맵기": "맵기", + "뼈/순살": "형태", + "컵 옵션": "컵", +}; + +/** 마지막 글자에 받침이 있으면 '과', 없으면 '와'. "맵기과" 가 되면 안 된다. */ +const 받침있나 = (말: string): boolean => { + const 끝 = 말.charCodeAt(말.length - 1); + if (!(끝 >= 0xac00 && 끝 <= 0xd7a3)) return false; + return (끝 - 0xac00) % 28 !== 0; +}; + +/** + * 우리가 합친 문장. 이름이 가운뎃점으로 이어져 있다 — + * "선호하신 포장하기·매운맛과 맞는 메뉴라 우선 추천드립니다." + * + * 알레르기 문장과 같은 방식이다. 이름만 하나씩 옮기고 틀은 표에서 가져온다. + * 가운뎃점이 **있을 때만** 걸리게 해서, 서버가 보낸 한 축짜리 문장은 지금처럼 + * 표의 열쇠로 그대로 옮겨지게 둔다. + */ +const 합친축 = /^선호하신 (.+·.+?)(?:과|와) 맞는 메뉴라 우선 추천드립니다\.$/; + const 문장옮기기 = (문장: string): string => { + const 합침 = 합친축.exec(문장); + if (합침) { + /* + * 우리말에서도 이 길을 지난다. 옮길 것이 없으니 t() 가 이름을 그대로 + * 돌려주고, 표의 우리말 틀이 다시 맞춰져 "선호하신 포장하기, 매운맛과 + * 맞는 메뉴라 우선 추천드립니다." 가 된다. + */ + const 것들 = 합침[1].split("·").map((이름) => t(이름)).join(", "); + return 받침있나(것들) + ? tf("선호하신 {것들}과 맞는 메뉴라 우선 추천드립니다.", { 것들 }) + : tf("선호하신 {것들}와 맞는 메뉴라 우선 추천드립니다.", { 것들 }); + } const 겹침 = 알레르기제외.exec(문장); if (겹침) return tf("[{알레르기}] 알레르기와 겹쳐서 제외됐어요.", { 알레르기: 겹침[1] }); const 축겹침 = 축제외.exec(문장); @@ -82,3 +138,84 @@ export const 이유글 = (r: RecommendationReason): string => { */ return r.메뉴 ? tf("{메뉴} — {글}", { 메뉴: t(r.메뉴), 글: 값붙은 }) : 값붙은; }; + +/** + * 같은 메뉴에 대해 여러 축이 맞았다는 줄을 **한 줄로 합친다.** + * + * ── 왜 ────────────────────────────────────────────────────────────────────── + * + * 서버는 맞은 축마다 한 줄씩 따로 준다. 두 축이 맞으면 화면이 이렇게 됐다. + * + * 반영: 포장 전용 닭강정 — 선호하신 이용 방식과 일치하는 메뉴라 우선 추천드립니다. + * 반영: 포장 전용 닭강정 — 선호하신 맵기와 맞는 메뉴라 우선 추천드립니다. + * + * 메뉴 이름과 뒷말이 통째로 되풀이돼서, 두 줄을 다 읽어야 무엇이 다른지 알 수 + * 있다. 큰 글씨로 보는 분에게는 화면 하나를 이것만으로 채운다. 한 줄로 합친다. + * + * 반영: 포장 전용 닭강정 — 선호하신 포장하기, 매운맛과 맞는 메뉴라 우선 추천드립니다. + * + * ── 축 이름 대신 고르신 값을 쓴다 ─────────────────────────────────────────── + * + * "이용 방식" 보다 "포장하기" 가 사용자가 실제로 고른 말이다. 짐작이 아니다 — + * 어느 축인지는 서버 문장에 적혀 있고, 그 축에 무엇을 골랐는지는 주문표에 + * 있다. 둘 다 아는 것만 쓴다. 주문표에 값이 없거나 "상관없음" 이면 축 이름 + * 그대로 둔다. + * + * ── 안 건드리는 것 ────────────────────────────────────────────────────────── + * + * 한 축만 맞은 줄은 그대로 둔다. 되풀이가 없어서 합칠 것이 없고, 그 문장들은 + * 표에 열쇠가 그대로 있어 지금처럼 옮겨진다. 못 맞춘 조건·제외 이유도 손대지 + * 않는다 — 저마다 다른 말이라 합치면 뜻이 뭉개진다. + */ +export const 이유묶기 = ( + reasons: RecommendationReason[], + 고른것: Record = {}, +): RecommendationReason[] => { + const 축찾기 = (r: RecommendationReason): string | undefined => + r.kind === "used" ? 맞은축.exec(r.문장 ?? r.text)?.[1] : undefined; + + const 부를이름 = (축: string): string => { + const 값 = (고른것[주문표축[축] ?? 축] ?? []).filter((v) => v !== "상관없음"); + return 값.length > 0 ? 값.join("·") : 축; + }; + + /* + * 메뉴별로 **서로 다른 축이 몇 개** 맞았는지 센다. 둘 이상일 때만 합친다. + * + * 같은 축이 두 번 오면 세지 않는다. 서버는 축마다 한 줄씩 주지만, 겹쳐 온 + * 적이 있는 자리다(error.details 도 그랬다). 그대로 세면 "매운맛·매운맛과 + * 맞는 메뉴라" 가 되어, 합치지 않느니만 못한 줄이 된다. + * + * 값이 아니라 **축**으로 가린다. 두 축이 같은 값 이름을 가질 수도 있고 + * (주문표에 없으면 축 이름으로 물러난다), 그때는 서로 다른 조건이 맞은 + * 것이므로 둘 다 세는 것이 맞다. + */ + const 메뉴별 = new Map(); + for (const r of reasons) { + const 축 = 축찾기(r); + if (!축) continue; + const 열쇠 = r.메뉴 ?? ""; + const 있던것 = 메뉴별.get(열쇠) ?? []; + if (있던것.some((it) => it.축 === 축)) continue; + 메뉴별.set(열쇠, [...있던것, { 축, 이름: 부를이름(축) }]); + } + + const 합친것 = new Set(); + const 나온것: RecommendationReason[] = []; + for (const r of reasons) { + const 축 = 축찾기(r); + if (!축) { 나온것.push(r); continue; } + const 열쇠 = r.메뉴 ?? ""; + const 것들 = (메뉴별.get(열쇠) ?? []).map((it) => it.이름); + if (것들.length < 2) { 나온것.push(r); continue; } + // 두 번째부터는 첫 줄에 합쳐졌다. 자리는 첫 줄이 지킨다. + if (합친것.has(열쇠)) continue; + 합친것.add(열쇠); + const 목록 = 것들.join("·"); + const 문장 = `선호하신 ${목록}${받침있나(목록) ? "과" : "와"} 맞는 메뉴라 우선 추천드립니다.`; + // text 도 같이 고친다. 화면이 이걸 목록 열쇠로 쓰고, 합치기 전 문장이 + // 남아 있으면 열쇠가 사라진 줄을 가리킨다. + 나온것.push({ ...r, 문장, text: r.메뉴 ? `${r.메뉴} — ${문장}` : 문장 }); + } + return 나온것; +};