회기 원장 상태 복원 보강

This commit is contained in:
Yun Chan 2026-08-10 00:52:27 +09:00
parent 13d1c39a33
commit 9a7dd98cb6
7 changed files with 101 additions and 32 deletions

View file

@ -1324,7 +1324,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await page
.getByRole("button", { name: "이번 회기 연습 근거 확인" })
.click();
const observation = page.locator(".dp-runtime-observation");
let observation = page.locator(".dp-runtime-observation");
await expect(observation).toBeVisible();
await expect(
observation.getByRole("heading", {
@ -1374,6 +1374,16 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await observation.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "repractice-observation");
await page.reload();
observation = page.locator(".dp-runtime-observation");
await expect(observation).toBeVisible();
await expect(observation).toContainText("원장 반영됨");
await expect(observation).toContainText("현재 원장 상태");
await expect(observation).toContainText(
"이미 기록된 이 회기 근거를 원장에서 다시 불러왔습니다.",
);
await expect(observation).not.toContainText("학습자만 실행");
await observation
.getByRole("button", { name: "반영 상태 다시 확인" })
.click();

View file

@ -216,18 +216,24 @@ function latestEpisodeFor(
prescription: PracticePrescriptionItem,
episodes: PracticeEpisodeItem[],
): PracticeEpisodeItem | null {
const matching = episodes.filter((episode) => {
const payload = episode.assessment_payload as Record<string, unknown>;
return (
payload.prescription_id === prescription.prescription_key ||
payload.prescription_id ===
prescription.prescription_payload.prescription_id ||
payload.competency_id === prescription.competency_id
);
});
const matching = episodes.filter((episode) =>
episodeMatchesPrescription(episode, prescription),
);
return matching.at(-1) ?? null;
}
function episodeMatchesPrescription(
episode: PracticeEpisodeItem,
prescription: PracticePrescriptionItem,
): boolean {
const payload = episode.assessment_payload as Record<string, unknown>;
return (
payload.prescription_id === prescription.prescription_key ||
payload.prescription_id === prescription.prescription_payload.prescription_id ||
payload.competency_id === prescription.competency_id
);
}
interface RuntimeObservationSnapshot {
progress: PracticeProgress;
attemptCount: number;
@ -272,6 +278,24 @@ function runtimeObservationSnapshot(
};
}
function runtimeEpisodeForSession(
data: DeliberatePracticeReadModel,
intent: PracticeLaunchIntent,
sessionId: string,
): PracticeEpisodeItem | null {
const prescription = prescriptionForLaunchIntent(data, intent);
if (!prescription) return null;
return (
data.episodes
.filter(
(episode) =>
episode.session_id === sessionId &&
episodeMatchesPrescription(episode, prescription),
)
.at(-1) ?? null
);
}
function prioritizedPrescriptions(
data: DeliberatePracticeReadModel,
): PracticePrescriptionItem[] {
@ -974,16 +998,25 @@ function RuntimePracticeObservation({
data: DeliberatePracticeReadModel;
onObserved: () => void;
}) {
const persistedRuntimeEpisode = runtimeEpisodeForSession(
data,
intent,
sessionId,
);
const [baseline, setBaseline] = useState<RuntimeObservationSnapshot>(() =>
runtimeObservationSnapshot(data, intent),
);
const [state, setState] = useState<RuntimeObservationState>("idle");
const [state, setState] = useState<RuntimeObservationState>(() =>
persistedRuntimeEpisode ? "success" : "idle",
);
const [result, setResult] =
useState<PracticeAttemptSubmissionResponse | null>(null);
useEffect(() => {
setBaseline(runtimeObservationSnapshot(data, intent));
setState("idle");
setState(
runtimeEpisodeForSession(data, intent, sessionId) ? "success" : "idle",
);
setResult(null);
// read model reload는 반영 전 기준을 바꾸지 않는다. 새 회기나 새 처방만 초기화한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -992,6 +1025,8 @@ function RuntimePracticeObservation({
const connectedPrescription = prescriptionForLaunchIntent(data, intent);
const current = runtimeObservationSnapshot(data, intent);
const currentProgress = result?.progress ?? current.progress;
const ledgerRecorded = Boolean(result || persistedRuntimeEpisode);
const hydratedFromLedger = Boolean(persistedRuntimeEpisode && !result);
if (intent.kind === "transfer") {
return (
@ -1085,30 +1120,43 @@ function RuntimePracticeObservation({
.
</p>
</div>
<Badge tone={state === "success" ? "accent" : "neutral"}>
{state === "success" ? "원장 반영됨" : "학습자만 실행"}
<Badge tone={ledgerRecorded ? "accent" : "neutral"}>
{ledgerRecorded ? "원장 반영됨" : "학습자만 실행"}
</Badge>
</div>
<div className="dp-runtime-observation__comparison">
<section>
<span> </span>
<strong>{PROGRESS_COPY[baseline.progress].title}</strong>
<small>
{baseline.familiarDemonstrations}, {" "}
{baseline.unseenTransferDemonstrations}
</small>
</section>
{result ? (
{hydratedFromLedger ? (
<section className="is-after">
<span> </span>
<span> </span>
<strong>{PROGRESS_COPY[currentProgress].label}</strong>
<small>
{current.familiarDemonstrations}, {" "}
{current.unseenTransferDemonstrations}
</small>
</section>
) : null}
) : (
<>
<section>
<span> </span>
<strong>{PROGRESS_COPY[baseline.progress].title}</strong>
<small>
{baseline.familiarDemonstrations}, {" "}
{baseline.unseenTransferDemonstrations}
</small>
</section>
{result ? (
<section className="is-after">
<span> </span>
<strong>{PROGRESS_COPY[currentProgress].label}</strong>
<small>
{current.familiarDemonstrations}, {" "}
{current.unseenTransferDemonstrations}
</small>
</section>
) : null}
</>
)}
</div>
{state === "waiting" ? (
@ -1126,6 +1174,8 @@ function RuntimePracticeObservation({
<p>
{result?.idempotent_replay
? "같은 회기 근거를 중복 없이 확인했습니다."
: hydratedFromLedger
? "이미 기록된 이 회기 근거를 원장에서 다시 불러왔습니다."
: "새 관찰 근거를 원장에 반영하고 최신 진행 상태를 다시 불러왔습니다."}
</p>
</div>