@blotch/dolan 0.0.0-stage → 22.2.1
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.
- package/dist/commands/add.d.ts +22 -0
- package/dist/commands/add.js +132 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/add.test.d.ts +1 -0
- package/dist/commands/add.test.js +209 -0
- package/dist/commands/add.test.js.map +1 -0
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +155 -0
- package/dist/commands/diff.js.map +1 -0
- package/dist/commands/diff.test.d.ts +1 -0
- package/dist/commands/diff.test.js +327 -0
- package/dist/commands/diff.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/doctor.js +248 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/doctor.test.d.ts +1 -0
- package/dist/commands/doctor.test.js +301 -0
- package/dist/commands/doctor.test.js.map +1 -0
- package/dist/commands/eject.d.ts +11 -0
- package/dist/commands/eject.js +55 -0
- package/dist/commands/eject.js.map +1 -0
- package/dist/commands/eject.test.d.ts +1 -0
- package/dist/commands/eject.test.js +151 -0
- package/dist/commands/eject.test.js.map +1 -0
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +73 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/info.test.d.ts +1 -0
- package/dist/commands/info.test.js +143 -0
- package/dist/commands/info.test.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +98 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +154 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/commands/list.d.ts +16 -0
- package/dist/commands/list.js +124 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +285 -0
- package/dist/commands/list.test.js.map +1 -0
- package/dist/commands/outdated.d.ts +13 -0
- package/dist/commands/outdated.js +116 -0
- package/dist/commands/outdated.js.map +1 -0
- package/dist/commands/outdated.test.d.ts +1 -0
- package/dist/commands/outdated.test.js +208 -0
- package/dist/commands/outdated.test.js.map +1 -0
- package/dist/commands/remove.d.ts +18 -0
- package/dist/commands/remove.js +161 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/remove.test.d.ts +1 -0
- package/dist/commands/remove.test.js +361 -0
- package/dist/commands/remove.test.js.map +1 -0
- package/dist/commands/theme.d.ts +1 -0
- package/dist/commands/theme.js +66 -0
- package/dist/commands/theme.js.map +1 -0
- package/dist/commands/theme.test.d.ts +1 -0
- package/dist/commands/theme.test.js +133 -0
- package/dist/commands/theme.test.js.map +1 -0
- package/dist/commands/update.d.ts +31 -0
- package/dist/commands/update.js +601 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/update.test.d.ts +1 -0
- package/dist/commands/update.test.js +1233 -0
- package/dist/commands/update.test.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +48 -0
- package/dist/config.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/lockfile.d.ts +21 -0
- package/dist/lockfile.js +39 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +85 -0
- package/dist/registry.js.map +1 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +95 -0
- package/dist/registry.test.js.map +1 -0
- package/dist/test-utils.d.ts +16 -0
- package/dist/test-utils.js +65 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/ui/components/accordion/accordion.test.ts +194 -0
- package/dist/ui/components/accordion/accordion.ts +173 -0
- package/dist/ui/components/accordion/index.ts +6 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +211 -0
- package/dist/ui/components/action-sheet/action-sheet.ts +353 -0
- package/dist/ui/components/action-sheet/index.ts +6 -0
- package/dist/ui/components/alert/alert.test.ts +48 -0
- package/dist/ui/components/alert/alert.ts +104 -0
- package/dist/ui/components/alert/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +141 -0
- package/dist/ui/components/alert-dialog/alert-dialog.ts +243 -0
- package/dist/ui/components/alert-dialog/index.ts +7 -0
- package/dist/ui/components/area-chart/area-chart-render.test.ts +121 -0
- package/dist/ui/components/area-chart/area-chart.test.ts +158 -0
- package/dist/ui/components/area-chart/area-chart.ts +349 -0
- package/dist/ui/components/area-chart/index.ts +6 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.test.ts +10 -0
- package/dist/ui/components/aspect-ratio/aspect-ratio.ts +28 -0
- package/dist/ui/components/aspect-ratio/index.ts +1 -0
- package/dist/ui/components/avatar/avatar.test.ts +69 -0
- package/dist/ui/components/avatar/avatar.ts +131 -0
- package/dist/ui/components/avatar/index.ts +1 -0
- package/dist/ui/components/badge/badge.test.ts +49 -0
- package/dist/ui/components/badge/badge.ts +97 -0
- package/dist/ui/components/badge/index.ts +1 -0
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +174 -0
- package/dist/ui/components/bar-chart/bar-chart.test.ts +73 -0
- package/dist/ui/components/bar-chart/bar-chart.ts +327 -0
- package/dist/ui/components/bar-chart/index.ts +6 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +163 -0
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +339 -0
- package/dist/ui/components/bottom-sheet/index.ts +1 -0
- package/dist/ui/components/button/button-render.test.ts +113 -0
- package/dist/ui/components/button/button.test.ts +70 -0
- package/dist/ui/components/button/button.ts +170 -0
- package/dist/ui/components/button/index.ts +6 -0
- package/dist/ui/components/button-group/button-group-render.test.ts +146 -0
- package/dist/ui/components/button-group/button-group.test.ts +54 -0
- package/dist/ui/components/button-group/button-group.ts +209 -0
- package/dist/ui/components/button-group/index.ts +8 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +174 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +96 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +355 -0
- package/dist/ui/components/candlestick-chart/index.ts +7 -0
- package/dist/ui/components/card/card.test.ts +110 -0
- package/dist/ui/components/card/card.ts +162 -0
- package/dist/ui/components/card/index.ts +8 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +188 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +549 -0
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +1140 -0
- package/dist/ui/components/cartesian-chart/index.ts +18 -0
- package/dist/ui/components/checkbox/checkbox.test.ts +59 -0
- package/dist/ui/components/checkbox/checkbox.ts +96 -0
- package/dist/ui/components/checkbox/index.ts +1 -0
- package/dist/ui/components/collapsible/collapsible.test.ts +112 -0
- package/dist/ui/components/collapsible/collapsible.ts +122 -0
- package/dist/ui/components/collapsible/index.ts +5 -0
- package/dist/ui/components/dialog/dialog.test.ts +157 -0
- package/dist/ui/components/dialog/dialog.ts +248 -0
- package/dist/ui/components/dialog/index.ts +7 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +60 -0
- package/dist/ui/components/empty-state/empty-state.ts +46 -0
- package/dist/ui/components/empty-state/index.ts +1 -0
- package/dist/ui/components/icon/icon.test.ts +52 -0
- package/dist/ui/components/icon/icon.ts +54 -0
- package/dist/ui/components/icon/icons.ts +569 -0
- package/dist/ui/components/icon/index.ts +2 -0
- package/dist/ui/components/input/index.ts +1 -0
- package/dist/ui/components/input/input.test.ts +134 -0
- package/dist/ui/components/input/input.ts +189 -0
- package/dist/ui/components/label/index.ts +1 -0
- package/dist/ui/components/label/label.test.ts +38 -0
- package/dist/ui/components/label/label.ts +24 -0
- package/dist/ui/components/line-chart/index.ts +6 -0
- package/dist/ui/components/line-chart/line-chart-render.test.ts +121 -0
- package/dist/ui/components/line-chart/line-chart.test.ts +81 -0
- package/dist/ui/components/line-chart/line-chart.ts +273 -0
- package/dist/ui/components/list/index.ts +1 -0
- package/dist/ui/components/list/list.test.ts +35 -0
- package/dist/ui/components/list/list.ts +71 -0
- package/dist/ui/components/nav-drawer/index.ts +8 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +267 -0
- package/dist/ui/components/nav-drawer/nav-drawer.ts +504 -0
- package/dist/ui/components/pagination/index.ts +1 -0
- package/dist/ui/components/pagination/pagination.test.ts +96 -0
- package/dist/ui/components/pagination/pagination.ts +147 -0
- package/dist/ui/components/progress/index.ts +1 -0
- package/dist/ui/components/progress/progress.test.ts +71 -0
- package/dist/ui/components/progress/progress.ts +118 -0
- package/dist/ui/components/radio-group/index.ts +1 -0
- package/dist/ui/components/radio-group/radio-group.test.ts +115 -0
- package/dist/ui/components/radio-group/radio-group.ts +129 -0
- package/dist/ui/components/scatter-chart/index.ts +6 -0
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +157 -0
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +57 -0
- package/dist/ui/components/scatter-chart/scatter-chart.ts +277 -0
- package/dist/ui/components/scroll-area/index.ts +1 -0
- package/dist/ui/components/scroll-area/scroll-area.test.ts +40 -0
- package/dist/ui/components/scroll-area/scroll-area.ts +73 -0
- package/dist/ui/components/select/index.ts +1 -0
- package/dist/ui/components/select/select.test.ts +107 -0
- package/dist/ui/components/select/select.ts +146 -0
- package/dist/ui/components/separator/index.ts +1 -0
- package/dist/ui/components/separator/separator.test.ts +26 -0
- package/dist/ui/components/separator/separator.ts +45 -0
- package/dist/ui/components/sheet/index.ts +7 -0
- package/dist/ui/components/sheet/sheet.test.ts +44 -0
- package/dist/ui/components/sheet/sheet.ts +114 -0
- package/dist/ui/components/skeleton/index.ts +1 -0
- package/dist/ui/components/skeleton/skeleton.test.ts +13 -0
- package/dist/ui/components/skeleton/skeleton.ts +45 -0
- package/dist/ui/components/spinner/index.ts +1 -0
- package/dist/ui/components/spinner/spinner.test.ts +39 -0
- package/dist/ui/components/spinner/spinner.ts +83 -0
- package/dist/ui/components/switch/index.ts +1 -0
- package/dist/ui/components/switch/switch.test.ts +63 -0
- package/dist/ui/components/switch/switch.ts +98 -0
- package/dist/ui/components/tabs/index.ts +1 -0
- package/dist/ui/components/tabs/tabs.test.ts +199 -0
- package/dist/ui/components/tabs/tabs.ts +317 -0
- package/dist/ui/components/textarea/index.ts +1 -0
- package/dist/ui/components/textarea/textarea.test.ts +138 -0
- package/dist/ui/components/textarea/textarea.ts +221 -0
- package/dist/ui/components/toast/index.ts +9 -0
- package/dist/ui/components/toast/toast-state.test.ts +54 -0
- package/dist/ui/components/toast/toast-state.ts +46 -0
- package/dist/ui/components/toast/toast.test.ts +342 -0
- package/dist/ui/components/toast/toast.ts +527 -0
- package/dist/ui/components/toggle/index.ts +7 -0
- package/dist/ui/components/toggle/toggle.test.ts +186 -0
- package/dist/ui/components/toggle/toggle.ts +218 -0
- package/dist/ui/components/typography/index.ts +5 -0
- package/dist/ui/components/typography/typography.test.ts +55 -0
- package/dist/ui/components/typography/typography.ts +44 -0
- package/dist/ui/theme/dark.css +41 -0
- package/dist/ui/theme/default.css +56 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +124 -0
- package/dist/ui/theme/tailwind-plugin.ts +151 -0
- package/dist/ui/utils/animate.test.ts +324 -0
- package/dist/ui/utils/animate.ts +544 -0
- package/dist/ui/utils/cn.test.ts +27 -0
- package/dist/ui/utils/cn.ts +6 -0
- package/dist/utils/analyze.d.ts +32 -0
- package/dist/utils/analyze.js +109 -0
- package/dist/utils/analyze.js.map +1 -0
- package/dist/utils/analyze.test.d.ts +1 -0
- package/dist/utils/analyze.test.js +146 -0
- package/dist/utils/analyze.test.js.map +1 -0
- package/dist/utils/animate.d.ts +210 -0
- package/dist/utils/animate.js +423 -0
- package/dist/utils/animate.js.map +1 -0
- package/dist/utils/animate.test.d.ts +1 -0
- package/dist/utils/animate.test.js +365 -0
- package/dist/utils/animate.test.js.map +1 -0
- package/dist/utils/banner.d.ts +1 -0
- package/dist/utils/banner.js +15 -0
- package/dist/utils/banner.js.map +1 -0
- package/dist/utils/banner.test.d.ts +1 -0
- package/dist/utils/banner.test.js +29 -0
- package/dist/utils/banner.test.js.map +1 -0
- package/dist/utils/cn.d.ts +2 -0
- package/dist/utils/cn.js +6 -0
- package/dist/utils/cn.js.map +1 -0
- package/dist/utils/cn.test.d.ts +1 -0
- package/dist/utils/cn.test.js +21 -0
- package/dist/utils/cn.test.js.map +1 -0
- package/dist/utils/format.d.ts +31 -0
- package/dist/utils/format.js +95 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/format.test.d.ts +1 -0
- package/dist/utils/format.test.js +71 -0
- package/dist/utils/format.test.js.map +1 -0
- package/dist/utils/logger.d.ts +6 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +1 -0
- package/dist/utils/logger.test.js +36 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +12 -0
- package/dist/utils/resolve-paths.js +29 -0
- package/dist/utils/resolve-paths.js.map +1 -0
- package/dist/utils/resolve-paths.test.d.ts +1 -0
- package/dist/utils/resolve-paths.test.js +52 -0
- package/dist/utils/resolve-paths.test.js.map +1 -0
- package/package.json +56 -4
- package/README.md +0 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { signal } from '@angular/core';
|
|
2
|
+
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { setInputSignal } from '../../../test-utils/set-input-signal';
|
|
5
|
+
import { UiInput } from './input';
|
|
6
|
+
|
|
7
|
+
describe('UiInput', () => {
|
|
8
|
+
it('renders with default configuration', async () => {
|
|
9
|
+
const { container } = await render(UiInput);
|
|
10
|
+
expect(container).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('onInput() updates the model and emits `changed`', async () => {
|
|
14
|
+
const { componentRef } = await render(UiInput);
|
|
15
|
+
const inst = componentRef.instance as unknown as UiInput & {
|
|
16
|
+
onInput: (e: { detail: { value: string } }) => void;
|
|
17
|
+
};
|
|
18
|
+
const emitted: string[] = [];
|
|
19
|
+
inst.changed.subscribe((v) => emitted.push(v));
|
|
20
|
+
inst.onInput({ detail: { value: 'hello' } });
|
|
21
|
+
expect(inst.value()).toBe('hello');
|
|
22
|
+
expect(emitted).toEqual(['hello']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('onInput() is a no-op when disabled', async () => {
|
|
26
|
+
const { componentRef } = await render(UiInput);
|
|
27
|
+
const inst = componentRef.instance as unknown as UiInput & {
|
|
28
|
+
onInput: (e: { detail: { value: string } }) => void;
|
|
29
|
+
};
|
|
30
|
+
setInputSignal(inst.disabled, true);
|
|
31
|
+
const emitted = vi.fn();
|
|
32
|
+
inst.changed.subscribe(emitted);
|
|
33
|
+
inst.onInput({ detail: { value: 'blocked' } });
|
|
34
|
+
expect(inst.value()).toBe('');
|
|
35
|
+
expect(emitted).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('onFocus() flips the focused state and emits `focused`', async () => {
|
|
39
|
+
const { componentRef, container } = await render(UiInput);
|
|
40
|
+
const inst = componentRef.instance as unknown as UiInput & {
|
|
41
|
+
onFocus: () => void;
|
|
42
|
+
};
|
|
43
|
+
const emitted = vi.fn();
|
|
44
|
+
inst.focused.subscribe(emitted);
|
|
45
|
+
inst.onFocus();
|
|
46
|
+
await waitForUpdate();
|
|
47
|
+
expect(emitted).toHaveBeenCalledTimes(1);
|
|
48
|
+
// Focus turns the overlay opacity to 1 via ringVisible().
|
|
49
|
+
const overlay = container.querySelector('[style*="box-shadow"]');
|
|
50
|
+
expect(overlay?.getAttribute('style')).toContain('opacity');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('onBlur() clears focused state and emits `blurred`', async () => {
|
|
54
|
+
const { componentRef } = await render(UiInput);
|
|
55
|
+
const inst = componentRef.instance as unknown as UiInput & {
|
|
56
|
+
onFocus: () => void;
|
|
57
|
+
onBlur: () => void;
|
|
58
|
+
};
|
|
59
|
+
const emitted = vi.fn();
|
|
60
|
+
inst.blurred.subscribe(emitted);
|
|
61
|
+
inst.onFocus();
|
|
62
|
+
inst.onBlur();
|
|
63
|
+
expect(emitted).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('error input triggers the shake animation via effect', async () => {
|
|
67
|
+
// The effect reads error() + containerRef.nativeElement and calls shake().
|
|
68
|
+
// Setting error to a non-empty value while it was empty exercises the branch.
|
|
69
|
+
// JIT + jsdom doesn't populate template-ref viewChild queries, so we
|
|
70
|
+
// monkey-patch containerRef with a signal returning a fake ElementRef so the
|
|
71
|
+
// effect's `el` check passes and the shake path runs.
|
|
72
|
+
const { componentRef, container } = await render(UiInput);
|
|
73
|
+
const inst = componentRef.instance as UiInput;
|
|
74
|
+
const view = container.querySelector('view') as Element;
|
|
75
|
+
(inst as unknown as { containerRef: unknown }).containerRef = signal({
|
|
76
|
+
nativeElement: view,
|
|
77
|
+
});
|
|
78
|
+
setInputSignal(inst.error, 'Required');
|
|
79
|
+
await waitForUpdate();
|
|
80
|
+
await waitForUpdate();
|
|
81
|
+
// The error text should render and the destructive ring shadow should apply.
|
|
82
|
+
expect(container.textContent).toContain('Required');
|
|
83
|
+
|
|
84
|
+
// Re-trigger the shake (previous error cleared → then set) so `#shakeAnim`
|
|
85
|
+
// is set once and canceled the next time — covers both branches of `?.`.
|
|
86
|
+
setInputSignal(inst.error, '');
|
|
87
|
+
await waitForUpdate();
|
|
88
|
+
await waitForUpdate();
|
|
89
|
+
setInputSignal(inst.error, 'Required again');
|
|
90
|
+
await waitForUpdate();
|
|
91
|
+
await waitForUpdate();
|
|
92
|
+
expect(container.textContent).toContain('Required again');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders helper text when no error is present', async () => {
|
|
96
|
+
const { componentRef, container } = await render(UiInput);
|
|
97
|
+
const inst = componentRef.instance as UiInput;
|
|
98
|
+
setInputSignal(inst.helperText, 'Enter your name');
|
|
99
|
+
await waitForUpdate();
|
|
100
|
+
expect(container.textContent).toContain('Enter your name');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('renders the label when provided', async () => {
|
|
104
|
+
const { componentRef, container } = await render(UiInput);
|
|
105
|
+
const inst = componentRef.instance as UiInput;
|
|
106
|
+
setInputSignal(inst.label, 'Name');
|
|
107
|
+
await waitForUpdate();
|
|
108
|
+
expect(container.textContent).toContain('Name');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('makes the wrapper a stacking context so the field scrolls with it', async () => {
|
|
112
|
+
// On Lynx a z-indexed element inside a scroll-view only follows the scroll
|
|
113
|
+
// when an ancestor inside the scroll content is a stacking context. Without
|
|
114
|
+
// z-0 on the wrapper, the native field (and its placeholder) stays fixed on
|
|
115
|
+
// screen while the page scrolls.
|
|
116
|
+
const { container } = await render(UiInput);
|
|
117
|
+
const wrapper = container.querySelector(
|
|
118
|
+
'[class*="rounded-xl"][class*="bg-muted"]',
|
|
119
|
+
);
|
|
120
|
+
expect(wrapper?.getAttribute('class')?.split(/\s+/)).toContain('z-0');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('applies the disabled dim class when disabled', async () => {
|
|
124
|
+
// Covers the true branch of `disabled() && 'opacity-50'` in inputWrapperClass.
|
|
125
|
+
const { componentRef, container } = await render(UiInput);
|
|
126
|
+
const inst = componentRef.instance as UiInput;
|
|
127
|
+
setInputSignal(inst.disabled, true);
|
|
128
|
+
await waitForUpdate();
|
|
129
|
+
const wrapper = container.querySelector(
|
|
130
|
+
'[class*="rounded-xl"][class*="bg-muted"]',
|
|
131
|
+
);
|
|
132
|
+
expect(wrapper?.getAttribute('class')).toContain('opacity-50');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ElementRef,
|
|
3
|
+
Component,
|
|
4
|
+
ViewEncapsulation,
|
|
5
|
+
computed,
|
|
6
|
+
effect,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
output,
|
|
10
|
+
signal,
|
|
11
|
+
viewChild,
|
|
12
|
+
} from '@angular/core';
|
|
13
|
+
import { type FormValueControl } from '@angular/forms/signals';
|
|
14
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
15
|
+
|
|
16
|
+
import { type AnimationHandle, shake } from '../../utils/animate';
|
|
17
|
+
import { cn } from '../../utils/cn';
|
|
18
|
+
|
|
19
|
+
@Component({
|
|
20
|
+
selector: 'ui-input',
|
|
21
|
+
standalone: true,
|
|
22
|
+
imports: [LYNX_ELEMENTS],
|
|
23
|
+
encapsulation: ViewEncapsulation.None,
|
|
24
|
+
template: `
|
|
25
|
+
<view #container [class]="containerClass()">
|
|
26
|
+
@if (label()) {
|
|
27
|
+
<text [class]="labelClass()">{{ label() }}</text>
|
|
28
|
+
}
|
|
29
|
+
<!-- Focus ring is an inline box-shadow on a dedicated overlay layer, not a
|
|
30
|
+
Tailwind ring-* class (the Lynx preset doesn't wire up --tw-ring-*, so
|
|
31
|
+
ring-* renders nothing on device). It rides its OWN layer so it can
|
|
32
|
+
fade: box-shadow is animatable:no on Lynx (the core rejects a
|
|
33
|
+
transition: box-shadow), but opacity is animatable, so we transition
|
|
34
|
+
the layer's opacity instead. The overlay is the FIRST child so Lynx,
|
|
35
|
+
which paints in strict source order, keeps the <input> on top and
|
|
36
|
+
tappable. Source order alone is NOT enough on web: CSS paints a
|
|
37
|
+
positioned (absolute) sibling above a static one regardless of source
|
|
38
|
+
order, so this transparent overlay would swallow every tap — no focus,
|
|
39
|
+
no typing. The <input> below therefore carries position:relative +
|
|
40
|
+
z-index:1 so it sits above the overlay on BOTH platforms (required on
|
|
41
|
+
web). On Lynx, though, a z-indexed element is stacked against its
|
|
42
|
+
nearest stacking context, and a scroll-view isn't one unless it sets
|
|
43
|
+
z-index itself — so the native input would stay fixed on screen while
|
|
44
|
+
the page scrolled (only its placeholder visibly floats). The wrapper's
|
|
45
|
+
z-0 makes it that stacking context, keeping the input's z-index local
|
|
46
|
+
so it scrolls with the field. pointer-events is avoided — it errors
|
|
47
|
+
the Lynx build. The shadow is OUTSET only: Lynx doesn't render inset box-shadows,
|
|
48
|
+
so the crisp 1px "border" is an outset ring hugging the edge, not a
|
|
49
|
+
real inset border. -->
|
|
50
|
+
<view [class]="inputWrapperClass()">
|
|
51
|
+
<view
|
|
52
|
+
class="rounded-xl opacity-0 absolute bottom-0 left-0 right-0 top-0"
|
|
53
|
+
[style.transition]="'opacity 150ms ease'"
|
|
54
|
+
[style.box-shadow]="ringShadow()"
|
|
55
|
+
[style.opacity]="ringVisible() ? 1 : 0"
|
|
56
|
+
></view>
|
|
57
|
+
<!-- [attr.disabled]="disabled() || undefined": passing 'undefined'
|
|
58
|
+
removes the attribute entirely; passing 'false' would set
|
|
59
|
+
disabled="false" which Lynx still treats as disabled. -->
|
|
60
|
+
<!-- [value] not [attr.value]: [attr.value] calls renderer.setAttribute()
|
|
61
|
+
which routes to __SetAttribute — a no-op for live text on Lynx native
|
|
62
|
+
inputs. [value] is an Angular input binding that triggers
|
|
63
|
+
LynxInput.ngOnChanges, where the setValue UIMethod override lives.
|
|
64
|
+
Without this, form model resets would update the signal but the
|
|
65
|
+
native input would keep showing the user's old typed text. -->
|
|
66
|
+
<input
|
|
67
|
+
[attr.placeholder]="placeholder()"
|
|
68
|
+
[attr.type]="type()"
|
|
69
|
+
[value]="value()"
|
|
70
|
+
[attr.disabled]="disabled() || undefined"
|
|
71
|
+
class="text-sm text-foreground"
|
|
72
|
+
style="border: none; background: transparent; height: 100%; width: 100%; position: relative; z-index: 1;"
|
|
73
|
+
(bindinput)="onInput($any($event))"
|
|
74
|
+
(bindfocus)="onFocus()"
|
|
75
|
+
(bindblur)="onBlur()"
|
|
76
|
+
/>
|
|
77
|
+
</view>
|
|
78
|
+
@if (error()) {
|
|
79
|
+
<text [class]="errorClass()">{{ error() }}</text>
|
|
80
|
+
} @else if (helperText()) {
|
|
81
|
+
<text [class]="helperClass()">{{ helperText() }}</text>
|
|
82
|
+
}
|
|
83
|
+
</view>
|
|
84
|
+
`,
|
|
85
|
+
})
|
|
86
|
+
export class UiInput implements FormValueControl<string> {
|
|
87
|
+
readonly value = model<string>('');
|
|
88
|
+
readonly label = input<string>('');
|
|
89
|
+
readonly placeholder = input<string>('');
|
|
90
|
+
readonly type = input<string>('text');
|
|
91
|
+
readonly helperText = input<string>('');
|
|
92
|
+
readonly error = input<string>('');
|
|
93
|
+
readonly disabled = input(false);
|
|
94
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
95
|
+
|
|
96
|
+
readonly changed = output<string>();
|
|
97
|
+
readonly focused = output<void>();
|
|
98
|
+
readonly blurred = output<void>();
|
|
99
|
+
|
|
100
|
+
readonly containerRef = viewChild<ElementRef>('container');
|
|
101
|
+
readonly #isFocused = signal(false);
|
|
102
|
+
#shakeAnim?: AnimationHandle;
|
|
103
|
+
#previousError = '';
|
|
104
|
+
|
|
105
|
+
constructor() {
|
|
106
|
+
// Shake the input when an error appears (transition from no-error to error)
|
|
107
|
+
effect(() => {
|
|
108
|
+
const err = this.error();
|
|
109
|
+
const el = this.containerRef()?.nativeElement;
|
|
110
|
+
if (err && !this.#previousError && el) {
|
|
111
|
+
this.#shakeAnim?.cancel();
|
|
112
|
+
this.#shakeAnim = shake(el);
|
|
113
|
+
}
|
|
114
|
+
this.#previousError = err;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected readonly containerClass = computed(() =>
|
|
119
|
+
cn('flex-col gap-1.5 flex', this.userClass()),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
protected readonly labelClass = computed(() =>
|
|
123
|
+
cn('text-sm font-medium text-foreground'),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
protected readonly inputWrapperClass = computed(() =>
|
|
127
|
+
// z-0: stacking context for the input's z-index (see the template comment).
|
|
128
|
+
cn(
|
|
129
|
+
'z-0 h-10 rounded-xl bg-muted px-3.5 py-2',
|
|
130
|
+
this.disabled() && 'opacity-50',
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Focus ring, drawn as an OUTSET box-shadow on a dedicated overlay layer (see
|
|
136
|
+
* the template comment for why it can't be a Tailwind ring-* class and why it
|
|
137
|
+
* needs its own layer). Two stacked outset shadows: a solid 1px ring hugging
|
|
138
|
+
* the edge (reads as a crisp border) and a softer 3px ring around it — both
|
|
139
|
+
* follow the wrapper's rounded-xl corners. Focus pairs var(--ring) with the
|
|
140
|
+
* translucent var(--ring-subtle); error uses solid var(--destructive) for both
|
|
141
|
+
* since it flags a problem. The value is always set (never null) so the shadow
|
|
142
|
+
* stays painted while the layer fades OUT — visibility is driven by
|
|
143
|
+
* ringVisible()/opacity, not by adding/removing the shadow. Colors resolve at
|
|
144
|
+
* runtime and track dark mode.
|
|
145
|
+
*/
|
|
146
|
+
protected readonly ringShadow = computed(() =>
|
|
147
|
+
this.error()
|
|
148
|
+
? '0 0 0 1px var(--destructive), 0 0 0 3px var(--destructive)'
|
|
149
|
+
: '0 0 0 1px var(--ring), 0 0 0 3px var(--ring-subtle)',
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether the focus ring is shown. The overlay's opacity transitions between 0
|
|
154
|
+
* and 1 on this, fading the ring in/out (opacity is animatable on Lynx;
|
|
155
|
+
* box-shadow is not). Shown while focused, or while an error is present so an
|
|
156
|
+
* invalid field always carries the destructive ring.
|
|
157
|
+
*/
|
|
158
|
+
protected readonly ringVisible = computed(
|
|
159
|
+
() => this.#isFocused() || this.error() !== '',
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
protected readonly helperClass = computed(() =>
|
|
163
|
+
cn('text-xs text-muted-foreground'),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
protected readonly errorClass = computed(() =>
|
|
167
|
+
cn('text-xs text-destructive'),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
protected onFocus(): void {
|
|
171
|
+
this.#isFocused.set(true);
|
|
172
|
+
this.focused.emit();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
protected onBlur(): void {
|
|
176
|
+
this.#isFocused.set(false);
|
|
177
|
+
this.blurred.emit();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Lynx input events carry the updated value in `event.detail.value`,
|
|
182
|
+
* not in `event.target.value` as on the web — hence the custom type.
|
|
183
|
+
*/
|
|
184
|
+
protected onInput(event: { detail: { value: string } }): void {
|
|
185
|
+
if (this.disabled()) return;
|
|
186
|
+
this.value.set(event.detail.value);
|
|
187
|
+
this.changed.emit(event.detail.value);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UiLabel } from './label';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
|
|
3
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { setInputSignal } from '../../../test-utils/set-input-signal';
|
|
6
|
+
import { UiLabel } from './label';
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
standalone: true,
|
|
10
|
+
imports: [UiLabel, LYNX_ELEMENTS],
|
|
11
|
+
template: `<ui-label>{{ label }}</ui-label>`,
|
|
12
|
+
})
|
|
13
|
+
class LabelHost {
|
|
14
|
+
label = 'Name';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('UiLabel', () => {
|
|
18
|
+
it('renders projected text', async () => {
|
|
19
|
+
const { getByText } = await render(LabelHost);
|
|
20
|
+
expect(getByText('Name')).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders standalone with no host wrapper (default disabled=false)', async () => {
|
|
24
|
+
const { container } = await render(UiLabel);
|
|
25
|
+
const textEl = container.querySelector('text');
|
|
26
|
+
expect(textEl?.getAttribute('class')).toContain('font-medium');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('applies opacity when disabled flips to true', async () => {
|
|
30
|
+
// JIT template bindings don't wire signal inputs — drive the input
|
|
31
|
+
// directly to exercise the `disabled() && 'opacity-50'` truthy branch.
|
|
32
|
+
const { componentRef, container } = await render(UiLabel);
|
|
33
|
+
setInputSignal((componentRef.instance as UiLabel).disabled, true);
|
|
34
|
+
await waitForUpdate();
|
|
35
|
+
const textEl = container.querySelector('text');
|
|
36
|
+
expect(textEl?.getAttribute('class')).toContain('opacity-50');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
|
|
6
|
+
@Component({
|
|
7
|
+
selector: 'ui-label',
|
|
8
|
+
standalone: true,
|
|
9
|
+
imports: [LYNX_ELEMENTS],
|
|
10
|
+
encapsulation: ViewEncapsulation.None,
|
|
11
|
+
template: `<text [class]="labelClass()"><ng-content /></text>`,
|
|
12
|
+
})
|
|
13
|
+
export class UiLabel {
|
|
14
|
+
readonly disabled = input(false);
|
|
15
|
+
readonly userClass = input<string>('', { alias: 'class' });
|
|
16
|
+
|
|
17
|
+
protected readonly labelClass = computed(() =>
|
|
18
|
+
cn(
|
|
19
|
+
'text-sm font-medium text-foreground',
|
|
20
|
+
this.disabled() && 'opacity-50',
|
|
21
|
+
this.userClass(),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { setInputSignal } from '../../../test-utils/set-input-signal';
|
|
4
|
+
import { UiCartesianChart } from '../cartesian-chart/cartesian-chart';
|
|
5
|
+
import { UiLineChart, UiLineSeries } from './line-chart';
|
|
6
|
+
|
|
7
|
+
describe('UiLineChart', () => {
|
|
8
|
+
it('renders with default configuration', async () => {
|
|
9
|
+
const { container } = await render(UiLineChart);
|
|
10
|
+
expect(container).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('computes axis domains from data', async () => {
|
|
14
|
+
const { componentRef } = await render(UiLineChart);
|
|
15
|
+
const inst = componentRef.instance as UiLineChart;
|
|
16
|
+
setInputSignal(inst.data, [
|
|
17
|
+
{ x: 0, y: 0 },
|
|
18
|
+
{ x: 5, y: 10 },
|
|
19
|
+
{ x: 10, y: 5 },
|
|
20
|
+
]);
|
|
21
|
+
await waitForUpdate();
|
|
22
|
+
const cast = inst as unknown as {
|
|
23
|
+
xAxis: () => { domain: readonly [number, number]; ticks: number[] };
|
|
24
|
+
yAxis: () => { domain: readonly [number, number]; ticks: number[] };
|
|
25
|
+
};
|
|
26
|
+
expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(0);
|
|
27
|
+
expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(10);
|
|
28
|
+
expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(0);
|
|
29
|
+
expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(10);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('honors explicit xMin / xMax / yMin / yMax overrides', async () => {
|
|
33
|
+
const { componentRef } = await render(UiLineChart);
|
|
34
|
+
const inst = componentRef.instance as UiLineChart;
|
|
35
|
+
setInputSignal(inst.data, [
|
|
36
|
+
{ x: 0, y: 0 },
|
|
37
|
+
{ x: 10, y: 10 },
|
|
38
|
+
]);
|
|
39
|
+
setInputSignal(inst.xMin, -5);
|
|
40
|
+
setInputSignal(inst.xMax, 20);
|
|
41
|
+
setInputSignal(inst.yMin, -1);
|
|
42
|
+
setInputSignal(inst.yMax, 15);
|
|
43
|
+
await waitForUpdate();
|
|
44
|
+
const cast = inst as unknown as {
|
|
45
|
+
xAxis: () => { domain: readonly [number, number] };
|
|
46
|
+
yAxis: () => { domain: readonly [number, number] };
|
|
47
|
+
};
|
|
48
|
+
expect(cast.xAxis().domain[0]).toBeLessThanOrEqual(-5);
|
|
49
|
+
expect(cast.xAxis().domain[1]).toBeGreaterThanOrEqual(20);
|
|
50
|
+
expect(cast.yAxis().domain[0]).toBeLessThanOrEqual(-1);
|
|
51
|
+
expect(cast.yAxis().domain[1]).toBeGreaterThanOrEqual(15);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('exposes the default tick formatter used by the child cartesian chart', async () => {
|
|
55
|
+
const { componentRef } = await render(UiLineChart);
|
|
56
|
+
const inst = componentRef.instance as UiLineChart;
|
|
57
|
+
// JIT doesn't wire the child cartesian chart's [xTickFormat] input, so the
|
|
58
|
+
// default formatter is never invoked through the template. Invoke it
|
|
59
|
+
// directly to cover its two-decimal rounding branch.
|
|
60
|
+
expect(inst.xTickFormat()(3.14159)).toBe('3.14');
|
|
61
|
+
expect(inst.yTickFormat()(1.005)).toBe('1');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('UiLineSeries', () => {
|
|
66
|
+
const renderSeries = async () => {
|
|
67
|
+
const parentResult = await render(UiCartesianChart);
|
|
68
|
+
const parent = parentResult.componentRef.instance as UiCartesianChart;
|
|
69
|
+
setInputSignal(parent.xDomain, [0, 10]);
|
|
70
|
+
setInputSignal(parent.yDomain, [0, 10]);
|
|
71
|
+
await waitForUpdate();
|
|
72
|
+
const seriesResult = await render(UiLineSeries, {
|
|
73
|
+
providers: [{ provide: UiCartesianChart, useValue: parent }],
|
|
74
|
+
});
|
|
75
|
+
const series = seriesResult.componentRef.instance as UiLineSeries;
|
|
76
|
+
setInputSignal(series.data, [
|
|
77
|
+
{ x: 0, y: 0 },
|
|
78
|
+
{ x: 5, y: 5 },
|
|
79
|
+
{ x: 10, y: 10 },
|
|
80
|
+
]);
|
|
81
|
+
await waitForUpdate();
|
|
82
|
+
return { parent, series, seriesResult };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
it('renders segments and dots for the projected data', async () => {
|
|
86
|
+
const { series } = await renderSeries();
|
|
87
|
+
const cast = series as unknown as {
|
|
88
|
+
segments: () => { style: string }[];
|
|
89
|
+
dots: () => { style: string }[];
|
|
90
|
+
};
|
|
91
|
+
expect(cast.segments().length).toBeGreaterThan(0);
|
|
92
|
+
expect(cast.dots().length).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('hides dots when showDots is false', async () => {
|
|
96
|
+
const { series } = await renderSeries();
|
|
97
|
+
setInputSignal(series.showDots, false);
|
|
98
|
+
await waitForUpdate();
|
|
99
|
+
const cast = series as unknown as { dots: () => { style: string }[] };
|
|
100
|
+
expect(cast.dots()).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('emits pointTap when onPointTap is invoked', async () => {
|
|
104
|
+
const { series } = await renderSeries();
|
|
105
|
+
const cb = vi.fn();
|
|
106
|
+
series.pointTap.subscribe(cb);
|
|
107
|
+
(series as unknown as { onPointTap: (p: unknown) => void }).onPointTap({
|
|
108
|
+
x: 1,
|
|
109
|
+
y: 2,
|
|
110
|
+
});
|
|
111
|
+
expect(cb).toHaveBeenCalledWith({ x: 1, y: 2 });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('smooth mode resamples through sampleSmoothLine', async () => {
|
|
115
|
+
const { series } = await renderSeries();
|
|
116
|
+
setInputSignal(series.smooth, true);
|
|
117
|
+
await waitForUpdate();
|
|
118
|
+
const cast = series as unknown as { segments: () => { style: string }[] };
|
|
119
|
+
expect(cast.segments().length).toBeGreaterThan(2);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock Angular (and the sibling cartesian-chart's Angular usage, which loads
|
|
4
|
+
// transitively) so importing the module needs no framework runtime. Only the
|
|
5
|
+
// exported pure `computeLineSegments` is exercised; components are never built.
|
|
6
|
+
vi.mock('@angular/core', () => ({
|
|
7
|
+
Component: () => () => {},
|
|
8
|
+
ViewEncapsulation: { None: 0 },
|
|
9
|
+
computed: () => () => {},
|
|
10
|
+
inject: () => ({}),
|
|
11
|
+
input: () => () => {},
|
|
12
|
+
output: () => () => {},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@blotch/angular-lynx', () => ({
|
|
16
|
+
LYNX_ELEMENTS: [],
|
|
17
|
+
// cartesian-chart (imported transitively) registers these in its decorator.
|
|
18
|
+
LynxGestureDetector: class {},
|
|
19
|
+
PanGesture: class {},
|
|
20
|
+
PinchGesture: class {},
|
|
21
|
+
Gesture: { Simultaneous: () => ({}) },
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const { computeLineSegments } = await import('./line-chart');
|
|
25
|
+
|
|
26
|
+
describe('computeLineSegments', () => {
|
|
27
|
+
it('produces a horizontal segment (angle 0)', () => {
|
|
28
|
+
const segments = computeLineSegments([
|
|
29
|
+
{ x: 0, y: 10 },
|
|
30
|
+
{ x: 100, y: 10 },
|
|
31
|
+
]);
|
|
32
|
+
expect(segments).toHaveLength(1);
|
|
33
|
+
expect(segments[0].x).toBe(0);
|
|
34
|
+
expect(segments[0].y).toBe(10);
|
|
35
|
+
expect(segments[0].length).toBeCloseTo(100);
|
|
36
|
+
expect(segments[0].angle).toBeCloseTo(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('measures a vertical segment as 90 degrees (screen y-down)', () => {
|
|
40
|
+
const segments = computeLineSegments([
|
|
41
|
+
{ x: 0, y: 0 },
|
|
42
|
+
{ x: 0, y: 50 },
|
|
43
|
+
]);
|
|
44
|
+
expect(segments[0].length).toBeCloseTo(50);
|
|
45
|
+
expect(segments[0].angle).toBeCloseTo(90);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('measures a 45-degree diagonal', () => {
|
|
49
|
+
const segments = computeLineSegments([
|
|
50
|
+
{ x: 0, y: 0 },
|
|
51
|
+
{ x: 10, y: 10 },
|
|
52
|
+
]);
|
|
53
|
+
expect(segments[0].length).toBeCloseTo(Math.sqrt(200));
|
|
54
|
+
expect(segments[0].angle).toBeCloseTo(45);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('emits one segment per consecutive pair', () => {
|
|
58
|
+
const segments = computeLineSegments([
|
|
59
|
+
{ x: 0, y: 0 },
|
|
60
|
+
{ x: 10, y: 5 },
|
|
61
|
+
{ x: 20, y: 0 },
|
|
62
|
+
]);
|
|
63
|
+
expect(segments).toHaveLength(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('skips zero-length (duplicate) points', () => {
|
|
67
|
+
const segments = computeLineSegments([
|
|
68
|
+
{ x: 5, y: 5 },
|
|
69
|
+
{ x: 5, y: 5 },
|
|
70
|
+
{ x: 10, y: 5 },
|
|
71
|
+
]);
|
|
72
|
+
expect(segments).toHaveLength(1);
|
|
73
|
+
expect(segments[0].x).toBe(5);
|
|
74
|
+
expect(segments[0].length).toBeCloseTo(5);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns no segments for fewer than two points', () => {
|
|
78
|
+
expect(computeLineSegments([])).toEqual([]);
|
|
79
|
+
expect(computeLineSegments([{ x: 1, y: 1 }])).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|