-
${totalChecked("chinese")}
语文累计(天)
-
${totalChecked("math")}
数学累计(天)
-
${totalChecked("english")}
英语累计(天)
-
${totalChecked("book")}
绘本累计(天)
+ const enabled = enabledModuleIds();
+ const learningOn = enabled.length > 0;
+
+ if (learningOn) {
+ const total = enabled.reduce((sum, module) => sum + totalChecked(module), 0);
+ const overview = $(`
+
+
${icon("trophy")} 打卡总览
+
+ ${enabled.map((module) => `
${totalChecked(module)}
${contentModuleLabel(module)}累计(天)
`).join("")}
+
+
${icon("chart")} 累计模块打卡:${total} 次
+
${icon("flame")} 连续打卡:${enabled.map((module) => `${contentModuleLabel(module)} ${streak(module)} 天`).join(" · ")}
+
+
目标:累计 30 次打卡解锁「挖掘机小队长」徽章
-
${icon("chart")} 累计模块打卡:${total} 次
-
${icon("flame")} 连续打卡:语文 ${streak("chinese")} 天 · 数学 ${streak("math")} 天 · 英语 ${streak("english")} 天 · 绘本 ${streak("book")} 天
-
-
目标:累计 30 次打卡解锁「挖掘机小队长」徽章
+ `);
+ main.appendChild(overview);
+ overview.querySelector(".progressbar i").style.width = Math.min(100,total/30*100)+"%";
+
+ // 日历式最近记录(只统计已启用模块)
+ let cells="";
+ for(let i=29;i>=0;i--){
+ const k=dateKeyOffset(i);
+ const c=store.checkins[k];
+ const n = enabled.filter((module) => hasCheckin(c, module)).length;
+ const day = Number(k.slice(-2));
+ const label = `${k},${n ? `已完成 ${n}/${enabled.length} 个学习模块` : "未打卡"}`;
+ cells += `
${day}${n}/${enabled.length}
`;
+ }
+ const legendLevels = Array.from({ length: enabled.length + 1 }, (_, level) =>
+ `
${level}/${enabled.length} ${level === 0 ? "未打卡" : level === enabled.length ? "全部完成" : "模块"}`
+ ).join("");
+ const cal = $(`
+
+
${icon("calendar")} 近 30 天打卡日历
+
${cells}
+
颜色表示当天完成的学习模块数,格内比例是已完成/共 ${enabled.length} 个模块,边框表示今天。
+
+ ${legendLevels}
+ 今天
+
+
+ `);
+ main.appendChild(cal);
+ } else {
+ const hint = $(`
+
+
${icon("sprout")} 学习模块统计已隐藏
+
当前孩子的学习包未启用,首页和这里不会显示学习模块统计。启用后在「学习」页为这个孩子开启学习模块。
+
+
+ `);
+ main.appendChild(hint);
+ hint.querySelector("[data-go]").onclick = () => switchMod("learning");
+ }
+
+ const balance = getBalance(growthLoopSnapshot);
+ const opening = getOpeningBalance(growthLoopSnapshot);
+ const openingCard = $(`
+
+ ${opening
+ ? `
${icon("checkCircle")} 期初积分已确认
+
+
+
${openingStatusLabel(opening)}
状态
+
+
已确认的期初积分计入余额,不计入行为统计;如需纠错,请使用普通积分调整流水。
`
+ : `
${icon("star")} 期初积分
+
把旧记录里已经积累的积分带过来?期初积分由家长为当前孩子明确确认一次,不会自动导入旧流水;确认后如需调整,请用普通积分调整流水。
+
`
+ }
`);
- main.appendChild(card);
- card.querySelector(".progressbar i").style.width = Math.min(100,total/30*100)+"%";
+ main.appendChild(openingCard);
+ const openingForm = openingCard.querySelector("#openingBalanceForm");
+ if (openingForm) {
+ openingForm.onsubmit = async (event) => {
+ event.preventDefault();
+ const form = new FormData(event.currentTarget);
+ const value = Number(form.get("balance"));
+ if (!Number.isInteger(value) || value < 1 || value > 1000000) {
+ alert("请填写 1 到 1000000 的整数积分。");
+ return;
+ }
+ if (!window.confirm(`确定为当前孩子确认 ${value} 分期初积分?每个孩子只能确认一次。`)) return;
+ try {
+ const result = await window.growthLoop.confirmOpeningBalance({
+ balance: value,
+ note: "期初积分",
+ request_id: clientRequestId("opening"),
+ });
+ if (result.error === "opening_balance_already_confirmed") {
+ alert("这个孩子的期初积分已经确认过了。");
+ } else if (result.error) {
+ alert("期初积分确认失败,请稍后重试。");
+ } else {
+ window.cloudSync?.scheduleGrowthLoop?.();
+ renderGrow();
+ }
+ } catch (error) {
+ console.error("Growth Loop opening balance confirm failed:", error);
+ alert("期初积分没有保存成功,请稍后重试。");
+ }
+ };
+ }
- const balance = getBalance(growthLoopSnapshot);
const rewards = window.growthLoop?.getRewards?.() || [];
const pendingRedemptions = growthLoopSnapshot.redemptions.filter((item) => item.status === "pending").length;
const rewardCards = rewards.map((reward) => {
@@ -1032,33 +1216,6 @@ function renderGrow(){
};
});
- // 日历式最近记录
- let cells="";
- for(let i=29;i>=0;i--){
- const k=dateKeyOffset(i);
- const c=store.checkins[k];
- const n = CHECKIN_MODULES.filter((module) => hasCheckin(c, module)).length;
- const lvl = n;
- const day = Number(k.slice(-2));
- const label = `${k},${n ? `已完成 ${n}/${CHECKIN_MODULES.length} 个学习模块` : "未打卡"}`;
- cells += `
${day}${n}/${CHECKIN_MODULES.length}
`;
- }
- const cal = $(`
-
-
${icon("calendar")} 近 30 天打卡日历
-
${cells}
-
颜色表示当天完成的学习模块数,格内比例是已完成/共 ${CHECKIN_MODULES.length} 个模块,边框表示今天。
-
- 0/${CHECKIN_MODULES.length} 未打卡
- 1/${CHECKIN_MODULES.length} 模块
- 2/${CHECKIN_MODULES.length} 模块
- 3/${CHECKIN_MODULES.length} 模块
- ${CHECKIN_MODULES.length}/${CHECKIN_MODULES.length} 全部完成
- 今天
-
-
- `);
- main.appendChild(cal);
main.appendChild($(``));
}
@@ -1433,6 +1590,7 @@ function switchMod(mod){
else b.removeAttribute("aria-current");
});
if(mod==="home") renderHome();
+ else if(mod==="learning") renderLearning();
else if(mod==="chinese") renderChinese();
else if(mod==="math") renderMath();
else if(mod==="english") renderEnglish();
diff --git a/src/learning-content-package.js b/src/learning-content-package.js
new file mode 100644
index 0000000..2fb3cf3
--- /dev/null
+++ b/src/learning-content-package.js
@@ -0,0 +1,85 @@
+// 启蒙学习包 v1 —— 轻量代码注册表。
+// 这是唯一的内容包/模块定义来源:稳定 ID、名称、图标、既有内容入口与记录类型。
+// 孩子级启停配置保存在学习状态 envelope 的 learning.content_config 中,
+// 不新增内容包数据库表;本模块不产生积分流水。
+
+export const CONTENT_CONFIG_SCHEMA_VERSION = 1;
+
+export const FOUNDATION_PACKAGE = Object.freeze({
+ id: "foundation-v1",
+ version: 1,
+ name: "启蒙学习包 v1",
+ suggested_age: "4-5",
+ goals: ["识字与阅读", "数感启蒙", "英语启蒙", "亲子共读"],
+ modules: Object.freeze([
+ { id: "chinese", name: "语文学习", icon_key: "book", content_entry: "chinese", record_type: "checkin" },
+ { id: "math", name: "数学与数感", icon_key: "calculator", content_entry: "math", record_type: "checkin" },
+ { id: "english", name: "英语学习", icon_key: "languages", content_entry: "english", record_type: "checkin" },
+ { id: "book", name: "绘本读物", icon_key: "library", content_entry: "book", record_type: "checkin" },
+ ]),
+});
+
+function isRecord(value) {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+export function defaultContentConfig() {
+ return {
+ schema_version: CONTENT_CONFIG_SCHEMA_VERSION,
+ package_id: FOUNDATION_PACKAGE.id,
+ package_version: FOUNDATION_PACKAGE.version,
+ enabled: true,
+ modules: {
+ chinese: true,
+ math: true,
+ english: true,
+ book: true,
+ },
+ };
+}
+
+export function normalizeContentConfig(raw = {}) {
+ const source = isRecord(raw) ? raw : {};
+ const defaults = defaultContentConfig();
+ const modules = {};
+ for (const module of FOUNDATION_PACKAGE.modules) {
+ modules[module.id] = source.modules?.[module.id] !== false;
+ }
+ return {
+ schema_version: CONTENT_CONFIG_SCHEMA_VERSION,
+ package_id: FOUNDATION_PACKAGE.id,
+ package_version: FOUNDATION_PACKAGE.version,
+ enabled: source.enabled !== false,
+ modules,
+ };
+}
+
+export function isPackageEnabled(config) {
+ return normalizeContentConfig(config).enabled;
+}
+
+export function getEnabledModuleIds(config) {
+ const normalized = normalizeContentConfig(config);
+ if (!normalized.enabled) return [];
+ return FOUNDATION_PACKAGE.modules
+ .filter((module) => normalized.modules[module.id] !== false)
+ .map((module) => module.id);
+}
+
+export function getContentModuleDefinition(moduleId) {
+ return FOUNDATION_PACKAGE.modules.find((module) => module.id === moduleId) || null;
+}
+
+export function setContentPackageEnabled(config, enabled) {
+ const normalized = normalizeContentConfig(config);
+ normalized.enabled = Boolean(enabled);
+ return normalized;
+}
+
+export function setContentModuleEnabled(config, moduleId, enabled) {
+ const normalized = normalizeContentConfig(config);
+ if (!getContentModuleDefinition(moduleId)) return normalized;
+ normalized.modules[moduleId] = Boolean(enabled);
+ if (enabled) normalized.enabled = true;
+ return normalized;
+}
diff --git a/src/learning-growth-cloud.js b/src/learning-growth-cloud.js
index 3473708..cfe4c96 100644
--- a/src/learning-growth-cloud.js
+++ b/src/learning-growth-cloud.js
@@ -60,6 +60,13 @@ export function createGrowthLoopTransport({ client } = {}) {
p_note: payload.note || null,
p_occurred_on: payload.occurred_on || new Date().toISOString().slice(0, 10),
});
+ case "opening_balance_confirm":
+ return rpc(client, "learning_confirm_opening_balance", {
+ p_profile_id: payload.profile_id,
+ p_balance: payload.delta,
+ p_request_id: event.request_id,
+ p_note: payload.note || null,
+ });
case "reward_upsert":
return upsert(client, "learning_rewards", publicDefinition(payload.reward, REWARD_FIELDS));
case "profile_reward_upsert": {
diff --git a/src/learning-growth-loop-controller.js b/src/learning-growth-loop-controller.js
index 99c9e16..f092018 100644
--- a/src/learning-growth-loop-controller.js
+++ b/src/learning-growth-loop-controller.js
@@ -1,10 +1,12 @@
import {
+ applyOpeningBalance,
applyPointAction,
applyPointItemCreation,
applyRedemption,
applyRewardCreation,
closePointPeriod,
createGrowthLoopState,
+ getOpeningBalance,
mergeGrowthLoopSnapshot,
normalizeGrowthLoopState,
recommendedPointItems,
@@ -123,6 +125,20 @@ export function createGrowthLoopController({ db } = {}) {
return { ...scope };
}
+ function openingBalance() {
+ const entry = getOpeningBalance(snapshot);
+ return entry ? clone(entry) : null;
+ }
+
+ async function confirmOpeningBalance({ balance, note = "期初积分", request_id = createId() }) {
+ const result = applyOpeningBalance(snapshot, { scope, balance, note, request_id });
+ if (result.error) {
+ return { ...clone(snapshot), error: result.error, entry: result.entry ? clone(result.entry) : null };
+ }
+ await persist(result.snapshot, result.events);
+ return clone(snapshot);
+ }
+
function getPointItems({ includeRecommendations = true } = {}) {
if (!includeRecommendations) return clone(snapshot.point_items);
const existingNames = new Set(snapshot.point_items.map((item) => item.name));
@@ -235,6 +251,11 @@ export function createGrowthLoopController({ db } = {}) {
} else if (event.type === "reward_upsert" && remote?.id) {
const row = next.rewards.find((reward) => reward.id === event.payload.reward?.id);
if (row) Object.assign(row, remote);
+ } else if (event.type === "opening_balance_confirm") {
+ const row = next.ledger.find((entry) => entry.request_id === event.request_id);
+ if (row) {
+ Object.assign(row, remote || {}, { status: "confirmed" });
+ }
} else if (event.type === "reward_redeem") {
const redemption = next.redemptions.find((entry) => entry.request_id === event.request_id);
if (redemption) {
@@ -254,6 +275,10 @@ export function createGrowthLoopController({ db } = {}) {
const row = next.ledger.find((entry) => entry.request_id === event.request_id);
if (row) Object.assign(row, { status: result.status, sync_error: result.error_code || "rejected" });
}
+ if (event.type === "opening_balance_confirm") {
+ const row = next.ledger.find((entry) => entry.request_id === event.request_id);
+ if (row) Object.assign(row, { status: result.status, sync_error: result.error_code || "rejected" });
+ }
if (event.type === "reward_redeem") {
const redemption = next.redemptions.find((entry) => entry.request_id === event.request_id);
if (redemption) Object.assign(redemption, { status: result.status, sync_error: result.error_code || "rejected" });
@@ -326,6 +351,8 @@ export function createGrowthLoopController({ db } = {}) {
loadScope,
getSnapshot,
getScope,
+ openingBalance,
+ confirmOpeningBalance,
getPointItems,
getRewards,
createPointItem,
diff --git a/src/learning-growth-loop.js b/src/learning-growth-loop.js
index 6016e07..e7536c8 100644
--- a/src/learning-growth-loop.js
+++ b/src/learning-growth-loop.js
@@ -176,6 +176,55 @@ export function getBalance(snapshot) {
return activeLedgerEntries(snapshot).reduce((total, entry) => total + Number(entry.delta || 0), 0);
}
+export function getOpeningBalance(snapshot) {
+ return activeLedgerEntries(snapshot).find((entry) => entry.entry_type === "initial_balance") || null;
+}
+
+export function applyOpeningBalance(
+ current,
+ { scope = current.scope, balance, note = "期初积分", request_id = createId("opening") } = {},
+) {
+ const snapshot = normalizeGrowthLoopState(current, scope);
+ const normalizedScope = normalizeScope(scope);
+ const delta = Math.trunc(Number(balance));
+ if (!Number.isFinite(delta) || delta <= 0 || delta > 1000000) {
+ return { snapshot, events: [], error: "opening_balance_invalid" };
+ }
+ const existing = getOpeningBalance(snapshot);
+ if (existing) {
+ return { snapshot, events: [], error: "opening_balance_already_confirmed", entry: existing };
+ }
+ const ledgerEntry = normalizeLedgerEntry({
+ id: createId("ledger"),
+ household_id: normalizedScope.household_id,
+ profile_id: normalizedScope.profile_id,
+ point_item_id: null,
+ delta,
+ entry_type: "initial_balance",
+ item_name_snapshot: "期初积分",
+ note: note || null,
+ request_id,
+ occurred_on: new Date().toISOString().slice(0, 10),
+ status: "pending",
+ metadata: { opening_balance: true },
+ }, normalizedScope);
+ snapshot.ledger.push(ledgerEntry);
+ return {
+ snapshot,
+ ledgerEntry,
+ events: [localEvent({
+ type: "opening_balance_confirm",
+ scope: normalizedScope,
+ request_id,
+ payload: {
+ profile_id: normalizedScope.profile_id,
+ delta,
+ note: note || null,
+ },
+ })],
+ };
+}
+
export function getActivePointAction(snapshot, pointItemId, occurredOn) {
const entries = activeLedgerEntries(snapshot).filter(
(entry) => entry.point_item_id === pointItemId && entry.occurred_on === occurredOn,
diff --git a/src/learning-local-db.js b/src/learning-local-db.js
index 8fd74a2..465a8fb 100644
--- a/src/learning-local-db.js
+++ b/src/learning-local-db.js
@@ -49,6 +49,7 @@ function rehomeOutboxEvent(event, scopeKey, scope) {
payload.profile_point_item.profile_id = scope.profile_id;
}
if (next.type === "point_record" && payload) payload.profile_id = scope.profile_id;
+ if (next.type === "opening_balance_confirm" && payload) payload.profile_id = scope.profile_id;
if (next.type === "reward_upsert" && payload.reward) {
payload.reward.household_id = scope.household_id;
}
diff --git a/src/learning-state-envelope.js b/src/learning-state-envelope.js
index 95b14f8..374bf82 100644
--- a/src/learning-state-envelope.js
+++ b/src/learning-state-envelope.js
@@ -1,3 +1,5 @@
+import { defaultContentConfig } from "./learning-content-package.js";
+
const LEGACY_STATE_KEYS = new Set([
"checkins",
"extra",
@@ -7,19 +9,6 @@ const LEGACY_STATE_KEYS = new Set([
"peanutRead",
]);
-const DEFAULT_CONTENT_CONFIG = {
- schema_version: 1,
- package_id: "foundation-v1",
- package_version: 1,
- enabled: true,
- modules: {
- chinese: true,
- math: true,
- english: true,
- book: true,
- },
-};
-
function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
@@ -84,7 +73,7 @@ export function migrateLegacyLearningState(source, scope = {}) {
bookShelf: cloneRecord(legacy.bookShelf),
peanutLog: cloneArray(legacy.peanutLog),
peanutRead: cloneRecord(legacy.peanutRead),
- content_config: structuredClone(DEFAULT_CONTENT_CONFIG),
+ content_config: defaultContentConfig(),
},
legacy: {
points_readonly: cloneRecord(legacy.points),
diff --git a/src/learning-state.js b/src/learning-state.js
index b0d0410..97c9358 100644
--- a/src/learning-state.js
+++ b/src/learning-state.js
@@ -1,3 +1,5 @@
+import { normalizeContentConfig } from "./learning-content-package.js";
+
const STATE_KEYS = ["checkins", "extra", "points", "bookShelf", "peanutLog", "peanutRead"];
export const CHECKIN_GROUPS = {
@@ -28,6 +30,7 @@ export function createLearningState(initial = {}) {
bookShelf: cloneRecord(source.bookShelf),
peanutLog: Array.isArray(source.peanutLog) ? structuredClone(source.peanutLog) : [],
peanutRead: cloneRecord(source.peanutRead),
+ content_config: normalizeContentConfig(source.content_config),
};
}
diff --git a/supabase/migrations/20260816120000_growth_loop_opening_balance.sql b/supabase/migrations/20260816120000_growth_loop_opening_balance.sql
new file mode 100644
index 0000000..e3f2256
--- /dev/null
+++ b/supabase/migrations/20260816120000_growth_loop_opening_balance.sql
@@ -0,0 +1,152 @@
+-- Shadow Mate Growth Loop: once-per-child opening balance confirmation.
+-- Old points stay read-only in legacy state; a guardian confirms a single
+-- initial balance per child that becomes one auditable initial_balance row.
+-- It is not history behavior and is excluded from effective-action metrics.
+
+-- Carry-forward balances can exceed the normal +/-1000 single-event guard, so
+-- the signed-delta ceiling is raised only for initial_balance rows. Every
+-- other entry type keeps the existing ceiling.
+do $migration$
+declare
+ constraint_name text;
+begin
+ select conname into constraint_name
+ from pg_constraint
+ where conrelid = to_regclass('public.learning_point_ledger')
+ and contype = 'c'
+ and pg_get_constraintdef(oid) like '%delta%'
+ order by conname
+ limit 1;
+
+ if constraint_name is not null then
+ execute format('alter table public.learning_point_ledger drop constraint %I', constraint_name);
+ end if;
+end;
+$migration$;
+
+alter table public.learning_point_ledger
+ add constraint learning_point_ledger_delta_check check (
+ delta <> 0
+ and (
+ (entry_type = 'initial_balance' and delta between -1000000 and 1000000)
+ or (entry_type <> 'initial_balance' and delta between -1000 and 1000)
+ )
+ );
+
+create or replace function public.learning_confirm_opening_balance(
+ p_profile_id uuid,
+ p_balance integer,
+ p_request_id uuid,
+ p_note text default '期初积分'
+)
+returns setof public.learning_point_ledger
+language plpgsql
+security definer
+set search_path = ''
+as $function$
+declare
+ actor_id uuid := (select auth.uid());
+ profile_household_id uuid;
+ existing_balance public.learning_point_ledger%rowtype;
+ existing_row public.learning_point_ledger%rowtype;
+ saved_row public.learning_point_ledger%rowtype;
+begin
+ if actor_id is null then
+ raise exception 'learning_auth_required' using errcode = '42501';
+ end if;
+
+ if p_request_id is null then
+ raise exception 'learning_request_id_required' using errcode = '22023';
+ end if;
+
+ if p_balance is null or p_balance <= 0 or p_balance > 1000000 then
+ raise exception 'learning_opening_balance_invalid' using errcode = '22023';
+ end if;
+
+ if p_note is not null and char_length(p_note) > 200 then
+ raise exception 'learning_point_note_too_long' using errcode = '22001';
+ end if;
+
+ select profile.household_id
+ into profile_household_id
+ from public.learning_profiles profile
+ join public.learning_household_members member
+ on member.household_id = profile.household_id
+ where profile.id = p_profile_id
+ and member.user_id = actor_id
+ and member.role in ('owner', 'guardian');
+
+ if not found then
+ raise exception 'learning_point_forbidden' using errcode = '42501';
+ end if;
+
+ -- A retry of the same request returns the original opening balance row.
+ select *
+ into existing_row
+ from public.learning_point_ledger ledger
+ where ledger.profile_id = p_profile_id
+ and ledger.request_id = p_request_id;
+
+ if found then
+ if existing_row.entry_type = 'initial_balance'
+ and existing_row.delta = p_balance
+ and existing_row.note is not distinct from p_note then
+ return next existing_row;
+ return;
+ end if;
+ raise exception 'learning_request_reuse_conflict' using errcode = 'P0001';
+ end if;
+
+ -- Child-level lock serializes concurrent confirmations for one child.
+ perform 1
+ from public.learning_profiles profile
+ where profile.id = p_profile_id
+ for update;
+
+ -- At most one confirmed opening balance per child.
+ select *
+ into existing_balance
+ from public.learning_point_ledger ledger
+ where ledger.profile_id = p_profile_id
+ and ledger.entry_type = 'initial_balance'
+ limit 1;
+
+ if found then
+ raise exception 'learning_opening_balance_already_confirmed' using errcode = 'P0001';
+ end if;
+
+ insert into public.learning_point_ledger (
+ household_id,
+ profile_id,
+ point_item_id,
+ delta,
+ entry_type,
+ item_name_snapshot,
+ note,
+ request_id,
+ actor_user_id
+ )
+ values (
+ profile_household_id,
+ p_profile_id,
+ null,
+ p_balance,
+ 'initial_balance',
+ '期初积分',
+ p_note,
+ p_request_id,
+ actor_id
+ )
+ returning * into saved_row;
+
+ return next saved_row;
+end;
+$function$;
+
+revoke all on function public.learning_confirm_opening_balance(uuid, integer, uuid, text) from public;
+revoke all on function public.learning_confirm_opening_balance(uuid, integer, uuid, text) from anon;
+grant execute on function public.learning_confirm_opening_balance(uuid, integer, uuid, text)
+ to authenticated;
+
+comment on function public.learning_confirm_opening_balance(uuid, integer, uuid, text) is
+ 'Confirms a once-per-child opening balance as a single initial_balance ledger row.';
diff --git a/supabase/tests/growth_loop_opening_balance_test.sql b/supabase/tests/growth_loop_opening_balance_test.sql
new file mode 100644
index 0000000..eb84d10
--- /dev/null
+++ b/supabase/tests/growth_loop_opening_balance_test.sql
@@ -0,0 +1,284 @@
+begin;
+select plan(20);
+
+set local role postgres;
+
+insert into auth.users (id, email, encrypted_password, raw_user_meta_data)
+values
+ ('77777777-7777-4777-8777-777777777777', 'balance-owner@example.test', '$2a$10$test-password-hash', '{}'::jsonb),
+ ('88888888-8888-4888-8888-888888888888', 'balance-other@example.test', '$2a$10$test-password-hash', '{}'::jsonb);
+
+insert into public.learning_households (id, name, owner_user_id)
+values (
+ 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ '期初积分测试家庭',
+ '77777777-7777-4777-8777-777777777777'
+);
+
+insert into public.learning_household_members (household_id, user_id, role)
+values (
+ 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ '77777777-7777-4777-8777-777777777777',
+ 'owner'
+);
+
+insert into public.learning_profiles (id, household_id, display_name, grade_level)
+values
+ ('dddddddd-1111-4ddd-8ddd-dddddddddddd', 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', '期初孩子一', 4),
+ ('dddddddd-2222-4ddd-8ddd-dddddddddddd', 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', '期初孩子二', 4);
+
+-- The signed-delta ceiling is relaxed only for initial_balance rows.
+select lives_ok(
+ $$insert into public.learning_point_ledger (
+ household_id, profile_id, delta, entry_type, item_name_snapshot, request_id
+ ) values (
+ 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 5000,
+ 'initial_balance',
+ '期初积分',
+ 'dddddddd-0001-4ddd-8ddd-dddddddddddd'
+ )$$,
+ 'ledger accepts an initial_balance delta above the normal ceiling'
+);
+
+select throws_ok(
+ $$insert into public.learning_point_ledger (
+ household_id, profile_id, delta, entry_type, item_name_snapshot, request_id
+ ) values (
+ 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 2000,
+ 'manual',
+ '普通流水',
+ 'dddddddd-0002-4ddd-8ddd-dddddddddddd'
+ )$$,
+ '23514',
+ null,
+ 'non-opening entries keep the +/-1000 ceiling'
+);
+
+-- Clean up the constraint probe so the child starts unconfirmed.
+delete from public.learning_point_ledger
+where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd';
+
+select ok(
+ exists (
+ select 1
+ from pg_proc function
+ join pg_namespace namespace on namespace.oid = function.pronamespace
+ where namespace.nspname = 'public'
+ and function.proname = 'learning_confirm_opening_balance'
+ and function.pronargs = 4
+ ),
+ 'opening balance RPC has the stable request contract'
+);
+
+select ok(
+ coalesce((
+ select has_function_privilege(
+ 'authenticated',
+ 'public.learning_confirm_opening_balance(uuid,integer,uuid,text)',
+ 'execute'
+ )
+ where exists (
+ select 1
+ from pg_proc function
+ join pg_namespace namespace on namespace.oid = function.pronamespace
+ where namespace.nspname = 'public'
+ and function.proname = 'learning_confirm_opening_balance'
+ )
+ ), false),
+ 'authenticated users can call the opening balance RPC'
+);
+
+select ok(
+ coalesce((
+ select not has_function_privilege(
+ 'anon',
+ 'public.learning_confirm_opening_balance(uuid,integer,uuid,text)',
+ 'execute'
+ )
+ where exists (
+ select 1
+ from pg_proc function
+ join pg_namespace namespace on namespace.oid = function.pronamespace
+ where namespace.nspname = 'public'
+ and function.proname = 'learning_confirm_opening_balance'
+ )
+ ), false),
+ 'anonymous users cannot call the opening balance RPC'
+);
+
+select ok(
+ coalesce((
+ select function.prosecdef
+ from pg_proc function
+ join pg_namespace namespace on namespace.oid = function.pronamespace
+ where namespace.nspname = 'public'
+ and function.proname = 'learning_confirm_opening_balance'
+ and function.pronargs = 4
+ limit 1
+ ), false),
+ 'opening balance RPC runs as security definer'
+);
+
+set local role authenticated;
+set local request.jwt.claim.sub = '77777777-7777-4777-8777-777777777777';
+
+select is(
+ (select count(*)
+ from public.learning_confirm_opening_balance(
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 1234,
+ 'dddddddd-aaaa-4ddd-8ddd-dddddddddddd',
+ '期初积分'
+ )),
+ 1::bigint,
+ 'guardian can confirm an opening balance once'
+);
+
+select is(
+ (select count(*)
+ from public.learning_point_ledger
+ where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd'
+ and entry_type = 'initial_balance'),
+ 1::bigint,
+ 'exactly one initial_balance row is created for the child'
+);
+
+select is(
+ (select delta
+ from public.learning_point_ledger
+ where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd'
+ and entry_type = 'initial_balance'),
+ 1234,
+ 'opening balance stores the confirmed carry-forward value'
+);
+
+select is(
+ (select item_name_snapshot
+ from public.learning_point_ledger
+ where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd'
+ and entry_type = 'initial_balance'),
+ '期初积分',
+ 'opening balance is labeled as opening balance, not history behavior'
+);
+
+select is(
+ (select count(*)
+ from public.learning_confirm_opening_balance(
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 1234,
+ 'dddddddd-aaaa-4ddd-8ddd-dddddddddddd',
+ '期初积分'
+ )),
+ 1::bigint,
+ 'repeating the same request returns the original opening balance'
+);
+
+select is(
+ (select count(*)
+ from public.learning_point_ledger
+ where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd'
+ and entry_type = 'initial_balance'),
+ 1::bigint,
+ 'repeating the same request does not duplicate the row'
+);
+
+select throws_ok(
+ $$select * from public.learning_confirm_opening_balance(
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 100,
+ 'dddddddd-bbbb-4ddd-8ddd-dddddddddddd',
+ '再次确认'
+ )$$,
+ 'P0001',
+ 'learning_opening_balance_already_confirmed',
+ 'a second confirmation for the same child is rejected'
+);
+
+select throws_ok(
+ $$select * from public.learning_confirm_opening_balance(
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 0,
+ 'dddddddd-cccc-4ddd-8ddd-dddddddddddd',
+ '零分'
+ )$$,
+ '22023',
+ 'learning_opening_balance_invalid',
+ 'a zero opening balance is rejected'
+);
+
+select throws_ok(
+ $$select * from public.learning_confirm_opening_balance(
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 2000000,
+ 'dddddddd-cccc-4ddd-8ddd-dddddddddddd',
+ '超限'
+ )$$,
+ '22023',
+ 'learning_opening_balance_invalid',
+ 'an overflowing opening balance is rejected'
+);
+
+select is(
+ (select count(*)
+ from public.learning_confirm_opening_balance(
+ 'dddddddd-2222-4ddd-8ddd-dddddddddddd',
+ 50,
+ 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ '期初积分'
+ )),
+ 1::bigint,
+ 'another child in the same household can confirm its own opening balance'
+);
+
+select is(
+ (select count(*)
+ from public.learning_point_ledger
+ where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd'
+ and entry_type = 'initial_balance'),
+ 1::bigint,
+ 'the first child is unaffected by the second child confirmation'
+);
+
+select throws_ok(
+ $$insert into public.learning_point_ledger (
+ household_id, profile_id, delta, entry_type, item_name_snapshot, request_id
+ ) values (
+ 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 5,
+ 'initial_balance',
+ '绕过 RPC',
+ 'dddddddd-eeee-4ddd-8ddd-dddddddddddd'
+ )$$,
+ '42501',
+ null,
+ 'authenticated users cannot insert ledger rows directly'
+);
+
+set local request.jwt.claim.sub = '88888888-8888-4888-8888-888888888888';
+
+select throws_ok(
+ $$select * from public.learning_confirm_opening_balance(
+ 'dddddddd-1111-4ddd-8ddd-dddddddddddd',
+ 10,
+ 'dddddddd-ffff-4ddd-8ddd-dddddddddddd',
+ '越权'
+ )$$,
+ '42501',
+ 'learning_point_forbidden',
+ 'a non-member user cannot confirm an opening balance'
+);
+
+select is(
+ (select count(*) from public.learning_point_ledger
+ where profile_id = 'dddddddd-1111-4ddd-8ddd-dddddddddddd'),
+ 0::bigint,
+ 'a non-member user cannot read the first household ledger'
+);
+
+select * from finish();
+rollback;
diff --git a/tests/e2e/offline-tts-error.spec.js b/tests/e2e/offline-tts-error.spec.js
index e251cd6..24ccb2b 100644
--- a/tests/e2e/offline-tts-error.spec.js
+++ b/tests/e2e/offline-tts-error.spec.js
@@ -30,7 +30,8 @@ test.describe("Offline voice download errors", () => {
value: function SpeechSynthesisUtterance() {},
});
});
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
const button = page.locator("[data-speak]").first();
await button.click();
await page.click('.voice-dialog-actions [data-action="ok"]');
diff --git a/tests/e2e/offline-tts-warmup.spec.js b/tests/e2e/offline-tts-warmup.spec.js
index 66a4db5..62a12f8 100644
--- a/tests/e2e/offline-tts-warmup.spec.js
+++ b/tests/e2e/offline-tts-warmup.spec.js
@@ -93,7 +93,8 @@ test.describe("Offline voice warmup", () => {
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
const button = page.locator("[data-speak]").first();
await button.click();
await page.click('.voice-dialog-actions [data-action="ok"]');
@@ -144,7 +145,8 @@ test.describe("Offline voice warmup", () => {
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
const button = page.locator("[data-speak]").first();
await button.click();
@@ -215,7 +217,8 @@ test.describe("Offline voice warmup", () => {
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
await page.locator("[data-speak]").first().click();
await expect.poll(() => page.evaluate(() => window.__audioPlayCalls || 0), { timeout: 3000 }).toBe(1);
@@ -286,7 +289,8 @@ test.describe("Offline voice warmup", () => {
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
await page.locator("[data-speak]").first().click();
await expect.poll(() => page.evaluate(() => window.__ttsRuntimeKinds), { timeout: 3000 })
@@ -371,7 +375,8 @@ test.describe("Offline voice warmup", () => {
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
await page.locator("[data-speak]").first().click();
await expect.poll(() => page.evaluate(() => ({
diff --git a/tests/e2e/offline.spec.js b/tests/e2e/offline.spec.js
index 0d18ef0..003f060 100644
--- a/tests/e2e/offline.spec.js
+++ b/tests/e2e/offline.spec.js
@@ -1,5 +1,10 @@
import { test, expect } from "@playwright/test";
+async function openModule(page, mod) {
+ await page.click('[data-mod="learning"]');
+ await page.click(`[data-go="${mod}"]`);
+}
+
test.describe("Offline mode (no login)", () => {
test("app loads with correct title", async ({ page }) => {
await page.goto("/");
@@ -9,7 +14,7 @@ test.describe("Offline mode (no login)", () => {
test("home page shows banner and stats", async ({ page }) => {
await page.goto("/");
await expect(page.locator(".banner")).toBeVisible();
- await expect(page.locator(".stat-grid .stat")).toHaveCount(4);
+ await expect(page.locator(".stat-grid .stat")).toHaveCount(5);
});
test("footer exposes social links and the WeChat QR dialog", async ({ page }) => {
@@ -57,7 +62,7 @@ test.describe("Offline mode (no login)", () => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/");
- await page.click('[data-mod="math"]');
+ await openModule(page, "math");
await expect(page.locator(".lvl-btn").nth(1)).toHaveCSS("background-color", "rgb(24, 39, 29)");
await expect(page.locator(".lvl-btn").first()).toHaveCSS("background-color", "rgb(26, 56, 36)");
await expect(page.locator(".num-cell.miss")).toHaveCSS("background-color", "rgb(24, 39, 29)");
@@ -107,6 +112,12 @@ test.describe("Offline mode (no login)", () => {
["math", "数学"],
["english", "英语"],
["book", "绘本"],
+ ]) {
+ await openModule(page, mod);
+ await page.waitForSelector(".module-title h2",{timeout:5000});
+ await expect(page.locator(".module-title h2")).toContainText(label);
+ }
+ for (const [mod, label] of [
["points", "积分"],
["grow", "成长"],
]) {
@@ -143,7 +154,7 @@ test.describe("Offline mode (no login)", () => {
});
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await openModule(page, "english");
const word = await page.locator(".word-en").first().textContent();
await page.locator("[data-speak]").first().click();
await expect.poll(() => page.evaluate(() => window.__speechCalls)).toEqual([word]);
@@ -158,7 +169,7 @@ test.describe("Offline mode (no login)", () => {
});
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: function SpeechSynthesisUtterance() {} });
});
- await page.click('[data-mod="english"]');
+ await openModule(page, "english");
const button = page.locator("[data-speak]").first();
await button.click();
await expect(page.locator("#shadow-voice-dialog[open]")).toBeVisible();
@@ -169,7 +180,7 @@ test.describe("Offline mode (no login)", () => {
test("number sense keeps exactly one missing number in sequence", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="math"]');
+ await openModule(page, "math");
const cells = await page.locator(".num-grid .num-cell").allTextContents();
const missingIndex = cells.indexOf("?");
expect(missingIndex).toBeGreaterThan(0);
@@ -179,7 +190,7 @@ test.describe("Offline mode (no login)", () => {
test("checkin marks module as done", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="chinese"]');
+ await openModule(page, "chinese");
const btn = page.locator('[data-cmod="chinese-literacy"]');
const wasDone = await btn.evaluate((el) => el.classList.contains("done"));
if (!wasDone) {
@@ -190,7 +201,7 @@ test.describe("Offline mode (no login)", () => {
test("checkin can be cancelled by clicking again", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="book"]');
+ await openModule(page, "book");
const btn = page.locator('[data-cmod="book-reading"]');
const initiallyDone = await btn.evaluate((el) => el.classList.contains("done"));
if (initiallyDone) await btn.click();
@@ -203,7 +214,7 @@ test.describe("Offline mode (no login)", () => {
test("cancelling one checkin does not cancel other tasks in the module", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="chinese"]');
+ await openModule(page, "chinese");
const buttons = page.locator('[data-cmod^="chinese-"]');
await expect(buttons).toHaveCount(3);
for (const button of await buttons.all()) {
@@ -254,7 +265,7 @@ test.describe("Offline mode (no login)", () => {
test("math quiz shows feedback", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="math"]');
+ await openModule(page, "math");
await expect(page.locator("#qq")).toBeVisible();
await page.fill("#qa", "999");
await page.click("#qsubmit");
@@ -264,7 +275,7 @@ test.describe("Offline mode (no login)", () => {
test("book shelf marks read status", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="book"]');
+ await openModule(page, "book");
const card = page.locator("[data-bk]").first();
await expect(card).toBeVisible();
await expect(card).toHaveCSS("opacity", "0.55");
@@ -276,7 +287,7 @@ test.describe("Offline mode (no login)", () => {
test("reading log can be added and deleted", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="book"]');
+ await openModule(page, "book");
await page.fill("#pbTitle", "E2E Test Book");
await page.locator('.pstar[data-n="5"]').click();
await page.click("#pbAdd");
@@ -304,13 +315,13 @@ test.describe("Offline mode (no login)", () => {
test("state persists across navigation", async ({ page }) => {
await page.goto("/");
// Do a checkin on chinese
- await page.click('[data-mod="chinese"]');
+ await openModule(page, "chinese");
const btn = page.locator('[data-cmod="chinese-literacy"]');
const wasDone = await btn.evaluate((el) => el.classList.contains("done"));
if (!wasDone) await btn.click();
// Navigate away and back
- await page.click('[data-mod="math"]');
- await page.click('[data-mod="chinese"]');
+ await openModule(page, "math");
+ await openModule(page, "chinese");
// Checkin should still be done
const btn2 = page.locator('[data-cmod="chinese-literacy"]');
await expect(btn2).toHaveClass(/done/);
@@ -318,11 +329,11 @@ test.describe("Offline mode (no login)", () => {
test("state persists across a reload", async ({ page }) => {
await page.goto("/");
- await page.click('[data-mod="chinese"]');
+ await openModule(page, "chinese");
const btn = page.locator('[data-cmod="chinese-literacy"]');
if (!(await btn.evaluate((el) => el.classList.contains("done")))) await btn.click();
await page.reload();
- await page.click('[data-mod="chinese"]');
+ await openModule(page, "chinese");
await expect(page.locator('[data-cmod="chinese-literacy"]')).toHaveClass(/done/);
});
});
diff --git a/tests/e2e/tts-system-fallback.spec.js b/tests/e2e/tts-system-fallback.spec.js
index 06d6c6e..c81c015 100644
--- a/tests/e2e/tts-system-fallback.spec.js
+++ b/tests/e2e/tts-system-fallback.spec.js
@@ -57,7 +57,8 @@ test.describe("System speech fallback", () => {
});
});
await page.goto("/");
- await page.click('[data-mod="english"]');
+ await page.click('[data-mod="learning"]');
+ await page.click('[data-go="english"]');
const button = page.locator("[data-speak]").first();
await button.click();
diff --git a/tests/unit/learning-content-package.test.js b/tests/unit/learning-content-package.test.js
new file mode 100644
index 0000000..fe61cda
--- /dev/null
+++ b/tests/unit/learning-content-package.test.js
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import {
+ CONTENT_CONFIG_SCHEMA_VERSION,
+ defaultContentConfig,
+ FOUNDATION_PACKAGE,
+ getContentModuleDefinition,
+ getEnabledModuleIds,
+ isPackageEnabled,
+ normalizeContentConfig,
+ setContentModuleEnabled,
+ setContentPackageEnabled,
+} from "../../src/learning-content-package.js";
+
+describe("content package registry", () => {
+ it("exposes a stable package and module registry", () => {
+ expect(FOUNDATION_PACKAGE.id).toBe("foundation-v1");
+ expect(FOUNDATION_PACKAGE.version).toBe(1);
+ expect(FOUNDATION_PACKAGE.modules.map((module) => module.id)).toEqual(["chinese", "math", "english", "book"]);
+ for (const module of FOUNDATION_PACKAGE.modules) {
+ expect(getContentModuleDefinition(module.id)).toBe(module);
+ }
+ expect(getContentModuleDefinition("unknown")).toBeNull();
+ });
+
+ it("defaults every module and the package to enabled", () => {
+ expect(defaultContentConfig()).toEqual({
+ schema_version: CONTENT_CONFIG_SCHEMA_VERSION,
+ package_id: "foundation-v1",
+ package_version: 1,
+ enabled: true,
+ modules: { chinese: true, math: true, english: true, book: true },
+ });
+ expect(getEnabledModuleIds(defaultContentConfig())).toEqual(["chinese", "math", "english", "book"]);
+ });
+
+ it("normalizes malformed configs to all-enabled defaults", () => {
+ expect(getEnabledModuleIds(null)).toEqual(["chinese", "math", "english", "book"]);
+ expect(getEnabledModuleIds({ modules: { math: false } })).toEqual(["chinese", "english", "book"]);
+ expect(getEnabledModuleIds({ modules: { book: false }, enabled: false })).toEqual([]);
+ });
+
+ it("disabling the package hides every module", () => {
+ const config = setContentPackageEnabled(defaultContentConfig(), false);
+ expect(isPackageEnabled(config)).toBe(false);
+ expect(getEnabledModuleIds(config)).toEqual([]);
+ });
+
+ it("re-enabling a module also enables the package", () => {
+ const config = setContentModuleEnabled(setContentPackageEnabled(defaultContentConfig(), false), "math", true);
+ expect(config.enabled).toBe(true);
+ expect(getEnabledModuleIds(config)).toEqual(["chinese", "math", "english", "book"]);
+ });
+
+ it("disabling one module removes it from the enabled set", () => {
+ const config = setContentModuleEnabled(defaultContentConfig(), "math", false);
+ expect(config.enabled).toBe(true);
+ expect(getEnabledModuleIds(config)).toEqual(["chinese", "english", "book"]);
+ });
+
+ it("ignores unknown module ids and never leaks unknown config keys", () => {
+ const config = setContentModuleEnabled(defaultContentConfig(), "unknown", false);
+ expect(getEnabledModuleIds(config)).toEqual(["chinese", "math", "english", "book"]);
+ expect(normalizeContentConfig({ modules: { chinese: false, junk: true } })).toEqual({
+ schema_version: CONTENT_CONFIG_SCHEMA_VERSION,
+ package_id: "foundation-v1",
+ package_version: 1,
+ enabled: true,
+ modules: { chinese: false, math: true, english: true, book: true },
+ });
+ });
+});
diff --git a/tests/unit/learning-growth-loop.test.js b/tests/unit/learning-growth-loop.test.js
index abeb0ac..a32d70c 100644
--- a/tests/unit/learning-growth-loop.test.js
+++ b/tests/unit/learning-growth-loop.test.js
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import {
+ applyOpeningBalance,
applyPointAction,
applyRedemption,
closePointPeriod,
createGrowthLoopState,
getActivePointAction,
getBalance,
+ getOpeningBalance,
getPointPeriodTotal,
mergeGrowthLoopSnapshot,
recommendedPointItems,
@@ -150,3 +152,54 @@ describe("Growth Loop local projection", () => {
expect(closed.events).toEqual([expect.objectContaining({ type: "point_record" })]);
});
});
+
+describe("Growth Loop opening balance", () => {
+ it("confirms an opening balance once and counts it in the balance", () => {
+ const state = createGrowthLoopState(scope);
+ const result = applyOpeningBalance(state, {
+ scope,
+ balance: 128,
+ note: "期初积分",
+ request_id: "opening-1",
+ });
+
+ expect(result.error).toBeUndefined();
+ expect(result.snapshot.ledger).toEqual([
+ expect.objectContaining({
+ delta: 128,
+ entry_type: "initial_balance",
+ item_name_snapshot: "期初积分",
+ request_id: "opening-1",
+ status: "pending",
+ }),
+ ]);
+ expect(result.events).toEqual([
+ expect.objectContaining({ type: "opening_balance_confirm", request_id: "opening-1" }),
+ ]);
+ expect(getOpeningBalance(result.snapshot)).toEqual(expect.objectContaining({ delta: 128, entry_type: "initial_balance" }));
+ expect(getBalance(result.snapshot)).toBe(128);
+ });
+
+ it("rejects a second confirmation for the same child", () => {
+ const first = applyOpeningBalance(createGrowthLoopState(scope), { scope, balance: 50, request_id: "opening-1" });
+ const second = applyOpeningBalance(first.snapshot, { scope, balance: 200, request_id: "opening-2" });
+
+ expect(second.error).toBe("opening_balance_already_confirmed");
+ expect(second.entry).toEqual(expect.objectContaining({ delta: 50 }));
+ expect(second.snapshot.ledger).toHaveLength(1);
+ });
+
+ it("rejects invalid opening balances", () => {
+ for (const invalid of [0, -5, 1000001, "abc", undefined, null]) {
+ const result = applyOpeningBalance(createGrowthLoopState(scope), { scope, balance: invalid });
+ expect(result.error).toBe("opening_balance_invalid");
+ expect(result.snapshot.ledger).toHaveLength(0);
+ expect(result.events).toEqual([]);
+ }
+ });
+
+ it("keeps opening balance out of effective-action metrics", () => {
+ const result = applyOpeningBalance(createGrowthLoopState(scope), { scope, balance: 10, request_id: "opening-1" });
+ expect(result.snapshot.ledger.map((entry) => entry.entry_type)).toEqual(["initial_balance"]);
+ });
+});
diff --git a/tests/unit/learning-state.test.js b/tests/unit/learning-state.test.js
index 8a85009..a22c4df 100644
--- a/tests/unit/learning-state.test.js
+++ b/tests/unit/learning-state.test.js
@@ -23,6 +23,13 @@ describe("learning state machine", () => {
bookShelf: {},
peanutLog: [],
peanutRead: {},
+ content_config: {
+ schema_version: 1,
+ package_id: "foundation-v1",
+ package_version: 1,
+ enabled: true,
+ modules: { chinese: true, math: true, english: true, book: true },
+ },
});
});
@@ -41,6 +48,13 @@ describe("learning state machine", () => {
bookShelf: { "2": 1 },
peanutLog: [],
peanutRead: {},
+ content_config: {
+ schema_version: 1,
+ package_id: "foundation-v1",
+ package_version: 1,
+ enabled: true,
+ modules: { chinese: true, math: true, english: true, book: true },
+ },
});
expect(hasCheckin({ "chinese-literacy": true }, "chinese")).toBe(true);
expect(hasCheckin(null, "chinese")).toBe(false);
@@ -157,6 +171,13 @@ describe("learning state machine", () => {
bookShelf: {},
peanutLog: [],
peanutRead: {},
+ content_config: {
+ schema_version: 1,
+ package_id: "foundation-v1",
+ package_version: 1,
+ enabled: true,
+ modules: { chinese: true, math: true, english: true, book: true },
+ },
});
expect(initial.points["2026-8"]).toEqual({ "0": { "1": 1 } });
});