1import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
2import { act, StrictMode, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6import type { ScopedSettingsPatch } from "./scopedSettings";
7
8type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
9const state = vi.hoisted(() => ({
10 styles: [] as WritingStyle[],
11 updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(),
12}));
13
14vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
15vi.mock("./useScopedSettings", () => ({
16 useScopedSettings: () => ({
17 ...DEFAULT_UNIFIED_SETTINGS,
18 sourceControlWritingStyle: state.styles[0],
19 }),
20 // Review guides stay off and agreed here, so their rows do not render beside the style's.
21 useScopedSettingsMixed: (keys: ReadonlyArray<string>) =>
22 !keys.includes("reviewGuidesEnabled") &&
23 JSON.stringify(state.styles[0]) !== JSON.stringify(state.styles[1]),
24 useUpdateScopedSettings: () => state.updateSettings,
25}));
26vi.mock("./SettingsScopeContext", () => ({
27 useSettingsScope: () => ({
28 scope: { kind: "all", environmentIds: [] },
29 environment: null,
30 connectedEnvironments: [],
31 targets: state.styles.map((style) => ({
32 settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style },
33 })),
34 }),
35}));
36vi.mock("./useScopedModelAvailability", () => ({
37 useScopedModelDisabledReason: () => () => null,
38}));
39vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
40vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] }));
41vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: () => null }));
42vi.mock("./settingsSearch", () => ({ searchableSetting: (id: string) => ({ id, title: id }) }));
43vi.mock("./settingsLayout", () => ({
44 SETTINGS_PICKER_TRIGGER_CLASSNAME: "",
45 SettingResetButton: ({ label, onClick }: { label: string; onClick: () => void }) => (
46 <button onClick={onClick}>{`Reset ${label}`}</button>
47 ),
48 SettingsSection: ({ children }: { children: ReactNode }) => children,
49 SettingsRow: ({
50 children,
51 control,
52 resetAction,
53 }: {
54 children: ReactNode;
55 control: ReactNode;
56 resetAction: ReactNode;
57 }) => (
58 <div>
59 {control}
60 {resetAction}
61 {children}
62 </div>
63 ),
64}));
65vi.mock("../ui/select", () => ({
66 Select: ({ children }: { children: ReactNode }) => children,
67 SelectItem: "span",
68 SelectPopup: "div",
69 SelectTrigger: "div",
70 SelectValue: "span",
71}));
72vi.mock("../ui/switch", () => ({ Switch: "input" }));
73vi.mock("../ui/textarea", () => ({ Textarea: "textarea" }));
74vi.mock("../ui/button", () => ({ Button: "button" }));
75
76import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
77
78let renderer: ReactTestRenderer | null;
79
80function button(label: string) {
81 return renderer!.root.findAllByType("button").find((item) => item.children.includes(label))!;
82}
83
84function openEditor() {
85 act(() => button("Write custom instructions for all").props.onClick());
86}
87
88function editInstructions(value: string) {
89 act(() => renderer!.root.findByType("textarea").props.onChange({ target: { value } }));
90}
91
92function applyInstructions() {
93 act(() => button("Apply instructions to all").props.onClick());
94}
95
96beforeEach(() => {
97 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
98 state.styles = [
99 {
100 ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
101 mode: "custom",
102 customInstructions: "First environment instructions",
103 followChangeRequestTemplates: true,
104 },
105 {
106 ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
107 mode: "custom",
108 customInstructions: "Second environment instructions",
109 followChangeRequestTemplates: false,
110 },
111 ];
112 state.updateSettings.mockReset().mockImplementation((patch) => {
113 state.styles = state.styles.map((style) => ({ ...style, ...patch.sourceControlWritingStyle }));
114 });
115 act(() => {
116 renderer = create(
117 <StrictMode>
118 <SourceControlWritingSettingsSection />
119 </StrictMode>,
120 );
121 });
122});
123
124afterEach(async () => {
125 await act(async () => renderer?.unmount());
126 renderer = null;
127 vi.unstubAllGlobals();
128});
129
130describe("mixed source control instructions", () => {
131 it("resets mixed template preferences without replacing each environment's instructions", () => {
132 const initialInstructions = state.styles.map(({ mode, customInstructions }) => ({
133 mode,
134 customInstructions,
135 }));
136
137 act(() => button("Reset change request templates").props.onClick());
138
139 expect(state.updateSettings).toHaveBeenCalledTimes(1);
140 expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([
141 DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates,
142 DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.followChangeRequestTemplates,
143 ]);
144 expect(
145 state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })),
146 ).toEqual(initialInstructions);
147 });
148
149 it("resets every environment even when the representative already has default instructions", () => {
150 state.styles[0] = { ...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle };
151 act(() => {
152 renderer!.update(
153 <StrictMode>
154 <SourceControlWritingSettingsSection />
155 </StrictMode>,
156 );
157 });
158
159 act(() => button("Reset source control writing style").props.onClick());
160
161 expect(state.updateSettings).toHaveBeenCalledTimes(1);
162 expect(
163 state.styles.map(({ mode, customInstructions }) => ({ mode, customInstructions })),
164 ).toEqual(
165 [0, 1].map(() => ({
166 mode: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.mode,
167 customInstructions: DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle.customInstructions,
168 })),
169 );
170 expect(state.styles[1]!.followChangeRequestTemplates).toBe(false);
171 });
172
173 it("does not write an untouched bulk draft", () => {
174 const initialStyles = state.styles;
175 openEditor();
176 expect(renderer!.root.findByType("textarea").props.value).toBe("");
177 expect(button("Apply instructions to all").props.disabled).toBe(true);
178
179 applyInstructions();
180 expect(state.updateSettings).not.toHaveBeenCalled();
181 expect(state.styles).toEqual(initialStyles);
182 });
183
184 it("applies edited instructions to every selected environment", () => {
185 openEditor();
186 editInstructions(" Keep titles concise. ");
187 expect(button("Apply instructions to all").props.disabled).toBe(false);
188 applyInstructions();
189
190 expect(state.updateSettings).toHaveBeenCalledTimes(1);
191 expect(state.styles.map((style) => style.customInstructions)).toEqual([
192 "Keep titles concise.",
193 "Keep titles concise.",
194 ]);
195 expect(state.styles.map((style) => style.followChangeRequestTemplates)).toEqual([true, false]);
196 });
197
198 it("allows an intentional clear after editing", () => {
199 openEditor();
200 editInstructions("Temporary instructions");
201 editInstructions("");
202 expect(button("Apply instructions to all").props.disabled).toBe(false);
203 applyInstructions();
204
205 expect(state.updateSettings).toHaveBeenCalledTimes(1);
206 expect(state.styles.map((style) => style.customInstructions)).toEqual(["", ""]);
207 });
208
209 it("shows the plain editor once instructions agree, even while templates differ", () => {
210 openEditor();
211 editInstructions("Shared instructions");
212 applyInstructions();
213
214 expect(state.updateSettings).toHaveBeenCalledTimes(1);
215 expect(state.styles.map((style) => style.customInstructions)).toEqual([
216 "Shared instructions",
217 "Shared instructions",
218 ]);
219 // Template preferences still differ, but that is the templates row's
220 // concern: the instructions editor is no longer a bulk draft.
221 expect(button("Write custom instructions for all")).toBeUndefined();
222 expect(renderer!.root.findByType("textarea").props.defaultValue).toBe("Shared instructions");
223 });
224});