주기 실회기 검증과 G7 종료계약 보강
This commit is contained in:
parent
83590e9ef7
commit
7b4955c3fc
23 changed files with 2916 additions and 117 deletions
|
|
@ -32,7 +32,7 @@ class G7EvidenceProvenance(BaseModel):
|
|||
|
||||
@model_validator(mode="after")
|
||||
def require_preregistered_analysis(self) -> "G7EvidenceProvenance":
|
||||
if self.registered_at > self.held_out_labels_opened_at:
|
||||
if self.registered_at >= self.held_out_labels_opened_at:
|
||||
raise ValueError("analysis protocol must precede held-out label access")
|
||||
return self
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ class G7PowerPlan(BaseModel):
|
|||
required_held_out_participants: int = Field(ge=1)
|
||||
required_held_out_sessions: int = Field(ge=1)
|
||||
required_paired_axis_observations: int = Field(ge=3)
|
||||
alpha: float = Field(gt=0.0, le=0.05)
|
||||
alpha: Literal[0.05] = 0.05
|
||||
target_power: float = Field(ge=0.8, lt=1.0)
|
||||
minimally_detectable_gain: float = Field(gt=0.0, le=1.0)
|
||||
planned_bootstrap_samples: Literal[10000] = 10000
|
||||
|
|
@ -91,10 +91,7 @@ class G7HumanAxisLabel(BaseModel):
|
|||
|
||||
labeler_key: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$")
|
||||
score: float = Field(ge=0.0, le=1.0)
|
||||
category: str | None = Field(
|
||||
default=None,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$",
|
||||
)
|
||||
category: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$")
|
||||
|
||||
|
||||
class G7ReliabilityClaim(BaseModel):
|
||||
|
|
@ -103,11 +100,7 @@ class G7ReliabilityClaim(BaseModel):
|
|||
method: Literal["ICC(A,1)"] = "ICC(A,1)"
|
||||
labeler_keys: tuple[str, ...] = Field(min_length=2)
|
||||
reported_icc: float = Field(ge=-1.0, le=1.0)
|
||||
reported_categorical_kappa: float | None = Field(
|
||||
default=None,
|
||||
ge=-1.0,
|
||||
le=1.0,
|
||||
)
|
||||
reported_categorical_kappa: float = Field(ge=-1.0, le=1.0)
|
||||
report_sha256: Sha256 = Field(pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -146,9 +139,6 @@ class G7PairedAxisObservation(BaseModel):
|
|||
labeler_keys = [item.labeler_key for item in self.labels]
|
||||
if len(set(labeler_keys)) != len(labeler_keys):
|
||||
raise ValueError("observation labeler keys must be unique")
|
||||
has_category = [item.category is not None for item in self.labels]
|
||||
if any(has_category) and not all(has_category):
|
||||
raise ValueError("categorical labels must be complete within an observation")
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -227,7 +217,6 @@ class G7HumanVoiceGainEvidencePack(BaseModel):
|
|||
|
||||
participant_by_session: dict[str, str] = {}
|
||||
axes_by_session: dict[str, set[AllianceAxis]] = {}
|
||||
categorical_modes: set[bool] = set()
|
||||
for observation in self.observations:
|
||||
if split_by_participant.get(observation.participant_key) != "held_out":
|
||||
raise ValueError("evaluation observations must use held-out participants")
|
||||
|
|
@ -243,16 +232,8 @@ class G7HumanVoiceGainEvidencePack(BaseModel):
|
|||
row_labelers = {item.labeler_key for item in observation.labels}
|
||||
if row_labelers != reliability_panel:
|
||||
raise ValueError("every row must use the declared reliability panel")
|
||||
categorical_modes.add(observation.labels[0].category is not None)
|
||||
|
||||
required_axes: set[AllianceAxis] = {"goal", "task", "bond"}
|
||||
if any(axes != required_axes for axes in axes_by_session.values()):
|
||||
raise ValueError("every held-out session must cover goal, task, and bond")
|
||||
if len(categorical_modes) != 1:
|
||||
raise ValueError("categorical labels must be all-present or all-absent")
|
||||
has_categories = True in categorical_modes
|
||||
if has_categories != (
|
||||
self.reliability.reported_categorical_kappa is not None
|
||||
):
|
||||
raise ValueError("categorical labels and reported kappa must appear together")
|
||||
return self
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ class VoiceGainEvidenceResult(BaseModel):
|
|||
passed: bool
|
||||
clinical_claim_allowed: Literal[False] = False
|
||||
held_out_participants: int
|
||||
total_held_out_sessions: int
|
||||
held_out_sessions: int
|
||||
total_axis_observations: int
|
||||
paired_axis_observations: int
|
||||
intention_to_evaluate_imputations: int
|
||||
text_only_one_minus_mae: float
|
||||
|
|
@ -62,7 +64,7 @@ class VoiceGainEvidenceResult(BaseModel):
|
|||
confidence_level: float
|
||||
bootstrap_samples: int
|
||||
recomputed_icc: float
|
||||
recomputed_categorical_kappa: float | None
|
||||
recomputed_categorical_kappa: float
|
||||
checks: tuple[VoiceGainEvidenceCheck, ...]
|
||||
failure_reasons: tuple[str, ...]
|
||||
|
||||
|
|
@ -221,22 +223,23 @@ def evaluate_human_voice_gain(
|
|||
labels_by_key = {item.labeler_key: item for item in observation.labels}
|
||||
ordered_labels = [labels_by_key[labeler_key] for labeler_key in panel]
|
||||
ratings.append([item.score for item in ordered_labels])
|
||||
if ordered_labels[0].category is not None:
|
||||
categories.append([str(item.category) for item in ordered_labels])
|
||||
categories.append([item.category for item in ordered_labels])
|
||||
reference = sum(item.score for item in ordered_labels) / len(ordered_labels)
|
||||
|
||||
if observation.text_only_status == "observed":
|
||||
text_observed = observation.text_only_status == "observed"
|
||||
voice_observed = observation.voice_enabled_status == "observed"
|
||||
if text_observed and voice_observed:
|
||||
assert observation.text_only_score is not None
|
||||
text_error = abs(observation.text_only_score - reference)
|
||||
else:
|
||||
text_error = 1.0
|
||||
imputation_count += 1
|
||||
if observation.voice_enabled_status == "observed":
|
||||
assert observation.voice_enabled_score is not None
|
||||
voice_error = abs(observation.voice_enabled_score - reference)
|
||||
else:
|
||||
# 한 조건만 결측이어도 두 조건을 모두 최대 오류로 대치한다. baseline-only
|
||||
# 결측이 candidate gain을 인위적으로 키우는 비대칭을 차단하면서도
|
||||
# intention-to-evaluate 행은 분석에서 유지한다.
|
||||
text_error = 1.0
|
||||
voice_error = 1.0
|
||||
imputation_count += 1
|
||||
imputation_count += int(not text_observed) + int(not voice_observed)
|
||||
errors_by_participant[observation.participant_key].append(
|
||||
(text_error, voice_error)
|
||||
)
|
||||
|
|
@ -258,11 +261,28 @@ def evaluate_human_voice_gain(
|
|||
seed=thresholds.seed,
|
||||
)
|
||||
recomputed_icc = _icc_absolute_agreement_single(ratings)
|
||||
recomputed_kappa = _fleiss_kappa(categories) if categories else None
|
||||
recomputed_kappa = _fleiss_kappa(categories)
|
||||
|
||||
held_out_participants = len(errors_by_participant)
|
||||
held_out_sessions = len({item.session_key for item in pack.observations})
|
||||
observation_count = len(pack.observations)
|
||||
all_session_keys = {item.session_key for item in pack.observations}
|
||||
complete_axes_by_session: dict[str, set[str]] = defaultdict(set)
|
||||
for observation in pack.observations:
|
||||
if (
|
||||
observation.text_only_status == "observed"
|
||||
and observation.voice_enabled_status == "observed"
|
||||
):
|
||||
complete_axes_by_session[observation.session_key].add(observation.axis)
|
||||
required_axes = {"goal", "task", "bond"}
|
||||
held_out_sessions = sum(
|
||||
axes == required_axes for axes in complete_axes_by_session.values()
|
||||
)
|
||||
total_observation_count = len(pack.observations)
|
||||
paired_observation_count = sum(
|
||||
1
|
||||
for observation in pack.observations
|
||||
if observation.text_only_status == "observed"
|
||||
and observation.voice_enabled_status == "observed"
|
||||
)
|
||||
checks: list[VoiceGainEvidenceCheck] = []
|
||||
_check(
|
||||
checks,
|
||||
|
|
@ -281,8 +301,8 @@ def evaluate_human_voice_gain(
|
|||
_check(
|
||||
checks,
|
||||
"production_observation_floor",
|
||||
observation_count >= thresholds.min_paired_axis_observations,
|
||||
observation_count,
|
||||
paired_observation_count >= thresholds.min_paired_axis_observations,
|
||||
paired_observation_count,
|
||||
thresholds.min_paired_axis_observations,
|
||||
)
|
||||
_check(
|
||||
|
|
@ -318,8 +338,8 @@ def evaluate_human_voice_gain(
|
|||
_check(
|
||||
checks,
|
||||
"power_plan_observations_achieved",
|
||||
observation_count >= pack.power_plan.required_paired_axis_observations,
|
||||
observation_count,
|
||||
paired_observation_count >= pack.power_plan.required_paired_axis_observations,
|
||||
paired_observation_count,
|
||||
pack.power_plan.required_paired_axis_observations,
|
||||
)
|
||||
_check(
|
||||
|
|
@ -355,26 +375,24 @@ def evaluate_human_voice_gain(
|
|||
recomputed_icc,
|
||||
pack.reliability.reported_icc,
|
||||
)
|
||||
if recomputed_kappa is not None:
|
||||
assert pack.reliability.reported_categorical_kappa is not None
|
||||
_check(
|
||||
checks,
|
||||
"recomputed_categorical_kappa",
|
||||
recomputed_kappa >= thresholds.min_categorical_kappa,
|
||||
recomputed_kappa,
|
||||
thresholds.min_categorical_kappa,
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"reported_kappa_matches_rows",
|
||||
math.isclose(
|
||||
recomputed_kappa,
|
||||
pack.reliability.reported_categorical_kappa,
|
||||
abs_tol=0.0005,
|
||||
),
|
||||
_check(
|
||||
checks,
|
||||
"recomputed_categorical_kappa",
|
||||
recomputed_kappa >= thresholds.min_categorical_kappa,
|
||||
recomputed_kappa,
|
||||
thresholds.min_categorical_kappa,
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"reported_kappa_matches_rows",
|
||||
math.isclose(
|
||||
recomputed_kappa,
|
||||
pack.reliability.reported_categorical_kappa,
|
||||
)
|
||||
abs_tol=0.0005,
|
||||
),
|
||||
recomputed_kappa,
|
||||
pack.reliability.reported_categorical_kappa,
|
||||
)
|
||||
_check(checks, "minimum_paired_gain", gain >= thresholds.min_gain, gain, thresholds.min_gain)
|
||||
_check(checks, "bootstrap_ci_excludes_zero", ci_lower > 0.0, ci_lower, "> 0")
|
||||
_check(
|
||||
|
|
@ -390,8 +408,10 @@ def evaluate_human_voice_gain(
|
|||
passed=not failures,
|
||||
clinical_claim_allowed=False,
|
||||
held_out_participants=held_out_participants,
|
||||
total_held_out_sessions=len(all_session_keys),
|
||||
held_out_sessions=held_out_sessions,
|
||||
paired_axis_observations=observation_count,
|
||||
total_axis_observations=total_observation_count,
|
||||
paired_axis_observations=paired_observation_count,
|
||||
intention_to_evaluate_imputations=imputation_count,
|
||||
text_only_one_minus_mae=text_accuracy,
|
||||
voice_enabled_one_minus_mae=voice_accuracy,
|
||||
|
|
|
|||
|
|
@ -179,8 +179,34 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase):
|
|||
self.assertEqual(result.intention_to_evaluate_imputations, 1)
|
||||
expected_voice_accuracy = 1.0 - ((1.0 + (0.05 * 5)) / 6)
|
||||
self.assertAlmostEqual(result.voice_enabled_one_minus_mae, expected_voice_accuracy)
|
||||
self.assertEqual(result.paired_axis_observations, 5)
|
||||
self.assertEqual(result.held_out_sessions, 1)
|
||||
self.assertIn("production_observation_floor", result.failure_reasons)
|
||||
self.assertIn("production_session_floor", result.failure_reasons)
|
||||
self.assertFalse(result.passed)
|
||||
|
||||
def test_baseline_only_missing_cannot_inflate_candidate_gain(self) -> None:
|
||||
complete = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload())
|
||||
complete_result = evaluate_human_voice_gain(
|
||||
complete,
|
||||
thresholds=_test_thresholds(),
|
||||
)
|
||||
payload = _valid_payload()
|
||||
observations = payload["observations"]
|
||||
assert isinstance(observations, list)
|
||||
observations[0]["text_only_status"] = "missing"
|
||||
observations[0]["text_only_score"] = None
|
||||
missing = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
|
||||
missing_result = evaluate_human_voice_gain(
|
||||
missing,
|
||||
thresholds=_test_thresholds(),
|
||||
)
|
||||
|
||||
self.assertLess(missing_result.paired_gain, complete_result.paired_gain)
|
||||
self.assertEqual(missing_result.intention_to_evaluate_imputations, 1)
|
||||
self.assertEqual(missing_result.paired_axis_observations, 5)
|
||||
|
||||
def test_duplicate_or_calibration_observation_is_rejected(self) -> None:
|
||||
duplicate = _valid_payload()
|
||||
duplicate_rows = duplicate["observations"]
|
||||
|
|
@ -236,6 +262,64 @@ class G7HumanVoiceGainEvidenceTests(unittest.TestCase):
|
|||
self.assertFalse(result.passed)
|
||||
self.assertIn("reported_icc_matches_rows", result.failure_reasons)
|
||||
|
||||
def test_categorical_kappa_is_mandatory_and_recomputed(self) -> None:
|
||||
missing_report = _valid_payload()
|
||||
reliability = missing_report["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability.pop("reported_categorical_kappa")
|
||||
with self.assertRaises(ValidationError):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(missing_report)
|
||||
|
||||
missing_category = _valid_payload()
|
||||
observations = missing_category["observations"]
|
||||
assert isinstance(observations, list)
|
||||
labels = observations[0]["labels"]
|
||||
assert isinstance(labels, list)
|
||||
labels[0].pop("category")
|
||||
with self.assertRaises(ValidationError):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(missing_category)
|
||||
|
||||
mismatched = _valid_payload()
|
||||
reliability = mismatched["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability["reported_categorical_kappa"] = 0.8
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(mismatched)
|
||||
result = evaluate_human_voice_gain(pack, thresholds=_test_thresholds())
|
||||
self.assertIn("reported_kappa_matches_rows", result.failure_reasons)
|
||||
|
||||
weak = _valid_payload()
|
||||
observations = weak["observations"]
|
||||
assert isinstance(observations, list)
|
||||
for observation in observations:
|
||||
labels = observation["labels"]
|
||||
assert isinstance(labels, list)
|
||||
labels[0]["category"] = "low"
|
||||
labels[1]["category"] = "high"
|
||||
reliability = weak["reliability"]
|
||||
assert isinstance(reliability, dict)
|
||||
reliability["reported_categorical_kappa"] = -1.0
|
||||
weak_pack = G7HumanVoiceGainEvidencePack.model_validate(weak)
|
||||
weak_result = evaluate_human_voice_gain(
|
||||
weak_pack,
|
||||
thresholds=_test_thresholds(),
|
||||
)
|
||||
self.assertIn("recomputed_categorical_kappa", weak_result.failure_reasons)
|
||||
|
||||
def test_analysis_registration_must_strictly_precede_held_out_access(self) -> None:
|
||||
payload = _valid_payload()
|
||||
provenance = payload["provenance"]
|
||||
assert isinstance(provenance, dict)
|
||||
provenance["registered_at"] = provenance["held_out_labels_opened_at"]
|
||||
with self.assertRaisesRegex(ValidationError, "must precede"):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(payload)
|
||||
|
||||
wrong_alpha = _valid_payload()
|
||||
power_plan = wrong_alpha["power_plan"]
|
||||
assert isinstance(power_plan, dict)
|
||||
power_plan["alpha"] = 0.01
|
||||
with self.assertRaises(ValidationError):
|
||||
G7HumanVoiceGainEvidencePack.model_validate(wrong_alpha)
|
||||
|
||||
def test_custom_thresholds_require_explicit_test_factory(self) -> None:
|
||||
pack = G7HumanVoiceGainEvidencePack.model_validate(_valid_payload())
|
||||
with self.assertRaisesRegex(ValueError, "test-only"):
|
||||
|
|
|
|||
|
|
@ -333,6 +333,7 @@ test.describe("dev dashboard static command center", () => {
|
|||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 320, height: 568 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
|
||||
|
|
|
|||
426
apps/web/e2e/periodic-learner-real-closed-loop.spec.ts
Normal file
426
apps/web/e2e/periodic-learner-real-closed-loop.spec.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIResponse,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import {
|
||||
completeAlliancePreCheckpoint,
|
||||
useRealApi,
|
||||
} from "./support";
|
||||
|
||||
interface ReturnedPracticeFixture {
|
||||
schema_version: "vignette.returned-practice-browser-fixture.v1";
|
||||
login: {
|
||||
email: string;
|
||||
role: "learner";
|
||||
display_name: string;
|
||||
cohort_ids: string[];
|
||||
};
|
||||
source_session_id: string;
|
||||
practice_session_id: string;
|
||||
deliberate: {
|
||||
prescription_id: string;
|
||||
criterion_id: string;
|
||||
novelty: "familiar" | "unseen_transfer";
|
||||
mode:
|
||||
| "replay"
|
||||
| "branch"
|
||||
| "constrained_response"
|
||||
| "voice_retry"
|
||||
| "difficulty_ladder";
|
||||
};
|
||||
transfer: {
|
||||
prescription_id: string;
|
||||
suite_id: string;
|
||||
trial_id: string;
|
||||
criterion_id: string;
|
||||
novelty: "unseen_transfer";
|
||||
mode:
|
||||
| "counterevidence_forecast"
|
||||
| "evidence_recall"
|
||||
| "uncertainty_range"
|
||||
| "collect_more_evidence";
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session_id: string;
|
||||
persona_code: string;
|
||||
}
|
||||
|
||||
interface AuthMe {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface DeliberateSubmission {
|
||||
idempotent_replay: boolean;
|
||||
}
|
||||
|
||||
interface DeliberateReadModel {
|
||||
episodes: Array<{
|
||||
session_id?: string | null;
|
||||
attempts?: unknown[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TransferSubmission {
|
||||
idempotent_replay: boolean;
|
||||
assessment: {
|
||||
execution_count: number;
|
||||
independent_execution_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TransferReadModel {
|
||||
actual_executions: Array<{
|
||||
original_transfer_trial_record_id: string;
|
||||
practice_session_id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const LIVE_GATE = process.env.E2E_PERIODIC_LEARNER_REAL_CLOSED_LOOP === "1";
|
||||
const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? "";
|
||||
const RESULT_PATH = process.env.E2E_PERIODIC_LEARNER_RESULT ?? "";
|
||||
|
||||
function loadFixture(): ReturnedPracticeFixture {
|
||||
if (!FIXTURE_PATH) {
|
||||
throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required");
|
||||
}
|
||||
return JSON.parse(
|
||||
readFileSync(path.resolve(FIXTURE_PATH), "utf8"),
|
||||
) as ReturnedPracticeFixture;
|
||||
}
|
||||
|
||||
async function expectOk(response: APIResponse) {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function signInExistingFixture(
|
||||
page: Page,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
): Promise<string> {
|
||||
const login = await page.request.post("/api/auth/dev-login", {
|
||||
data: fixture.login,
|
||||
});
|
||||
await expectOk(login);
|
||||
const me = await page.request.get("/api/auth/me");
|
||||
await expectOk(me);
|
||||
return ((await me.json()) as AuthMe).user_id;
|
||||
}
|
||||
|
||||
function launchSearch(
|
||||
fixture: ReturnedPracticeFixture,
|
||||
kind: "deliberate" | "transfer",
|
||||
): string {
|
||||
const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer;
|
||||
const search = new URLSearchParams({
|
||||
launch: kind,
|
||||
prescription: source.prescription_id,
|
||||
source_session: fixture.source_session_id,
|
||||
criterion: source.criterion_id,
|
||||
novelty: source.novelty,
|
||||
mode: source.mode,
|
||||
});
|
||||
if (kind === "transfer") {
|
||||
search.set("suite", fixture.transfer.suite_id);
|
||||
search.set("trial", fixture.transfer.trial_id);
|
||||
}
|
||||
return search.toString();
|
||||
}
|
||||
|
||||
function runtimeAttemptCount(
|
||||
readModel: DeliberateReadModel,
|
||||
practiceSessionId: string,
|
||||
): number {
|
||||
return readModel.episodes
|
||||
.filter((episode) => episode.session_id === practiceSessionId)
|
||||
.reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0);
|
||||
}
|
||||
|
||||
function actualExecutionCount(
|
||||
readModel: TransferReadModel,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
practiceSessionId: string,
|
||||
): number {
|
||||
return readModel.actual_executions.filter(
|
||||
(execution) =>
|
||||
execution.original_transfer_trial_record_id === fixture.transfer.trial_id &&
|
||||
execution.practice_session_id === practiceSessionId,
|
||||
).length;
|
||||
}
|
||||
|
||||
async function readPracticeCount(page: Page, practiceSessionId: string) {
|
||||
const response = await page.request.get("/api/practice/learners/me");
|
||||
await expectOk(response);
|
||||
return runtimeAttemptCount(
|
||||
(await response.json()) as DeliberateReadModel,
|
||||
practiceSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function readTransferCount(
|
||||
page: Page,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
practiceSessionId: string,
|
||||
) {
|
||||
const response = await page.request.get("/api/calibration/learners/me");
|
||||
await expectOk(response);
|
||||
return actualExecutionCount(
|
||||
(await response.json()) as TransferReadModel,
|
||||
fixture,
|
||||
practiceSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function openEvidenceCard(
|
||||
page: Page,
|
||||
fixture: ReturnedPracticeFixture,
|
||||
practiceSessionId: string,
|
||||
kind: "deliberate" | "transfer",
|
||||
) {
|
||||
await page.goto(
|
||||
`/learn/session/${practiceSessionId}/review?${launchSearch(fixture, kind)}`,
|
||||
);
|
||||
const insightsTab = page.locator("#sr-tab-insights");
|
||||
await expect(insightsTab).toBeVisible({ timeout: 30_000 });
|
||||
if ((await insightsTab.getAttribute("aria-selected")) !== "true") {
|
||||
await insightsTab.click();
|
||||
}
|
||||
const selector =
|
||||
kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer";
|
||||
const card = page.locator("#sr-panel-insights").locator(selector);
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
return card;
|
||||
}
|
||||
|
||||
function writeResult(result: Record<string, unknown>) {
|
||||
if (!RESULT_PATH) {
|
||||
throw new Error("E2E_PERIODIC_LEARNER_RESULT is required");
|
||||
}
|
||||
const resolved = path.resolve(RESULT_PATH);
|
||||
mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
writeFileSync(resolved, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
test.describe("periodic same-learner real closed loop", () => {
|
||||
test.skip(!LIVE_GATE, "Explicit disposable periodic gate only");
|
||||
|
||||
test("@single-run home recommendation, SSE session, review, G4/G5 POST and reload stay one learner", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(20 * 60_000);
|
||||
const fixture = loadFixture();
|
||||
expect(fixture.schema_version).toBe(
|
||||
"vignette.returned-practice-browser-fixture.v1",
|
||||
);
|
||||
await useRealApi(page);
|
||||
const learnerId = await signInExistingFixture(page, fixture);
|
||||
|
||||
const preparedSession = await page.request.get(
|
||||
`/api/sessions/${fixture.practice_session_id}`,
|
||||
);
|
||||
await expectOk(preparedSession);
|
||||
const practicePersona = ((await preparedSession.json()) as SessionDetail)
|
||||
.persona_code;
|
||||
expect(practicePersona).toBeTruthy();
|
||||
|
||||
await page.goto("/learn");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("다음 연습 추천", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("리뷰 확인을 우선합니다.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "리뷰 확인하기" }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||
|
||||
await page.goto(`/learn/practice?${launchSearch(fixture, "deliberate")}`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /처방을 이어받았습니다/ }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const escapedPersona = practicePersona.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const persona = page.getByRole("option", {
|
||||
name: new RegExp(escapedPersona),
|
||||
});
|
||||
await persona.click();
|
||||
await expect(persona).toHaveAttribute("aria-selected", "true");
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${escapedPersona}\\?`));
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await completeAlliancePreCheckpoint(page);
|
||||
|
||||
const activeUrl = new URL(page.url());
|
||||
const practiceSessionId = activeUrl.pathname.split("/").filter(Boolean).at(-1);
|
||||
expect(practiceSessionId).toBeTruthy();
|
||||
expect(practiceSessionId).not.toBe(practicePersona);
|
||||
|
||||
const learnerText =
|
||||
"그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. 제가 이해한 마음이 맞을까요?";
|
||||
const streamResponse = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
response.url().endsWith(`/api/sessions/${practiceSessionId}/stream`),
|
||||
{ timeout: 6 * 60_000 },
|
||||
);
|
||||
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
const stream = await streamResponse;
|
||||
await expectOk(stream);
|
||||
expect(stream.headers()["content-type"] ?? "").toContain("text/event-stream");
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt.is-client").last()).toBeVisible({
|
||||
timeout: 6 * 60_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "회기 종료" }).click();
|
||||
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/learn/session/${practiceSessionId}/review\\?`),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const review = await page.request.get(
|
||||
`/api/sessions/${practiceSessionId}/review`,
|
||||
);
|
||||
if (!review.ok()) return false;
|
||||
return Boolean(((await review.json()) as { reviewReady?: boolean }).reviewReady);
|
||||
},
|
||||
{ timeout: 6 * 60_000, intervals: [1_000, 2_000, 5_000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(await readPracticeCount(page, practiceSessionId!)).toBe(0);
|
||||
let deliberateCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"deliberate",
|
||||
);
|
||||
const g4PostUrl = `/api/practice/${encodeURIComponent(
|
||||
fixture.deliberate.prescription_id,
|
||||
)}/attempts/from-session/${practiceSessionId}`;
|
||||
const g4FirstPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await deliberateCard
|
||||
.getByRole("button", { name: "이번 회기를 독립 관찰로 반영" })
|
||||
.click();
|
||||
const g4First = await g4FirstPost;
|
||||
await expectOk(g4First);
|
||||
expect(((await g4First.json()) as DeliberateSubmission).idempotent_replay).toBe(
|
||||
false,
|
||||
);
|
||||
const g4FirstCount = await readPracticeCount(page, practiceSessionId!);
|
||||
expect(g4FirstCount).toBe(1);
|
||||
await page.reload();
|
||||
deliberateCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"deliberate",
|
||||
);
|
||||
const g4ReplayPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g4PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await deliberateCard
|
||||
.getByRole("button", { name: "반영 상태 다시 확인" })
|
||||
.click();
|
||||
const g4Replay = await g4ReplayPost;
|
||||
await expectOk(g4Replay);
|
||||
expect(((await g4Replay.json()) as DeliberateSubmission).idempotent_replay).toBe(
|
||||
true,
|
||||
);
|
||||
const g4ReplayCount = await readPracticeCount(page, practiceSessionId!);
|
||||
expect(g4ReplayCount).toBe(1);
|
||||
|
||||
expect(await readTransferCount(page, fixture, practiceSessionId!)).toBe(0);
|
||||
let transferCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"transfer",
|
||||
);
|
||||
const g5PostUrl = "/api/calibration/transfer-executions";
|
||||
const g5FirstPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await transferCard
|
||||
.getByRole("button", { name: "이 회기를 전이 근거로 확인" })
|
||||
.click();
|
||||
const g5First = await g5FirstPost;
|
||||
await expectOk(g5First);
|
||||
const g5FirstBody = (await g5First.json()) as TransferSubmission;
|
||||
expect(g5FirstBody.idempotent_replay).toBe(false);
|
||||
expect(g5FirstBody.assessment.execution_count).toBe(1);
|
||||
expect(g5FirstBody.assessment.independent_execution_count).toBe(1);
|
||||
const g5FirstCount = await readTransferCount(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
);
|
||||
expect(g5FirstCount).toBe(1);
|
||||
await page.reload();
|
||||
transferCard = await openEvidenceCard(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
"transfer",
|
||||
);
|
||||
const g5ReplayPost = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" && response.url().endsWith(g5PostUrl),
|
||||
{ timeout: 5 * 60_000 },
|
||||
);
|
||||
await transferCard
|
||||
.getByRole("button", { name: "같은 회기 기록 다시 확인" })
|
||||
.click();
|
||||
const g5Replay = await g5ReplayPost;
|
||||
await expectOk(g5Replay);
|
||||
expect(((await g5Replay.json()) as TransferSubmission).idempotent_replay).toBe(
|
||||
true,
|
||||
);
|
||||
const g5ReplayCount = await readTransferCount(
|
||||
page,
|
||||
fixture,
|
||||
practiceSessionId!,
|
||||
);
|
||||
expect(g5ReplayCount).toBe(1);
|
||||
|
||||
const meAfter = await page.request.get("/api/auth/me");
|
||||
await expectOk(meAfter);
|
||||
expect(((await meAfter.json()) as AuthMe).user_id).toBe(learnerId);
|
||||
|
||||
writeResult({
|
||||
schema_version: "vignette.periodic-learner-real-closed-loop.v1",
|
||||
learner_id: learnerId,
|
||||
source_session_id: fixture.source_session_id,
|
||||
practice_session_id: practiceSessionId,
|
||||
same_learner: true,
|
||||
recommendation_verified: true,
|
||||
session_created_in_browser: true,
|
||||
sse_turn_verified: true,
|
||||
review_ready: true,
|
||||
g4: {
|
||||
initial_count: 0,
|
||||
first_count: g4FirstCount,
|
||||
replay_count: g4ReplayCount,
|
||||
replay_idempotent: true,
|
||||
},
|
||||
g5: {
|
||||
initial_count: 0,
|
||||
first_count: g5FirstCount,
|
||||
replay_count: g5ReplayCount,
|
||||
replay_idempotent: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1020,6 +1020,20 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
);
|
||||
await expectReadableButtonContrast(page, "학습 대상 선택");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectInsideInitialViewport(
|
||||
page,
|
||||
"[data-learner-primary-action]",
|
||||
"320px 학습자 홈 핵심 행동",
|
||||
);
|
||||
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "home-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await capture(page, testInfo.project.name, "home");
|
||||
|
||||
await primaryAction.click();
|
||||
|
|
@ -1061,6 +1075,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: LEARNER_TEXT })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
|
||||
await expect(page.locator(".sx-page")).not.toContainText("[NAME]");
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await expect(
|
||||
page.getByRole("region", { name: "현재 회기 요약" }),
|
||||
|
|
@ -1145,7 +1160,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await expect(practiceCard).toContainText("미지 사례 전이 아직 미검증");
|
||||
await expect(practiceCard).not.toContainText("criterion.reflect-and-check");
|
||||
await expect(practiceCard).not.toContainText("unseen_transfer_not_verified");
|
||||
await expect(page.locator(".sr-root")).not.toContainText("[NAME]");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await practiceCard.scrollIntoViewIfNeeded();
|
||||
await expectMinimumHitTargets(
|
||||
page,
|
||||
".dp-launch-ticket__cta",
|
||||
"320px 처방 연습 시작 행동",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "review-prescription-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await practiceCard.scrollIntoViewIfNeeded();
|
||||
await capture(page, testInfo.project.name, "review-prescription");
|
||||
|
||||
|
|
@ -1185,6 +1215,16 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await expect(page.locator(".lh-root")).toContainText("원본 회기 참조");
|
||||
await expect(page.locator(".lh-root")).not.toContainText("criterion.reflect-and-check");
|
||||
await expect(page.locator(".lh-root")).not.toContainText(PRIMARY_SESSION_ID.slice(0, 8));
|
||||
await expect(page.locator(".lh-root")).not.toContainText("[NAME]");
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "repractice-home-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }),
|
||||
|
|
@ -1209,6 +1249,24 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
"처방 재연습 시작 행동",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await expectInsideInitialViewport(
|
||||
page,
|
||||
".sx-prestart__actions .vg-btn",
|
||||
"320px 처방 재연습 시작 행동",
|
||||
);
|
||||
await expectMinimumHitTargets(
|
||||
page,
|
||||
".sx-prestart__actions .vg-btn",
|
||||
"320px 처방 재연습 시작 행동",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "repractice-prestart-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await capture(page, testInfo.project.name, "repractice-prestart");
|
||||
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
|
|
@ -1297,6 +1355,22 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
await expect.poll(() => fixture.practiceReadCount).toBeGreaterThan(
|
||||
readsBeforeSuccess,
|
||||
);
|
||||
if (testInfo.project.name.includes("mobile")) {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await observation.scrollIntoViewIfNeeded();
|
||||
await expectMinimumHitTargets(
|
||||
page,
|
||||
".dp-runtime-observation button",
|
||||
"320px 서버 관찰 행동",
|
||||
);
|
||||
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
|
||||
await expect(observation).not.toContainText("[NAME]");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await capture(page, testInfo.project.name, "repractice-observation-320x568");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
}
|
||||
await observation.scrollIntoViewIfNeeded();
|
||||
await capture(page, testInfo.project.name, "repractice-observation");
|
||||
|
||||
|
|
|
|||
|
|
@ -206,13 +206,13 @@ async function expectMainControlsUnclipped(page: Page) {
|
|||
const controls = [
|
||||
{ selector: ".sx-compose textarea", parent: ".sx-compose" },
|
||||
{ selector: ".sx-compose .vg-btn", parent: ".sx-compose" },
|
||||
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar", minTouchSize: 44 },
|
||||
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar" },
|
||||
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar", minTouchSize: 44 },
|
||||
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar", minTouchSize: 44 },
|
||||
];
|
||||
|
||||
return controls.map(({ selector, parent }) => {
|
||||
return controls.map(({ selector, parent, minTouchSize }) => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
const parentEl = document.querySelector<HTMLElement>(parent);
|
||||
if (!el || !parentEl) return { selector, ok: false, reason: "missing" };
|
||||
|
|
@ -236,11 +236,21 @@ async function expectMainControlsUnclipped(page: Page) {
|
|||
rect.left >= parentRect.left - 1 &&
|
||||
rect.right <= parentRect.right + 1 &&
|
||||
rect.bottom <= parentRect.bottom + 1;
|
||||
const touchTargetOk =
|
||||
minTouchSize === undefined || (rect.width >= minTouchSize && rect.height >= minTouchSize);
|
||||
|
||||
return {
|
||||
selector,
|
||||
ok: visible && insideParent && !textClipped,
|
||||
reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "",
|
||||
ok: visible && insideParent && !textClipped && touchTargetOk,
|
||||
reason: !visible
|
||||
? "not-visible"
|
||||
: !insideParent
|
||||
? "outside-parent"
|
||||
: textClipped
|
||||
? "text-clipped"
|
||||
: !touchTargetOk
|
||||
? "touch-target-under-44px"
|
||||
: "",
|
||||
rect: {
|
||||
top: Math.round(rect.top),
|
||||
right: Math.round(rect.right),
|
||||
|
|
|
|||
|
|
@ -2386,7 +2386,7 @@
|
|||
flex: none;
|
||||
}
|
||||
.sx-end-button {
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
padding: 9px 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle));
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -4210,6 +4210,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 처방 재연습은 카드 안에 과제 종류·성공 기준·출처를 다시 명시한다. 320×568처럼
|
||||
세로가 아주 짧은 화면에서 상단 단계 헤더까지 반복하면 유일한 주 행동인 "회기 시작"이
|
||||
첫 화면 아래로 밀린다. 이 구간만 중복 헤더를 접고 셸 여백을 줄여 과제 맥락과 CTA를
|
||||
함께 보이게 한다. 일반 회기 준비 화면과 390×844 이상 레이아웃은 그대로 유지한다. */
|
||||
@media (max-width: 460px) and (max-height: 620px) {
|
||||
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
||||
padding-top: var(--sp-2);
|
||||
padding-bottom: var(--sp-3);
|
||||
}
|
||||
.sx-page--prestart:has(.sx-prestart--prescribed) .sx-head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) and (max-height: 820px) {
|
||||
.sx-sessionbar {
|
||||
min-height: 38px;
|
||||
|
|
@ -4355,19 +4369,18 @@
|
|||
.sx-page--active .sx-mic-block__ms {
|
||||
display: none;
|
||||
}
|
||||
/* 아주 좁거나 낮은 뷰포트: 세로 공간 확보를 위해 40px 로 낮춘다
|
||||
(390x720 등 여유 있는 폭에서는 44px 유지). 가로 터치 폭은 유지. */
|
||||
/* 아주 좁거나 낮은 뷰포트에서도 주요 회기 제어는 44px 터치 하한을 지킨다. */
|
||||
.sx-page--active .sx-mic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.sx-page--active .sx-pause {
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
}
|
||||
.sx-page--active .sx-end-button {
|
||||
min-width: 104px;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue