fix(dictation): extract tested computeDictationAvailability + gate mic from the reactive atom (#314 F1/F2)

F1: the availability publish-effect duplicated the #218 editability gate
(editable && inEditMode && !showStatic) inline — a copy that could silently
diverge from the tested isBodyEditable — and the reason computation (the core of
#309) had no tests. Extract computeDictationAvailability into editor-sync-state.ts
REUSING isBodyEditable; the effect is now a one-line call. Unit tests cover the
branches (synced→null; pre-sync disconnected→offline / else connecting;
!editable/!edit→read-only).

F2: DictationGroup gated the mic on the non-reactive editor.isEditable while the
PR already publishes the reactive dictationAvailability.isEditable (same signals)
— so gate and reason came from different sources and the mic could stick. Gate on
dictationAvailability.isEditable: one reactive source of truth for both.

vitest (editor-sync-state + dictation): 37 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-03 19:25:02 +03:00
parent a86e5f409f
commit d57392b5af
5 changed files with 125 additions and 66 deletions
@@ -1,36 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { useEffect, useState } from "react";
import { render, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
// Regression test for #311: on a page the user can edit, the byline mic stayed
// stuck disabled until an unrelated re-render happened, because DictationGroup
// read the non-reactive field `editor.isEditable` directly. The fix reads it via
// `useEditorState`, which subscribes to the editor's own events.
//
// The mock below mirrors the real `useEditorState` contract: it runs the
// selector, and re-runs it (re-rendering the consumer) whenever the editor emits
// an event. This is what makes the test faithful — with the pre-fix code
// (`disabled={!editor.isEditable}`) DictationGroup never subscribes, so emitting
// an event would NOT re-render and the mic would stay disabled.
vi.mock("@tiptap/react", () => ({
useEditorState: ({ editor, selector }: any) => {
const [value, setValue] = useState(() => selector({ editor }));
useEffect(() => {
const handler = () => setValue(selector({ editor }));
editor.on("update", handler);
return () => editor.off("update", handler);
}, [editor, selector]);
return value;
},
}));
// The mic only cares about the workspace's streaming flag; return a stable stub.
vi.mock("jotai", () => ({
useAtomValue: () => ({ settings: { ai: { dictationStreaming: false } } }),
}));
vi.mock("@/features/user/atoms/current-user-atom.ts", () => ({
workspaceAtom: {},
}));
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
// page the user can edit, the mic must un-grey once the body becomes editable.
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
// publishes (carrying both the editable gate AND the unavailable reason). The mic
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
// re-render the group and flip the disabled state (jotai drives the subscription).
// Detectable stand-in that surfaces the `disabled` prop the component computes.
vi.mock("@/features/dictation/components/mic-button", () => ({
@@ -41,33 +20,39 @@ vi.mock("@/features/dictation/components/mic-button", () => ({
import { DictationGroup } from "./dictation-group";
// Minimal editor stand-in: a mutable `isEditable` field plus a tiny event
// emitter, matching the surface DictationGroup + the mocked useEditorState use.
function makeFakeEditor(isEditable: boolean) {
const listeners = new Set<() => void>();
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
// handleText). The disabled gate no longer reads this — it reads the atom.
function makeFakeEditor() {
return {
isEditable,
isEditable: false,
isDestroyed: false,
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
on: (_event: string, cb: () => void) => listeners.add(cb),
off: (_event: string, cb: () => void) => listeners.delete(cb),
emit: () => listeners.forEach((cb) => cb()),
} as any;
}
describe("DictationGroup editable reactivity (#311)", () => {
it("re-enables the mic when the editor flips isEditable false -> true", () => {
const editor = makeFakeEditor(false);
const { getByTestId } = render(<DictationGroup editor={editor} />);
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
const editor = makeFakeEditor();
const store = createStore();
// Pre-sync: page editor publishes not-editable (with a reason).
store.set(dictationAvailabilityAtom, {
isEditable: false,
reason: "connecting",
});
// Pre-sync: not editable yet, so the mic is disabled (preserves #218 intent).
const { getByTestId } = render(
<Provider store={store}>
<DictationGroup editor={editor} />
</Provider>,
);
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
// Collab sync flips the editor editable via editor.setEditable(true), which
// mutates the field and emits — the mic must react and enable itself.
// Collab sync -> page editor republishes editable; the atom change must
// re-render the group and enable the mic.
act(() => {
editor.isEditable = true;
editor.emit();
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
});
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
@@ -83,7 +83,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
streaming={streamingDictation}
onStart={handleStart}
onText={handleText}
disabled={!editor.isEditable}
disabled={!dictationAvailability.isEditable}
unavailableReason={dictationAvailability.reason ?? undefined}
color={color}
iconSize={iconSize}