@multiplatform.one/frappe-ui 7.6.2 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/dist/cjs/ChartCard.cjs +52 -0
  2. package/dist/cjs/ChartCard.native.js +72 -0
  3. package/dist/cjs/ChartCard.native.js.map +1 -0
  4. package/dist/cjs/FrappeChartCard.cjs +3 -3
  5. package/dist/cjs/FrappeChartCard.native.js +3 -3
  6. package/dist/cjs/FrappeChartCard.native.js.map +1 -1
  7. package/dist/cjs/FrappeDashboard.cjs +6 -27
  8. package/dist/cjs/FrappeDashboard.native.js +6 -33
  9. package/dist/cjs/FrappeDashboard.native.js.map +1 -1
  10. package/dist/cjs/fields/CodeEditor/index.cjs +4 -0
  11. package/dist/cjs/fields/CodeEditor/index.native.js +4 -0
  12. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  13. package/dist/cjs/formEngine/createPrefill.cjs +37 -0
  14. package/dist/cjs/formEngine/createPrefill.native.js +40 -0
  15. package/dist/cjs/formEngine/createPrefill.native.js.map +1 -0
  16. package/dist/cjs/formEngine/dirtyTracking.cjs +55 -0
  17. package/dist/cjs/formEngine/dirtyTracking.native.js +77 -0
  18. package/dist/cjs/formEngine/dirtyTracking.native.js.map +1 -0
  19. package/dist/cjs/formEngine/doctypeMeta.cjs +18 -0
  20. package/dist/cjs/formEngine/doctypeMeta.native.js +21 -0
  21. package/dist/cjs/formEngine/doctypeMeta.native.js.map +1 -0
  22. package/dist/cjs/formEngine/fetchFrom.cjs +67 -0
  23. package/dist/cjs/formEngine/fetchFrom.native.js +110 -0
  24. package/dist/cjs/formEngine/fetchFrom.native.js.map +1 -0
  25. package/dist/cjs/formEngine/fieldPermissions.cjs +55 -0
  26. package/dist/cjs/formEngine/fieldPermissions.native.js +80 -0
  27. package/dist/cjs/formEngine/fieldPermissions.native.js.map +1 -0
  28. package/dist/cjs/formEngine/formActions.cjs +189 -0
  29. package/dist/cjs/formEngine/formActions.native.js +199 -0
  30. package/dist/cjs/formEngine/formActions.native.js.map +1 -0
  31. package/dist/cjs/formEngine/formLayout.cjs +245 -0
  32. package/dist/cjs/formEngine/formLayout.native.js +388 -0
  33. package/dist/cjs/formEngine/formLayout.native.js.map +1 -0
  34. package/dist/cjs/formEngine/index.cjs +48 -0
  35. package/dist/cjs/formEngine/index.native.js +51 -0
  36. package/dist/cjs/formEngine/index.native.js.map +1 -0
  37. package/dist/cjs/formEngine/useFormEngine.cjs +387 -0
  38. package/dist/cjs/formEngine/useFormEngine.native.js +476 -0
  39. package/dist/cjs/formEngine/useFormEngine.native.js.map +1 -0
  40. package/dist/cjs/index.cjs +19 -1
  41. package/dist/cjs/index.native.js +19 -1
  42. package/dist/cjs/index.native.js.map +1 -1
  43. package/dist/esm/ChartCard.mjs +27 -0
  44. package/dist/esm/ChartCard.mjs.map +1 -0
  45. package/dist/esm/ChartCard.native.js +33 -0
  46. package/dist/esm/ChartCard.native.js.map +1 -0
  47. package/dist/esm/FrappeChartCard.mjs +1 -1
  48. package/dist/esm/FrappeChartCard.native.js +1 -1
  49. package/dist/esm/FrappeChartCard.native.js.map +1 -1
  50. package/dist/esm/FrappeDashboard.mjs +6 -27
  51. package/dist/esm/FrappeDashboard.mjs.map +1 -1
  52. package/dist/esm/FrappeDashboard.native.js +6 -33
  53. package/dist/esm/FrappeDashboard.native.js.map +1 -1
  54. package/dist/esm/fields/CodeEditor/index.mjs +4 -0
  55. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  56. package/dist/esm/fields/CodeEditor/index.native.js +4 -0
  57. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  58. package/dist/esm/formEngine/createPrefill.mjs +11 -0
  59. package/dist/esm/formEngine/createPrefill.mjs.map +1 -0
  60. package/dist/esm/formEngine/createPrefill.native.js +11 -0
  61. package/dist/esm/formEngine/createPrefill.native.js.map +1 -0
  62. package/dist/esm/formEngine/dirtyTracking.mjs +28 -0
  63. package/dist/esm/formEngine/dirtyTracking.mjs.map +1 -0
  64. package/dist/esm/formEngine/dirtyTracking.native.js +47 -0
  65. package/dist/esm/formEngine/dirtyTracking.native.js.map +1 -0
  66. package/dist/esm/formEngine/doctypeMeta.mjs +2 -0
  67. package/dist/esm/formEngine/doctypeMeta.mjs.map +1 -0
  68. package/dist/esm/formEngine/doctypeMeta.native.js +2 -0
  69. package/dist/esm/formEngine/doctypeMeta.native.js.map +1 -0
  70. package/dist/esm/formEngine/fetchFrom.mjs +40 -0
  71. package/dist/esm/formEngine/fetchFrom.mjs.map +1 -0
  72. package/dist/esm/formEngine/fetchFrom.native.js +80 -0
  73. package/dist/esm/formEngine/fetchFrom.native.js.map +1 -0
  74. package/dist/esm/formEngine/fieldPermissions.mjs +28 -0
  75. package/dist/esm/formEngine/fieldPermissions.mjs.map +1 -0
  76. package/dist/esm/formEngine/fieldPermissions.native.js +50 -0
  77. package/dist/esm/formEngine/fieldPermissions.native.js.map +1 -0
  78. package/dist/esm/formEngine/formActions.mjs +146 -0
  79. package/dist/esm/formEngine/formActions.mjs.map +1 -0
  80. package/dist/esm/formEngine/formActions.native.js +153 -0
  81. package/dist/esm/formEngine/formActions.native.js.map +1 -0
  82. package/dist/esm/formEngine/formLayout.mjs +217 -0
  83. package/dist/esm/formEngine/formLayout.mjs.map +1 -0
  84. package/dist/esm/formEngine/formLayout.native.js +357 -0
  85. package/dist/esm/formEngine/formLayout.native.js.map +1 -0
  86. package/dist/esm/formEngine/index.mjs +8 -0
  87. package/dist/esm/formEngine/index.mjs.map +1 -0
  88. package/dist/esm/formEngine/index.native.js +8 -0
  89. package/dist/esm/formEngine/index.native.js.map +1 -0
  90. package/dist/esm/formEngine/useFormEngine.mjs +362 -0
  91. package/dist/esm/formEngine/useFormEngine.mjs.map +1 -0
  92. package/dist/esm/formEngine/useFormEngine.native.js +448 -0
  93. package/dist/esm/formEngine/useFormEngine.native.js.map +1 -0
  94. package/dist/esm/index.mjs +4 -2
  95. package/dist/esm/index.mjs.map +1 -1
  96. package/dist/esm/index.native.js +4 -2
  97. package/dist/esm/index.native.js.map +1 -1
  98. package/dist/jsx/ChartCard.mjs +27 -0
  99. package/dist/jsx/ChartCard.mjs.map +1 -0
  100. package/dist/jsx/ChartCard.native.js +72 -0
  101. package/dist/jsx/ChartCard.native.js.map +1 -0
  102. package/dist/jsx/FrappeChartCard.mjs +1 -1
  103. package/dist/jsx/FrappeChartCard.native.js +3 -3
  104. package/dist/jsx/FrappeChartCard.native.js.map +1 -1
  105. package/dist/jsx/FrappeDashboard.mjs +6 -27
  106. package/dist/jsx/FrappeDashboard.mjs.map +1 -1
  107. package/dist/jsx/FrappeDashboard.native.js +6 -33
  108. package/dist/jsx/FrappeDashboard.native.js.map +1 -1
  109. package/dist/jsx/fields/CodeEditor/index.mjs +4 -0
  110. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  111. package/dist/jsx/fields/CodeEditor/index.native.js +4 -0
  112. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  113. package/dist/jsx/formEngine/createPrefill.mjs +11 -0
  114. package/dist/jsx/formEngine/createPrefill.mjs.map +1 -0
  115. package/dist/jsx/formEngine/createPrefill.native.js +40 -0
  116. package/dist/jsx/formEngine/createPrefill.native.js.map +1 -0
  117. package/dist/jsx/formEngine/dirtyTracking.mjs +28 -0
  118. package/dist/jsx/formEngine/dirtyTracking.mjs.map +1 -0
  119. package/dist/jsx/formEngine/dirtyTracking.native.js +77 -0
  120. package/dist/jsx/formEngine/dirtyTracking.native.js.map +1 -0
  121. package/dist/jsx/formEngine/doctypeMeta.mjs +2 -0
  122. package/dist/jsx/formEngine/doctypeMeta.mjs.map +1 -0
  123. package/dist/jsx/formEngine/doctypeMeta.native.js +21 -0
  124. package/dist/jsx/formEngine/doctypeMeta.native.js.map +1 -0
  125. package/dist/jsx/formEngine/fetchFrom.mjs +40 -0
  126. package/dist/jsx/formEngine/fetchFrom.mjs.map +1 -0
  127. package/dist/jsx/formEngine/fetchFrom.native.js +110 -0
  128. package/dist/jsx/formEngine/fetchFrom.native.js.map +1 -0
  129. package/dist/jsx/formEngine/fieldPermissions.mjs +28 -0
  130. package/dist/jsx/formEngine/fieldPermissions.mjs.map +1 -0
  131. package/dist/jsx/formEngine/fieldPermissions.native.js +80 -0
  132. package/dist/jsx/formEngine/fieldPermissions.native.js.map +1 -0
  133. package/dist/jsx/formEngine/formActions.mjs +146 -0
  134. package/dist/jsx/formEngine/formActions.mjs.map +1 -0
  135. package/dist/jsx/formEngine/formActions.native.js +199 -0
  136. package/dist/jsx/formEngine/formActions.native.js.map +1 -0
  137. package/dist/jsx/formEngine/formLayout.mjs +217 -0
  138. package/dist/jsx/formEngine/formLayout.mjs.map +1 -0
  139. package/dist/jsx/formEngine/formLayout.native.js +388 -0
  140. package/dist/jsx/formEngine/formLayout.native.js.map +1 -0
  141. package/dist/jsx/formEngine/index.mjs +8 -0
  142. package/dist/jsx/formEngine/index.mjs.map +1 -0
  143. package/dist/jsx/formEngine/index.native.js +51 -0
  144. package/dist/jsx/formEngine/index.native.js.map +1 -0
  145. package/dist/jsx/formEngine/useFormEngine.mjs +362 -0
  146. package/dist/jsx/formEngine/useFormEngine.mjs.map +1 -0
  147. package/dist/jsx/formEngine/useFormEngine.native.js +476 -0
  148. package/dist/jsx/formEngine/useFormEngine.native.js.map +1 -0
  149. package/dist/jsx/index.js +4 -2
  150. package/dist/jsx/index.js.map +1 -1
  151. package/dist/jsx/index.mjs +4 -2
  152. package/dist/jsx/index.mjs.map +1 -1
  153. package/dist/jsx/index.native.js +19 -1
  154. package/dist/jsx/index.native.js.map +1 -1
  155. package/package.json +23 -17
  156. package/src/ChartCard.tsx +31 -0
  157. package/src/FileBrowser/FileBrowser.spec.tsx +16 -0
  158. package/src/FileBrowser/FileBrowser.stories.tsx +18 -1
  159. package/src/FileBrowser/folderTree.spec.tsx +68 -0
  160. package/src/FrappeCalendar.stories.tsx +1 -2
  161. package/src/FrappeChartCard.stories.tsx +1 -1
  162. package/src/FrappeChartCard.tsx +1 -1
  163. package/src/FrappeDashboard.stories.tsx +6 -5
  164. package/src/FrappeDashboard.tsx +6 -33
  165. package/src/FrappeGantt.stories.tsx +1 -2
  166. package/src/FrappeList.stories.tsx +3 -10
  167. package/src/components/attachments.stories.tsx +9 -2
  168. package/src/dependsOn.ts +2 -2
  169. package/src/fields/CodeEditor/CodeEditor.spec.tsx +37 -0
  170. package/src/fields/CodeEditor/CodeEditor.stories.tsx +83 -66
  171. package/src/fields/CodeEditor/index.tsx +4 -0
  172. package/src/fields/DateRange/DateRange.spec.tsx +143 -0
  173. package/src/fields/FileUpload.spec.tsx +334 -0
  174. package/src/fields/FileUploadField/FileUploadField.spec.tsx +166 -0
  175. package/src/fields/HTML/Html.spec.tsx +179 -0
  176. package/src/fields/Icon/Icon.stories.tsx +138 -0
  177. package/src/fields/Link.stories.tsx +2 -0
  178. package/src/fields/LongText/LongText.spec.tsx +217 -0
  179. package/src/fields/OTPInput/OTPInput.spec.tsx +167 -0
  180. package/src/fields/Password/Password.stories.tsx +21 -0
  181. package/src/fields/Phone/Phone.spec.tsx +151 -0
  182. package/src/fields/ReadOnly/ReadOnly.spec.tsx +229 -0
  183. package/src/fields/SmallText/SmallText.spec.tsx +175 -0
  184. package/src/fields/shared/EditorPreviewPanes.spec.tsx +122 -0
  185. package/src/formEngine/createPrefill.ts +28 -0
  186. package/src/formEngine/dirtyTracking.spec.ts +91 -0
  187. package/src/formEngine/dirtyTracking.ts +64 -0
  188. package/src/formEngine/doctypeMeta.ts +117 -0
  189. package/src/formEngine/fetchFrom.spec.ts +92 -0
  190. package/src/formEngine/fetchFrom.ts +111 -0
  191. package/src/formEngine/fieldPermissions.spec.ts +61 -0
  192. package/src/formEngine/fieldPermissions.ts +68 -0
  193. package/src/formEngine/formActions.ts +285 -0
  194. package/src/formEngine/formLayout.spec.ts +373 -0
  195. package/src/formEngine/formLayout.ts +392 -0
  196. package/src/formEngine/index.ts +33 -0
  197. package/src/formEngine/seam.spec.ts +148 -0
  198. package/src/formEngine/useFormEngine.ts +496 -0
  199. package/src/frappeRemainder.designLaw.spec.ts +9 -0
  200. package/src/frappeViews.designLaw.spec.ts +1 -0
  201. package/src/index.spec.ts +7 -3
  202. package/src/index.ts +51 -1
  203. package/src/schemaForm/SchemaForm.spec.tsx +4 -1
  204. package/types/ChartCard.d.ts +13 -0
  205. package/types/ChartCard.d.ts.map +1 -0
  206. package/types/FrappeDashboard.d.ts +2 -10
  207. package/types/FrappeDashboard.d.ts.map +1 -1
  208. package/types/dependsOn.d.ts +2 -2
  209. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  210. package/types/formEngine/createPrefill.d.ts +19 -0
  211. package/types/formEngine/createPrefill.d.ts.map +1 -0
  212. package/types/formEngine/dirtyTracking.d.ts +36 -0
  213. package/types/formEngine/dirtyTracking.d.ts.map +1 -0
  214. package/types/formEngine/doctypeMeta.d.ts +117 -0
  215. package/types/formEngine/doctypeMeta.d.ts.map +1 -0
  216. package/types/formEngine/fetchFrom.d.ts +43 -0
  217. package/types/formEngine/fetchFrom.d.ts.map +1 -0
  218. package/types/formEngine/fieldPermissions.d.ts +38 -0
  219. package/types/formEngine/fieldPermissions.d.ts.map +1 -0
  220. package/types/formEngine/formActions.d.ts +95 -0
  221. package/types/formEngine/formActions.d.ts.map +1 -0
  222. package/types/formEngine/formLayout.d.ts +91 -0
  223. package/types/formEngine/formLayout.d.ts.map +1 -0
  224. package/types/formEngine/index.d.ts +27 -0
  225. package/types/formEngine/index.d.ts.map +1 -0
  226. package/types/formEngine/useFormEngine.d.ts +61 -0
  227. package/types/formEngine/useFormEngine.d.ts.map +1 -0
  228. package/types/index.d.ts +4 -1
  229. package/types/index.d.ts.map +1 -1
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Phone contract specs. The adapter is thin by design — compose the forms
3
+ * PhoneInput, emit canonical onChange — so what has to be pinned is that every
4
+ * prop the Frappe layer sets actually ARRIVES: the country the number is
5
+ * parsed against (`defaultCountry`, fed from df.options by the registry), the
6
+ * controlled value, and the read-only / disabled gates.
7
+ *
8
+ * A dropped `defaultCountry` is the failure that matters here: the same digits
9
+ * serialise to a different E.164 number under a different region, so a silent
10
+ * default of US would corrupt every non-US phone field on save.
11
+ */
12
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
13
+ import { act, cleanup, fireEvent } from "@testing-library/react";
14
+ import type React from "react";
15
+ import { afterEach, describe, expect, it, vi } from "vitest";
16
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
17
+ import { Phone } from ".";
18
+
19
+ afterEach(cleanup);
20
+
21
+ const phoneInput = (container: HTMLElement) =>
22
+ container.querySelector('[data-testid="phone-input"]') as HTMLInputElement | null;
23
+
24
+ describe("Phone composes the forms PhoneInput", () => {
25
+ it("renders the composite phone control, not a bare Input", () => {
26
+ const result = renderWithProviders(<Phone label="Mobile" value="" onChange={() => {}} />);
27
+ expect(phoneInput(result.container)).toBeTruthy();
28
+ expect(result.container.querySelector('[data-testid="phone-box"]')).toBeTruthy();
29
+ });
30
+
31
+ it("shows the national form of a controlled E.164 value", () => {
32
+ const result = renderWithProviders(
33
+ <Phone label="Mobile" value="+14155550123" onChange={() => {}} />,
34
+ );
35
+ const input = phoneInput(result.container);
36
+ expect(input?.value).toContain("415");
37
+ expect(input?.value).toContain("555");
38
+ });
39
+
40
+ it("emits a serialised number through canonical onChange", async () => {
41
+ const onChange = vi.fn();
42
+ const result = renderWithProviders(<Phone label="Mobile" value="" onChange={onChange} />);
43
+ const input = phoneInput(result.container) as HTMLInputElement;
44
+ await act(async () => {
45
+ fireEvent.change(input, { target: { value: "4155550123" } });
46
+ });
47
+ expect(onChange).toHaveBeenCalled();
48
+ const emitted = onChange.mock.calls.at(-1)?.[0] as string;
49
+ expect(emitted.startsWith("+")).toBe(true);
50
+ expect(emitted).toContain("4155550123");
51
+ });
52
+ });
53
+
54
+ describe("Phone defaultCountry", () => {
55
+ it("defaults to the +1 region when the field declares no country", () => {
56
+ const result = renderWithProviders(<Phone label="Mobile" value="" onChange={() => {}} />);
57
+ expect(result.container.textContent).toContain("+1");
58
+ });
59
+
60
+ it("honours defaultCountry, so the dial code follows the DocField", () => {
61
+ const result = renderWithProviders(
62
+ <Phone label="Mobile" value="" defaultCountry="GB" onChange={() => {}} />,
63
+ );
64
+ expect(result.container.textContent).toContain("+44");
65
+ });
66
+
67
+ it("parses the same digits under the declared region", async () => {
68
+ const onChange = vi.fn();
69
+ const result = renderWithProviders(
70
+ <Phone label="Mobile" value="" defaultCountry="IN" onChange={onChange} />,
71
+ );
72
+ await act(async () => {
73
+ fireEvent.change(phoneInput(result.container) as HTMLInputElement, {
74
+ target: { value: "8074052136" },
75
+ });
76
+ });
77
+ const emitted = onChange.mock.calls.at(-1)?.[0] as string;
78
+ expect(emitted.startsWith("+91")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("Phone gates", () => {
83
+ it("readOnly marks the control read-only", () => {
84
+ const result = renderWithProviders(
85
+ <Phone label="Mobile" value="+14155550123" readOnly onChange={() => {}} />,
86
+ );
87
+ expect(phoneInput(result.container)?.hasAttribute("readonly")).toBe(true);
88
+ });
89
+
90
+ it("skeleton branch renders no phone control", () => {
91
+ const result = renderWithProviders(
92
+ <Phone label="Mobile" value="+14155550123" skeleton onChange={() => {}} />,
93
+ );
94
+ expect(phoneInput(result.container)).toBeNull();
95
+ });
96
+
97
+ it("shows the label, helperText and error", () => {
98
+ const result = renderWithProviders(
99
+ <Phone
100
+ label="Mobile"
101
+ value=""
102
+ helperText="include the country code"
103
+ error="not a number"
104
+ onChange={() => {}}
105
+ />,
106
+ );
107
+ expect(result.container.textContent).toContain("Mobile");
108
+ expect(result.container.textContent).toContain("not a number");
109
+ });
110
+ });
111
+
112
+ describe("registry resolution (Phone fieldtype)", () => {
113
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
114
+ return { value: "", onChange: () => {}, ...overrides };
115
+ }
116
+
117
+ it("resolves 'Phone' to the Phone adapter", () => {
118
+ const Component = getFieldComponent("Phone")!;
119
+ expect(Component).toBeTruthy();
120
+ const element = (Component as Function)(
121
+ propsFor({ value: "+14155550123", label: "Mobile" }),
122
+ ) as React.ReactElement<Record<string, any>>;
123
+ expect(element.type).toBe(Phone);
124
+ expect(element.props.value).toBe("+14155550123");
125
+ expect(element.props.label).toBe("Mobile");
126
+ });
127
+
128
+ it("takes defaultCountry from df.options — this is the DocField's own region", () => {
129
+ const Component = getFieldComponent("Phone")!;
130
+ const element = (Component as Function)(
131
+ propsFor({ fieldMeta: { options: "IN" } } as Partial<FieldRenderProps>),
132
+ ) as React.ReactElement<Record<string, any>>;
133
+ expect(element.props.defaultCountry).toBe("IN");
134
+ });
135
+
136
+ it("leaves defaultCountry unset when df.options is empty, so PhoneInput owns the default", () => {
137
+ const Component = getFieldComponent("Phone")!;
138
+ const element = (Component as Function)(
139
+ propsFor({ fieldMeta: { options: "" } } as Partial<FieldRenderProps>),
140
+ ) as React.ReactElement<Record<string, any>>;
141
+ expect(element.props.defaultCountry).toBeUndefined();
142
+ });
143
+
144
+ it("substitutes an empty string for a null value", () => {
145
+ const Component = getFieldComponent("Phone")!;
146
+ const element = (Component as Function)(
147
+ propsFor({ value: null as unknown as string }),
148
+ ) as React.ReactElement<Record<string, any>>;
149
+ expect(element.props.value).toBe("");
150
+ });
151
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * ReadOnly field contract specs. This is the display half of every fieldtype:
3
+ * SchemaForm swaps a live control for ReadOnly whenever a DocField is
4
+ * read-only, so what it prints IS the value the user sees. The contract is the
5
+ * formatting matrix (one per ReadOnlyType), the empty fallback, and the two
6
+ * types that render more than text — Color (swatch) and Link (pressable).
7
+ *
8
+ * Formatted output is asserted against the shared registers
9
+ * (formatNumericText / formatCurrency / formatPercent / formatDateText /
10
+ * formatTimeOfDay) rather than against hardcoded strings: the contract is
11
+ * "ReadOnly speaks the house register", not "ReadOnly speaks en-GB".
12
+ */
13
+ import { formatDateText, formatNumericText } from "@multiplatform.one/forms";
14
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
15
+ import { formatCurrency, formatPercent, formatTimeOfDay } from "@multiplatform.one/theme";
16
+ import { cleanup, fireEvent, screen } from "@testing-library/react";
17
+ import type React from "react";
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
20
+ import { ReadOnly } from ".";
21
+
22
+ afterEach(cleanup);
23
+
24
+ describe("ReadOnly formatting matrix", () => {
25
+ it("Data prints the value verbatim", () => {
26
+ renderWithProviders(<ReadOnly label="Title" type="Data" value="Sales Invoice" />);
27
+ expect(screen.getByText("Sales Invoice")).toBeTruthy();
28
+ });
29
+
30
+ it("Check prints Yes / No, never true / false", () => {
31
+ const yes = renderWithProviders(<ReadOnly label="Paid" type="Check" value={true} />);
32
+ expect(yes.container.textContent).toContain("Yes");
33
+ expect(yes.container.textContent).not.toContain("true");
34
+ cleanup();
35
+ const no = renderWithProviders(<ReadOnly label="Paid" type="Check" value={false} />);
36
+ expect(no.container.textContent).toContain("No");
37
+ });
38
+
39
+ it("Int rounds and drops the decimals", () => {
40
+ renderWithProviders(<ReadOnly label="Qty" type="Int" value={3.7} />);
41
+ expect(screen.getByText(formatNumericText(4, { precision: 0 }))).toBeTruthy();
42
+ });
43
+
44
+ it("Float honours precision", () => {
45
+ renderWithProviders(<ReadOnly label="Rate" type="Float" value={1.23456} precision={3} />);
46
+ expect(screen.getByText(formatNumericText(1.23456, { precision: 3 }))).toBeTruthy();
47
+ });
48
+
49
+ it("Currency goes through the money register with the given symbol", () => {
50
+ renderWithProviders(
51
+ <ReadOnly label="Total" type="Currency" value={1234.5} currencySymbol="$" precision={2} />,
52
+ );
53
+ expect(screen.getByText(formatCurrency(1234.5, { precision: 2, symbol: "$" }))).toBeTruthy();
54
+ });
55
+
56
+ it("Percent goes through the percent register", () => {
57
+ renderWithProviders(<ReadOnly label="Done" type="Percent" value={42.5} precision={1} />);
58
+ expect(screen.getByText(formatPercent(42.5, { precision: 1 }))).toBeTruthy();
59
+ });
60
+
61
+ it("Date goes through the date register", () => {
62
+ renderWithProviders(<ReadOnly label="Posting" type="Date" value="2026-03-04" />);
63
+ expect(screen.getByText(formatDateText("2026-03-04", { dateFormat: undefined }))).toBeTruthy();
64
+ });
65
+
66
+ it("Datetime carries the time half", () => {
67
+ const wire = "2026-03-04 14:30:00";
68
+ renderWithProviders(<ReadOnly label="Modified" type="Datetime" value={wire} />);
69
+ expect(
70
+ screen.getByText(formatDateText(wire, { withTime: true, dateFormat: undefined })),
71
+ ).toBeTruthy();
72
+ });
73
+
74
+ it("Time speaks the time-of-day register, not the raw wire string (LC-12)", () => {
75
+ renderWithProviders(<ReadOnly label="Start" type="Time" value="14:30:00" />);
76
+ expect(screen.getByText(formatTimeOfDay("14:30:00"))).toBeTruthy();
77
+ expect(screen.queryByText("14:30:00")).toBeNull();
78
+ });
79
+
80
+ it("Color uppercases the hex", () => {
81
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="#ab12ef" />);
82
+ expect(screen.getByText("#AB12EF")).toBeTruthy();
83
+ });
84
+ });
85
+
86
+ describe("ReadOnly empty handling", () => {
87
+ it.each([
88
+ ["undefined", undefined],
89
+ ["null", null],
90
+ ["empty string", ""],
91
+ ])("prints the default dash for %s", (_label, value) => {
92
+ const result = renderWithProviders(
93
+ <ReadOnly label="Ref" type="Data" value={value as string | null | undefined} />,
94
+ );
95
+ expect(result.container.textContent).toContain("-");
96
+ });
97
+
98
+ it("honours a custom emptyText", () => {
99
+ const result = renderWithProviders(
100
+ <ReadOnly label="Ref" type="Data" value={null} emptyText="Not set" />,
101
+ );
102
+ expect(result.container.textContent).toContain("Not set");
103
+ });
104
+
105
+ it("an empty Date is empty, not an epoch", () => {
106
+ const result = renderWithProviders(<ReadOnly label="Posting" type="Date" value="" />);
107
+ expect(result.container.textContent).toContain("-");
108
+ expect(result.container.textContent).not.toContain("1970");
109
+ });
110
+
111
+ it("a zero number is a value, not an empty", () => {
112
+ renderWithProviders(<ReadOnly label="Qty" type="Int" value={0} />);
113
+ expect(screen.getByText(formatNumericText(0, { precision: 0 }))).toBeTruthy();
114
+ });
115
+ });
116
+
117
+ describe("ReadOnly Color swatch", () => {
118
+ it("renders a swatch beside the text for a 6-digit hex", () => {
119
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="#FF0000" />);
120
+ const text = screen.getByText("#FF0000");
121
+ expect(text.parentElement?.childElementCount).toBe(2);
122
+ });
123
+
124
+ it("renders a swatch for a 3-digit hex too", () => {
125
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="#f00" />);
126
+ const text = screen.getByText("#F00");
127
+ expect(text.parentElement?.childElementCount).toBe(2);
128
+ });
129
+
130
+ it("refuses a swatch for a value that is not a hex colour", () => {
131
+ renderWithProviders(<ReadOnly label="Brand" type="Color" value="red" />);
132
+ const text = screen.getByText("RED");
133
+ expect(text.parentElement?.childElementCount).toBe(1);
134
+ });
135
+ });
136
+
137
+ describe("ReadOnly Link", () => {
138
+ it("calls onLinkPress when the value is pressed", () => {
139
+ const onLinkPress = vi.fn();
140
+ renderWithProviders(
141
+ <ReadOnly label="Customer" type="Link" value="CUST-0001" onLinkPress={onLinkPress} />,
142
+ );
143
+ fireEvent.click(screen.getByText("CUST-0001"));
144
+ expect(onLinkPress).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it("does not make an empty Link pressable", () => {
148
+ const onLinkPress = vi.fn();
149
+ const result = renderWithProviders(
150
+ <ReadOnly label="Customer" type="Link" value="" onLinkPress={onLinkPress} />,
151
+ );
152
+ fireEvent.click(result.getByText("-"));
153
+ expect(onLinkPress).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("a Link with neither href nor handler is plain text", () => {
157
+ const result = renderWithProviders(<ReadOnly label="Customer" type="Link" value="CUST-1" />);
158
+ expect(result.container.textContent).toContain("CUST-1");
159
+ });
160
+ });
161
+
162
+ describe("ReadOnly chrome", () => {
163
+ it("shows the label and helperText", () => {
164
+ const result = renderWithProviders(
165
+ <ReadOnly label="Status" type="Data" value="Draft" helperText="set by the workflow" />,
166
+ );
167
+ expect(result.container.textContent).toContain("Status");
168
+ expect(result.container.textContent).toContain("set by the workflow");
169
+ });
170
+
171
+ it("skeleton branch hides the value", () => {
172
+ const result = renderWithProviders(
173
+ <ReadOnly label="Status" type="Data" value="Draft" skeleton />,
174
+ );
175
+ expect(result.container.textContent).not.toContain("Draft");
176
+ });
177
+ });
178
+
179
+ describe("registry resolution (Read Only fieldtype)", () => {
180
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
181
+ return { value: "", onChange: () => {}, ...overrides };
182
+ }
183
+
184
+ it("resolves 'Read Only' to ReadOnly", () => {
185
+ const Component = getFieldComponent("Read Only")!;
186
+ expect(Component).toBeTruthy();
187
+ const element = (Component as Function)(
188
+ propsFor({ value: "Draft", label: "Status" }),
189
+ ) as React.ReactElement<Record<string, any>>;
190
+ expect(element.type).toBe(ReadOnly);
191
+ expect(element.props.value).toBe("Draft");
192
+ });
193
+
194
+ it("forwards the whole formatting contract, not just the value", () => {
195
+ const Component = getFieldComponent("Read Only")!;
196
+ const onLinkPress = () => {};
197
+ const element = (Component as Function)(
198
+ propsFor({
199
+ value: 12.5,
200
+ type: "Currency",
201
+ precision: 3,
202
+ currencySymbol: "€",
203
+ emptyText: "n/a",
204
+ dateFormat: "dd-MM-yyyy",
205
+ datetimeFormat: "dd-MM-yyyy HH:mm",
206
+ timeFormat: "HH:mm",
207
+ linkHref: "/app/x",
208
+ onLinkPress,
209
+ } as Partial<FieldRenderProps>),
210
+ ) as React.ReactElement<Record<string, any>>;
211
+ expect(element.props.type).toBe("Currency");
212
+ expect(element.props.precision).toBe(3);
213
+ expect(element.props.currencySymbol).toBe("€");
214
+ expect(element.props.emptyText).toBe("n/a");
215
+ expect(element.props.dateFormat).toBe("dd-MM-yyyy");
216
+ expect(element.props.datetimeFormat).toBe("dd-MM-yyyy HH:mm");
217
+ expect(element.props.timeFormat).toBe("HH:mm");
218
+ expect(element.props.linkHref).toBe("/app/x");
219
+ expect(element.props.onLinkPress).toBe(onLinkPress);
220
+ });
221
+
222
+ it("does not coerce a null value to an empty string — ReadOnly owns the empty text", () => {
223
+ const Component = getFieldComponent("Read Only")!;
224
+ const element = (Component as Function)(
225
+ propsFor({ value: null as unknown as string }),
226
+ ) as React.ReactElement<Record<string, any>>;
227
+ expect(element.props.value).toBeNull();
228
+ });
229
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * SmallText contract specs. Frappe's `Small Text` is the short free-text
3
+ * fieldtype, and the adapter's promise is that it is the SAME control as
4
+ * LongText with a smaller row budget (2..4 instead of 6..16) — not a second
5
+ * textarea implementation, and not an Input with a taller box.
6
+ */
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { cleanup, fireEvent } from "@testing-library/react";
9
+ import type React from "react";
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
12
+ import { LongText } from "../LongText";
13
+ import { SmallText } from ".";
14
+
15
+ afterEach(cleanup);
16
+
17
+ describe("SmallText composes the forms TextArea", () => {
18
+ it("renders a textarea, not a single-line input", () => {
19
+ const result = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
20
+ expect(result.getTextArea()).toBeTruthy();
21
+ expect(result.container.querySelector('input[type="text"]')).toBeNull();
22
+ });
23
+
24
+ it("shows the controlled value", () => {
25
+ const result = renderWithProviders(
26
+ <SmallText label="Note" value="short note" onChange={() => {}} />,
27
+ );
28
+ expect((result.getTextArea() as HTMLTextAreaElement).value).toBe("short note");
29
+ });
30
+
31
+ it("emits the text through canonical onChange", () => {
32
+ const onChange = vi.fn();
33
+ const result = renderWithProviders(<SmallText label="Note" value="" onChange={onChange} />);
34
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, { target: { value: "typed" } });
35
+ expect(onChange).toHaveBeenCalledWith("typed");
36
+ });
37
+
38
+ it("readOnly blocks emission", () => {
39
+ const onChange = vi.fn();
40
+ const result = renderWithProviders(
41
+ <SmallText label="Note" value="original" readOnly onChange={onChange} />,
42
+ );
43
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
44
+ expect(textarea.hasAttribute("readonly")).toBe(true);
45
+ fireEvent.change(textarea, { target: { value: "sneaky" } });
46
+ expect(onChange).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it("disabled blocks emission", () => {
50
+ const onChange = vi.fn();
51
+ const result = renderWithProviders(
52
+ <SmallText label="Note" value="original" disabled onChange={onChange} />,
53
+ );
54
+ fireEvent.change(result.getTextArea() as HTMLTextAreaElement, { target: { value: "sneaky" } });
55
+ expect(onChange).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("forwards maxLength — Frappe's df.length caps a Small Text", () => {
59
+ const result = renderWithProviders(
60
+ <SmallText label="Note" value="" maxLength={80} onChange={() => {}} />,
61
+ );
62
+ expect((result.getTextArea() as HTMLTextAreaElement).getAttribute("maxlength")).toBe("80");
63
+ });
64
+
65
+ it("forwards placeholder and textAreaProps", () => {
66
+ const result = renderWithProviders(
67
+ <SmallText
68
+ label="Note"
69
+ value=""
70
+ placeholder="One or two lines"
71
+ textAreaProps={{ tabIndex: -1 }}
72
+ onChange={() => {}}
73
+ />,
74
+ );
75
+ const textarea = result.getTextArea() as HTMLTextAreaElement;
76
+ expect(textarea.getAttribute("placeholder")).toBe("One or two lines");
77
+ expect(textarea.getAttribute("tabindex")).toBe("-1");
78
+ });
79
+ });
80
+
81
+ /**
82
+ * The row budget does not reach the DOM as the textarea's `rows` attribute:
83
+ * TextArea spreads `numberOfLines: minRows` onto the Tamagui TextArea and web
84
+ * drops it, so `rows` measured 3 for every adapter at every setting. The
85
+ * budget DOES drive the rendered minimum height, `getFieldHeight(size,
86
+ * minRows)`, so it is asserted through that. Reported on MPO-123.
87
+ */
88
+ const minHeightPxOf = (container: HTMLElement) => {
89
+ const cls = container.querySelector("textarea")?.getAttribute("class") ?? "";
90
+ const match = /(?:^|\s)_mih-(\d+)px(?:\s|$)/.exec(cls);
91
+ return match ? Number(match[1]) : undefined;
92
+ };
93
+
94
+ describe("SmallText row budget", () => {
95
+ it("opens at a two-row box", () => {
96
+ const small = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
97
+ const smallPx = minHeightPxOf(small.container);
98
+ cleanup();
99
+ // LongText's default budget is six rows to SmallText's two, on the same
100
+ // per-row height — so the ratio is the row count, with no hard-coded px.
101
+ const long = renderWithProviders(<LongText label="Note" value="" onChange={() => {}} />);
102
+ const longPx = minHeightPxOf(long.container);
103
+ expect(smallPx).toBeGreaterThan(0);
104
+ expect((longPx as number) / (smallPx as number)).toBe(3);
105
+ });
106
+
107
+ it("is shorter than LongText, which is the only thing that distinguishes them", () => {
108
+ const small = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
109
+ const smallPx = minHeightPxOf(small.container) as number;
110
+ cleanup();
111
+ const long = renderWithProviders(<LongText label="Note" value="" onChange={() => {}} />);
112
+ const longPx = minHeightPxOf(long.container) as number;
113
+ expect(smallPx).toBeLessThan(longPx);
114
+ });
115
+
116
+ it("ignores minHeight — a Small Text does not grow into a Long Text", () => {
117
+ const plain = renderWithProviders(<SmallText label="Note" value="" onChange={() => {}} />);
118
+ const plainPx = minHeightPxOf(plain.container);
119
+ cleanup();
120
+ const result = renderWithProviders(
121
+ <SmallText
122
+ label="Note"
123
+ value=""
124
+ onChange={() => {}}
125
+ {...({ minHeight: 480 } as Record<string, unknown>)}
126
+ />,
127
+ );
128
+ expect(minHeightPxOf(result.container)).toBe(plainPx);
129
+ });
130
+ });
131
+
132
+ describe("registry resolution (Small Text fieldtype)", () => {
133
+ function propsFor(overrides: Partial<FieldRenderProps> = {}): FieldRenderProps {
134
+ return { value: "", onChange: () => {}, ...overrides };
135
+ }
136
+
137
+ it("resolves 'Small Text' to SmallText, and not to the Long Text adapter", () => {
138
+ const Component = getFieldComponent("Small Text")!;
139
+ expect(Component).toBeTruthy();
140
+ expect(Component).not.toBe(getFieldComponent("Long Text"));
141
+ const element = (Component as Function)(
142
+ propsFor({ value: "note", label: "Remark" }),
143
+ ) as React.ReactElement<Record<string, any>>;
144
+ expect(element.type).toBe(SmallText);
145
+ expect(element.props.value).toBe("note");
146
+ expect(element.props.label).toBe("Remark");
147
+ });
148
+
149
+ it("substitutes an empty string for a null value", () => {
150
+ const Component = getFieldComponent("Small Text")!;
151
+ const element = (Component as Function)(
152
+ propsFor({ value: null as unknown as string }),
153
+ ) as React.ReactElement<Record<string, any>>;
154
+ expect(element.props.value).toBe("");
155
+ });
156
+
157
+ it("a disabled field also blocks the DOM textarea", () => {
158
+ const Component = getFieldComponent("Small Text")!;
159
+ const element = (Component as Function)(propsFor({ disabled: true })) as React.ReactElement<
160
+ Record<string, any>
161
+ >;
162
+ expect(element.props.textAreaProps).toEqual({ readOnly: true, tabIndex: -1 });
163
+ });
164
+
165
+ it("passes helperText / readOnly / skeleton / compact through", () => {
166
+ const Component = getFieldComponent("Small Text")!;
167
+ const element = (Component as Function)(
168
+ propsFor({ helperText: "keep it short", readOnly: true, skeleton: true, compact: true }),
169
+ ) as React.ReactElement<Record<string, any>>;
170
+ expect(element.props.helperText).toBe("keep it short");
171
+ expect(element.props.readOnly).toBe(true);
172
+ expect(element.props.skeleton).toBe(true);
173
+ expect(element.props.compact).toBe(true);
174
+ });
175
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * EditorPreviewPanes contract specs. Both source-editing fields
3
+ * (MarkdownEditorField, CodeEditorField) get their edit/preview arrangement
4
+ * from here, so the promises worth pinning are the ones a field author relies
5
+ * on without re-reading the file:
6
+ *
7
+ * - below the house md breakpoint it is Write/Preview TABS, and the tab
8
+ * labels are the caller's words, not hardcoded ones;
9
+ * - `defaultPreview` opens on the preview, which is what a read-only field
10
+ * wants — lead with the rendering, not with the source;
11
+ * - the tabs stay interactive on a disabled or read-only field, because
12
+ * those states gate EDITING and never VIEWING. A preview you cannot reach
13
+ * is the regression this component exists to prevent.
14
+ *
15
+ * The ≥ md two-pane branch pivots on a real layout measurement
16
+ * (`onLayout` → ResizeObserver), which happy-dom's stub never delivers, so it
17
+ * is left to the rendered proof rather than faked here.
18
+ */
19
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
20
+ import { cleanup, fireEvent, screen } from "@testing-library/react";
21
+ import { Text } from "tamagui";
22
+ import { afterEach, describe, expect, it } from "vitest";
23
+ import { EditorPreviewPanes, PreviewSurface } from "./EditorPreviewPanes";
24
+
25
+ afterEach(cleanup);
26
+
27
+ const panes = (extra: Partial<React.ComponentProps<typeof EditorPreviewPanes>> = {}) => (
28
+ <EditorPreviewPanes
29
+ editor={<Text>the source</Text>}
30
+ preview={<Text>the rendering</Text>}
31
+ editTabLabel="Write"
32
+ previewTabLabel="Preview"
33
+ {...extra}
34
+ />
35
+ );
36
+
37
+ describe("EditorPreviewPanes narrow arrangement", () => {
38
+ it("collapses into a two-tab strip", () => {
39
+ renderWithProviders(panes());
40
+ expect(screen.getByRole("tablist")).toBeTruthy();
41
+ expect(screen.getAllByRole("tab")).toHaveLength(2);
42
+ });
43
+
44
+ it("uses the caller's tab labels", () => {
45
+ renderWithProviders(panes({ editTabLabel: "Source", previewTabLabel: "Rendered" }));
46
+ expect(screen.getByText("Source")).toBeTruthy();
47
+ expect(screen.getByText("Rendered")).toBeTruthy();
48
+ });
49
+
50
+ it("opens on the editor and mounts only that pane", () => {
51
+ renderWithProviders(panes());
52
+ expect(screen.getByText("the source")).toBeTruthy();
53
+ expect(screen.queryByText("the rendering")).toBeNull();
54
+ });
55
+
56
+ it("defaultPreview opens on the preview instead", () => {
57
+ renderWithProviders(panes({ defaultPreview: true }));
58
+ expect(screen.getByText("the rendering")).toBeTruthy();
59
+ expect(screen.queryByText("the source")).toBeNull();
60
+ });
61
+
62
+ it("switches to the preview on a tab press", () => {
63
+ renderWithProviders(panes());
64
+ fireEvent.click(screen.getByText("Preview"));
65
+ expect(screen.getByText("the rendering")).toBeTruthy();
66
+ expect(screen.queryByText("the source")).toBeNull();
67
+ });
68
+
69
+ it("switches back to the editor", () => {
70
+ renderWithProviders(panes({ defaultPreview: true }));
71
+ fireEvent.click(screen.getByText("Write"));
72
+ expect(screen.getByText("the source")).toBeTruthy();
73
+ expect(screen.queryByText("the rendering")).toBeNull();
74
+ });
75
+
76
+ it("marks the open tab selected", () => {
77
+ renderWithProviders(panes());
78
+ const tabs = screen.getAllByRole("tab");
79
+ expect(tabs[0].getAttribute("aria-selected")).toBe("true");
80
+ expect(tabs[1].getAttribute("aria-selected")).toBe("false");
81
+ fireEvent.click(screen.getByText("Preview"));
82
+ expect(screen.getAllByRole("tab")[1].getAttribute("aria-selected")).toBe("true");
83
+ });
84
+
85
+ it("keeps the preview reachable even when the editor pane is a read-only surface", () => {
86
+ // The gate belongs to the editor, never to the tab strip: a read-only
87
+ // field still has to be able to look at its own rendering.
88
+ renderWithProviders(panes({ editor: <Text>read-only source</Text>, defaultPreview: true }));
89
+ fireEvent.click(screen.getByText("Write"));
90
+ expect(screen.getByText("read-only source")).toBeTruthy();
91
+ fireEvent.click(screen.getByText("Preview"));
92
+ expect(screen.getByText("the rendering")).toBeTruthy();
93
+ });
94
+ });
95
+
96
+ describe("PreviewSurface", () => {
97
+ it("renders its children inside a scrollable surface", () => {
98
+ const result = renderWithProviders(
99
+ <PreviewSurface>
100
+ <Text>rendered output</Text>
101
+ </PreviewSurface>,
102
+ );
103
+ expect(result.container.textContent).toContain("rendered output");
104
+ });
105
+
106
+ it("adds a second, horizontal scroller for code panes", () => {
107
+ const plain = renderWithProviders(
108
+ <PreviewSurface>
109
+ <Text>wrapped prose</Text>
110
+ </PreviewSurface>,
111
+ );
112
+ const plainDepth = plain.container.querySelectorAll("*").length;
113
+ cleanup();
114
+ const code = renderWithProviders(
115
+ <PreviewSurface horizontalScroll>
116
+ <Text>long unwrapped line</Text>
117
+ </PreviewSurface>,
118
+ );
119
+ expect(code.container.querySelectorAll("*").length).toBeGreaterThan(plainDepth);
120
+ expect(code.container.textContent).toContain("long unwrapped line");
121
+ });
122
+ });