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
26 changes: 19 additions & 7 deletions frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 을 그리고 셋 다 담긴다. 한 곳에만 안내를 달면 나머지 둘에서는
Expand Down Expand Up @@ -4167,7 +4179,7 @@ function OrderConfirmScreen({
*/}
{mapping && 이유단계 && (
<ReasonStep
reasons={mapping.reasons ?? []}
reasons={이유들}
scoredAxes={mapping.scoredAxes}
확인중={mapping.result === "clarification" || mapping.result === "low_confidence"}
onNext={() => set이유먼저(false)}
Expand All @@ -4193,13 +4205,13 @@ function OrderConfirmScreen({
* 목은 이제 그 경우를 not_found 로 답하지만, 화면이 서버를 믿고 단정할 이유는 없다.
*/}
{mapping?.result === "exact" && mapping.item && (
<OrderExact item={mapping.item} reasons={mapping.reasons} 합계알림={합계알림} onReasons={() => set이유먼저(true)} onApprove={() => approve()} onCancel={거절하기} />
<OrderExact item={mapping.item} reasons={이유들} 합계알림={합계알림} onReasons={() => set이유먼저(true)} onApprove={() => approve()} onCancel={거절하기} />
)}
{mapping?.result === "clarification" && (
<OrderClarification
candidates={mapping.candidates ?? []}
reason={mapping.reason}
reasons={mapping.reasons}
reasons={이유들}
onReasons={() => set이유먼저(true)}
options={mapping.sheetOptions}
onApprove={(candidateId) => approve({ candidateId })}
Expand All @@ -4211,7 +4223,7 @@ function OrderConfirmScreen({
<OrderChanged
item={mapping.item}
diffNote={mapping.diffNote}
reasons={mapping.reasons}
reasons={이유들}
합계알림={합계알림}
onReasons={() => set이유먼저(true)}
onApprove={() => approve({ acknowledgedDiff: true })}
Expand All @@ -4232,7 +4244,7 @@ function OrderConfirmScreen({
{mapping?.result === "low_confidence" && mapping.item && (
<OrderLowConfidence
item={mapping.item}
reasons={mapping.reasons}
reasons={이유들}
합계알림={합계알림}
onReasons={() => set이유먼저(true)}
/* 사용자가 카드를 눌러 "이 메뉴가 맞다"고 짚어야만 여기까지 온다. 그 사실을 서버에도 알린다. */
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ export const EN: Record<string, string> = {
"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.",
Expand Down
117 changes: 116 additions & 1 deletion frontend/src/i18n/reason.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 — 선호하신 매운맛, 포장하기와 맞는 메뉴라 우선 추천드립니다.");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 — 선호하신 포장하기·매운맛과 맞는 메뉴라 우선 추천드립니다.");
});
});
Loading