diff --git a/Front/client/src/components/FramePanel.tsx b/Front/client/src/components/FramePanel.tsx index 5b971c6..b773971 100644 --- a/Front/client/src/components/FramePanel.tsx +++ b/Front/client/src/components/FramePanel.tsx @@ -20,6 +20,19 @@ interface FramePanelProps { onNoDesignToggle: () => void; } +// IMP-#84 u1 — silent-automation contract: frame selection delegates directly +// to onFrameSelect for every V4 label (use_as_is / light_edit / restructure / +// reject). Prior IMP-47B u11 surfaced a window.confirm popup on reject; that +// popup is informational UI noise per `feedback_auto_pipeline_first` and is +// removed. Frame identity is preserved on reject (AI 재구성 = content-only, +// per AI 격리 contract); the popup never gated that contract. +export function applyFrameSelection( + candidate: FrameCandidate, + onFrameSelect: (frameId: string) => void, +): void { + onFrameSelect(candidate.id); +} + export default function FramePanel({ slidePlan, selectedZone, @@ -49,17 +62,9 @@ export default function FramePanel({ const handleFrameSelect = React.useCallback( (candidate: FrameCandidate) => { - const isReject = candidate.label === "reject"; - const alreadyApplied = currentFrameId === candidate.id; - if (isReject && !alreadyApplied) { - const ok = window.confirm( - `"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`, - ); - if (!ok) return; - } - onFrameSelect(candidate.id); + applyFrameSelection(candidate, onFrameSelect); }, - [currentFrameId, onFrameSelect], + [onFrameSelect], ); if (!selectedZone) { diff --git a/Front/client/tests/imp84_framepanel_reject_silent.test.ts b/Front/client/tests/imp84_framepanel_reject_silent.test.ts new file mode 100644 index 0000000..203fdd8 --- /dev/null +++ b/Front/client/tests/imp84_framepanel_reject_silent.test.ts @@ -0,0 +1,122 @@ +// IMP-#84 u1 — FramePanel reject silent-automation contract. +// +// Stage 2 unit u1 scope: +// 1) `applyFrameSelection(candidate, onFrameSelect)` invokes onFrameSelect +// with candidate.id verbatim for EVERY V4 label +// (use_as_is / light_edit / restructure / reject) — no window.confirm +// gate, no label-conditional branch, no frame swap. +// 2) Source-presence checks pin the FramePanel.tsx wiring so the runtime +// button → handler → helper chain stays intact even though we cannot +// mount React (no jsdom / RTL / happy-dom in Front devDependencies — +// verified against the IMP-56 u20 `imp90_bottom_actions.test.ts` and +// IMP-92 u5 `imp47b_human_review_toast.test.tsx` precedent that +// explicitly skip DOM mounting). +// 3) No `window.confirm` substring remains in FramePanel.tsx after u1. +// +// Out of scope (Stage 2 exit-report contract): +// - Home.tsx:523-524 `toast.error(aiReviewMsg)` (#92 operational-only). +// - FramePanel reject badge/tooltip read-only labels at L102/L147/L156 +// (no popup trigger; preserved as silent operator hint). +// - Backend `zone.provisional` emission (handled by u2 template-only). + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect, vi } from "vitest"; +import { applyFrameSelection } from "../src/components/FramePanel"; +import type { FrameCandidate } from "../src/types/designAgent"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FRAME_PANEL_SOURCE = readFileSync( + resolve(__dirname, "../src/components/FramePanel.tsx"), + "utf-8", +); + +function makeCandidate( + label: FrameCandidate["label"], + id: string, +): FrameCandidate { + return { + id, + name: `Frame ${id}`, + score: 0.5, + confidence: "medium", + label, + }; +} + +describe("applyFrameSelection (IMP-#84 u1 — silent-automation contract)", () => { + it("forwards candidate.id to onFrameSelect for use_as_is label", () => { + const onFrameSelect = vi.fn(); + applyFrameSelection(makeCandidate("use_as_is", "frame_a"), onFrameSelect); + expect(onFrameSelect).toHaveBeenCalledTimes(1); + expect(onFrameSelect).toHaveBeenCalledWith("frame_a"); + }); + + it("forwards candidate.id to onFrameSelect for light_edit label", () => { + const onFrameSelect = vi.fn(); + applyFrameSelection(makeCandidate("light_edit", "frame_b"), onFrameSelect); + expect(onFrameSelect).toHaveBeenCalledTimes(1); + expect(onFrameSelect).toHaveBeenCalledWith("frame_b"); + }); + + it("forwards candidate.id to onFrameSelect for restructure label", () => { + const onFrameSelect = vi.fn(); + applyFrameSelection(makeCandidate("restructure", "frame_c"), onFrameSelect); + expect(onFrameSelect).toHaveBeenCalledTimes(1); + expect(onFrameSelect).toHaveBeenCalledWith("frame_c"); + }); + + it("forwards candidate.id to onFrameSelect for reject label — no popup, no frame swap", () => { + // Reject is the silent-automation pivot case: prior IMP-47B u11 gated + // this path with window.confirm; post-IMP-#84 the helper invokes + // onFrameSelect with the reject frame.id directly. Backend / AI 격리 + // contract handles AI 재구성 (content-only, frame preserved). + const onFrameSelect = vi.fn(); + applyFrameSelection(makeCandidate("reject", "frame_d"), onFrameSelect); + expect(onFrameSelect).toHaveBeenCalledTimes(1); + expect(onFrameSelect).toHaveBeenCalledWith("frame_d"); + }); + + it("does not call onFrameSelect more than once per invocation", () => { + const onFrameSelect = vi.fn(); + applyFrameSelection(makeCandidate("reject", "frame_e"), onFrameSelect); + applyFrameSelection(makeCandidate("use_as_is", "frame_f"), onFrameSelect); + expect(onFrameSelect).toHaveBeenCalledTimes(2); + expect(onFrameSelect).toHaveBeenNthCalledWith(1, "frame_e"); + expect(onFrameSelect).toHaveBeenNthCalledWith(2, "frame_f"); + }); +}); + +describe("FramePanel.tsx source — silent-automation wiring pins (IMP-#84 u1)", () => { + it("has no window.confirm(...) call (popup removed; narrative mentions in comments are allowed)", () => { + // Match the call form `window.confirm(` rather than the bare substring + // so that explanatory comments documenting the removed popup are not + // flagged. A re-introduced call would carry an opening paren. + expect(FRAME_PANEL_SOURCE).not.toMatch(/\bwindow\.confirm\s*\(/); + }); + + it("does not embed the legacy reject-confirm Korean prompt body", () => { + // Prior IMP-47B u11 string fragment; absence guards against re-introduction. + expect(FRAME_PANEL_SOURCE).not.toContain("V4 reject 라벨입니다"); + expect(FRAME_PANEL_SOURCE).not.toContain("계속하시겠습니까?"); + }); + + it("wires the button onClick to handleFrameSelect(candidate)", () => { + expect(FRAME_PANEL_SOURCE).toContain( + "onClick={() => handleFrameSelect(candidate)}", + ); + }); + + it("delegates handleFrameSelect body to applyFrameSelection", () => { + expect(FRAME_PANEL_SOURCE).toContain( + "applyFrameSelection(candidate, onFrameSelect)", + ); + }); + + it("exports applyFrameSelection as a named export for caller-independent reuse", () => { + expect(FRAME_PANEL_SOURCE).toMatch( + /export function applyFrameSelection\(/, + ); + }); +}); diff --git a/templates/phase_z2/slide_base.html b/templates/phase_z2/slide_base.html index bac1e81..f987469 100644 --- a/templates/phase_z2/slide_base.html +++ b/templates/phase_z2/slide_base.html @@ -114,42 +114,10 @@ min-height: 0; } - /* ── IMP-30 u5 : provisional zone marker (first-render invariant) ── - When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted) - the pipeline still renders the rank-1 frame so the first-render invariant - holds, but the zone is tagged `provisional` so the user/AI can adapt later - (IMP-31). Visual contract: - - dashed amber border + striped wash → "needs adaptation" at a glance - - inline badge top-right → text label for non-color-perceiving readers - MDX content is preserved as-is; no shrink, no rewrite. */ - .zone--provisional { - outline: 2px dashed #b8860b; - outline-offset: -2px; - background-image: repeating-linear-gradient( - 45deg, - rgba(184, 134, 11, 0.04) 0, - rgba(184, 134, 11, 0.04) 8px, - transparent 8px, - transparent 16px - ); - } - .zone--provisional .zone__needs-adaptation-badge { - position: absolute; - top: 4px; - right: 4px; - z-index: 10; - padding: 2px 6px; - background: #b8860b; - color: #fff; - font-size: 9px; - font-weight: 700; - line-height: 1.2; - letter-spacing: 0.04em; - border-radius: 2px; - text-transform: uppercase; - pointer-events: none; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); - } + /* IMP-84: provisional zone visual treatment removed (silent-automation + policy). `data-provisional="1"` attribute is still emitted on the + zone div as silent telemetry for downstream selectors / inspection; + no user-visible outline, wash, or badge. */ /* ── Frame-family text layout contract (shared, reusable) ── feedback-1 (mvp1.5b_test7): visible improvement 강화. @@ -398,8 +366,7 @@
{% for zone in zones %} -
- {% if zone.provisional %}needs adaptation{% endif %} +
{{ zone.partial_html | safe }} {% if zone.has_popup %} {% set _popup_trigger = (zone.popup_binding.detail_trigger if zone.popup_binding else None) or {} %} diff --git a/tests/phase_z2/test_imp84_provisional_silent_render.py b/tests/phase_z2/test_imp84_provisional_silent_render.py new file mode 100644 index 0000000..bc2c1a5 --- /dev/null +++ b/tests/phase_z2/test_imp84_provisional_silent_render.py @@ -0,0 +1,249 @@ +"""IMP-84 u2 — provisional zone silent-render contract. + +Pins that `templates/phase_z2/slide_base.html` no longer surfaces the +provisional visual treatment (dashed outline, striped wash, badge span) +while keeping `data-provisional="1"` as silent telemetry on the zone div. + +Stage 2 binding contract (IMP-84): + - Remove .zone--provisional class emission on the zone div. + - Remove .zone__needs-adaptation-badge render. + - Remove the .zone--provisional CSS block and the + .zone__needs-adaptation-badge CSS block from the