mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge remote-tracking branch 'origin/main' into release/v1.47.4
This commit is contained in:
commit
02f4f8116c
3 files changed
+9
-136
No files matched your search
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Settings lists the Coach entry only while the Coach can answer, like the
|
||||
* sidebar and the bottom bar, and Settings → AI then keeps the stored Coach
|
||||
* memory in reach.
|
||||
*
|
||||
* Both used to read the raw module map, which answers the operator's switch
|
||||
* and Hide Coach but not a missing provider or missing consent: the settings
|
||||
* nav kept a Coach entry the sidebar had already dropped. The two now read
|
||||
* the same map (`useNavModules`), so the memory's own entry and the fallback
|
||||
* under Settings → AI cannot both go.
|
||||
*
|
||||
* The shell's module filter runs after hydration only, so `useMounted` is
|
||||
* mocked to the hydrated answer.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
coachAvailable: true,
|
||||
active: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/settings/ai",
|
||||
useRouter: () => ({ replace: () => {}, push: () => {} }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
modules: { coach: true },
|
||||
accountAccess: { active: h.active },
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: h.coachAvailable,
|
||||
reason: h.coachAvailable ? null : "no_provider",
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
// Settings → AI's cards have their own suites; only the memory slot is
|
||||
// under test here.
|
||||
vi.mock("@/components/ai/ai-consent-request", () => ({
|
||||
AiConsentRequest: () => null,
|
||||
}));
|
||||
vi.mock("@/components/settings/ai/ai-insights-card", () => ({
|
||||
AiInsightsCard: () => null,
|
||||
}));
|
||||
vi.mock("@/components/settings/ai/operator-off-notice", () => ({
|
||||
OperatorOffNotice: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/ai-setup-hint", () => ({
|
||||
AiSetupHint: () => null,
|
||||
}));
|
||||
vi.mock("@/components/settings/coach-memory-section", () => ({
|
||||
StoredCoachMemory: () => <div data-slot="stored-coach-memory" />,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiSection } from "../ai-section";
|
||||
import { SettingsShell } from "../settings-shell";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("the Coach entry in Settings", () => {
|
||||
beforeEach(() => {
|
||||
h.coachAvailable = true;
|
||||
h.active = null;
|
||||
});
|
||||
|
||||
it("is listed while the Coach can answer, and the memory lives there", () => {
|
||||
expect(render(<SettingsShell active="ai">body</SettingsShell>)).toContain(
|
||||
'href="/settings/coach"',
|
||||
);
|
||||
expect(render(<AiSection />)).not.toContain(
|
||||
'data-slot="stored-coach-memory"',
|
||||
);
|
||||
});
|
||||
|
||||
it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => {
|
||||
h.coachAvailable = false;
|
||||
expect(
|
||||
render(<SettingsShell active="ai">body</SettingsShell>),
|
||||
).not.toContain('href="/settings/coach"');
|
||||
expect(render(<AiSection />)).toContain('data-slot="stored-coach-memory"');
|
||||
});
|
||||
|
||||
it("stays for a managed record whose Coach cannot answer yet", () => {
|
||||
// A record has no Settings, AI: the guardian reaches the record's Coach
|
||||
// data scope and switch through this entry only.
|
||||
h.coachAvailable = false;
|
||||
h.active = {
|
||||
recordKind: "managed",
|
||||
level: "manage",
|
||||
manageableDomains: [],
|
||||
};
|
||||
expect(
|
||||
render(<SettingsShell active="coach">body</SettingsShell>),
|
||||
).toContain('href="/settings/coach"');
|
||||
});
|
||||
});
|
||||
@@ -25,12 +25,10 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
|
||||
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
|
||||
export function AiSection() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// v1.16.6 — the auth query can resolve before this boundary
|
||||
// hydrates; every `disabled={!isAuthenticated}` binding in the child
|
||||
// cards would then disagree with the SSR HTML (React #418). Gate the
|
||||
@@ -48,9 +46,11 @@ export function AiSection() {
|
||||
// nothing below reads as broken), and the one calm setup hint while no
|
||||
// provider is configured, or the consent question while the operator's
|
||||
// provider waits on it. Below it: what the Coach stored, while Settings →
|
||||
// Coach is not listed (the settings nav reads the same map), so it can
|
||||
// always be read and deleted.
|
||||
const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules);
|
||||
// Coach is not reachable, so it can always be read and deleted.
|
||||
const coachSettingsReachable = isSurfaceVisible(
|
||||
"settings:coach",
|
||||
user?.modules,
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<OperatorOffNotice />
|
||||
|
||||
@@ -47,7 +47,6 @@ import { cn } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
@@ -356,20 +355,6 @@ function deriveActiveSlug(
|
||||
return isSettingsSectionSlug(candidate) ? candidate : "account";
|
||||
}
|
||||
|
||||
/**
|
||||
* The module map the section list reads, the sidebar's. A managed record has
|
||||
* no Settings, AI to list its Coach settings (its data scope, its switch), so
|
||||
* there the Coach entry follows the module switch alone, not whether the
|
||||
* record's Coach can answer yet.
|
||||
*/
|
||||
function useSectionModules() {
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
return user?.accountAccess?.active?.recordKind === "managed"
|
||||
? user.modules
|
||||
: navModules;
|
||||
}
|
||||
|
||||
export function SettingsShell({
|
||||
active,
|
||||
heading,
|
||||
@@ -381,8 +366,8 @@ export function SettingsShell({
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.18.0 — per-submodule entries are listed only when their module
|
||||
// is enabled, from `useNavModules()` as in the sidebar (Coach follows its
|
||||
// AI capability; Settings → AI then lists the stored memory). Fail OPEN
|
||||
// is enabled. Read from the resolved `useAuth().user.modules` map (the
|
||||
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
|
||||
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
|
||||
// reads as enabled so an entry never silently disappears. A section's
|
||||
// owner comes from the surface map (`settings:<slug>`); sections with no
|
||||
@@ -405,7 +390,7 @@ export function SettingsShell({
|
||||
// effect (which the lint rule flags as a cascading render), and no risk of
|
||||
// the two passes disagreeing.
|
||||
const hydrated = useMounted();
|
||||
const modules = useSectionModules();
|
||||
const modules = user?.modules;
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const allVisibleSections = SETTINGS_SECTIONS.filter(
|
||||
(section) =>
|
||||
|
||||
Reference in new issue
Block a user