kcg-code 0.0.0-stage → 0.1.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.
- package/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test headline acak homepage (lib/headlines.ts) dan prompt tertunda
|
|
3
|
+
* (lib/pending-prompt.ts) — logika murni.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { HOME_HEADLINES, pickHeadline } from "../headlines";
|
|
7
|
+
import {
|
|
8
|
+
type PendingStore,
|
|
9
|
+
peekPendingPrompt,
|
|
10
|
+
setPendingPrompt,
|
|
11
|
+
takePendingPrompt,
|
|
12
|
+
} from "../pending-prompt";
|
|
13
|
+
|
|
14
|
+
describe("pickHeadline", () => {
|
|
15
|
+
test("memilih sesuai nilai random (batas bawah & atas)", () => {
|
|
16
|
+
const list = ["a", "b", "c"];
|
|
17
|
+
expect(pickHeadline(list, () => 0)).toBe("a");
|
|
18
|
+
expect(pickHeadline(list, () => 0.5)).toBe("b");
|
|
19
|
+
expect(pickHeadline(list, () => 0.9999)).toBe("c");
|
|
20
|
+
// random() === 1 tidak boleh keluar dari indeks.
|
|
21
|
+
expect(pickHeadline(list, () => 1)).toBe("c");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("default memakai HOME_HEADLINES; list kosong -> string kosong", () => {
|
|
25
|
+
expect(HOME_HEADLINES).toContain(pickHeadline());
|
|
26
|
+
expect(pickHeadline([])).toBe("");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function memoryStore(): PendingStore {
|
|
31
|
+
const map = new Map<string, string>();
|
|
32
|
+
return {
|
|
33
|
+
getItem: (k) => map.get(k) ?? null,
|
|
34
|
+
setItem: (k, v) => void map.set(k, v),
|
|
35
|
+
removeItem: (k) => void map.delete(k),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("pending prompt", () => {
|
|
40
|
+
test("disimpan per Session dan hanya bisa diambil sekali", () => {
|
|
41
|
+
const store = memoryStore();
|
|
42
|
+
setPendingPrompt("s1", " hello ", store);
|
|
43
|
+
expect(takePendingPrompt("s2", store)).toBeNull();
|
|
44
|
+
expect(takePendingPrompt("s1", store)).toBe("hello");
|
|
45
|
+
expect(takePendingPrompt("s1", store)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("peek membaca tanpa menghapus", () => {
|
|
49
|
+
const store = memoryStore();
|
|
50
|
+
setPendingPrompt("s1", "hi", store);
|
|
51
|
+
expect(peekPendingPrompt("s1", store)).toBe("hi");
|
|
52
|
+
expect(peekPendingPrompt("s1", store)).toBe("hi");
|
|
53
|
+
expect(takePendingPrompt("s1", store)).toBe("hi");
|
|
54
|
+
expect(peekPendingPrompt("s1", store)).toBeNull();
|
|
55
|
+
expect(peekPendingPrompt("s1", null)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("teks kosong tidak disimpan; tanpa storage aman", () => {
|
|
59
|
+
const store = memoryStore();
|
|
60
|
+
setPendingPrompt("s1", " ", store);
|
|
61
|
+
expect(takePendingPrompt("s1", store)).toBeNull();
|
|
62
|
+
expect(takePendingPrompt("s1", null)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test logika keyboard lock screen (src/lib/lock-screen.ts).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { applyPinAction, digitsFromPaste, PIN_MAX, pinKeyAction } from "../lock-screen";
|
|
6
|
+
|
|
7
|
+
describe("pinKeyAction", () => {
|
|
8
|
+
test("angka baris atas & numpad -> digit", () => {
|
|
9
|
+
for (const k of ["0", "5", "9"]) {
|
|
10
|
+
expect(pinKeyAction({ key: k })).toEqual({ type: "digit", digit: k });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("numpad dibaca dari code, termasuk saat NumLock mati", () => {
|
|
15
|
+
expect(pinKeyAction({ key: "4", code: "Numpad4" })).toEqual({ type: "digit", digit: "4" });
|
|
16
|
+
expect(pinKeyAction({ key: "ArrowLeft", code: "Numpad4" })).toEqual({
|
|
17
|
+
type: "digit",
|
|
18
|
+
digit: "4",
|
|
19
|
+
});
|
|
20
|
+
expect(pinKeyAction({ key: "Insert", code: "Numpad0" })).toEqual({ type: "digit", digit: "0" });
|
|
21
|
+
expect(pinKeyAction({ key: "Enter", code: "NumpadEnter" })).toEqual({ type: "submit" });
|
|
22
|
+
// Panah biasa (bukan numpad) tetap diabaikan.
|
|
23
|
+
expect(pinKeyAction({ key: "ArrowLeft", code: "ArrowLeft" })).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("Backspace/Delete, Enter, Escape", () => {
|
|
27
|
+
expect(pinKeyAction({ key: "Backspace" })).toEqual({ type: "backspace" });
|
|
28
|
+
expect(pinKeyAction({ key: "Delete" })).toEqual({ type: "backspace" });
|
|
29
|
+
expect(pinKeyAction({ key: "Enter" })).toEqual({ type: "submit" });
|
|
30
|
+
expect(pinKeyAction({ key: "Escape" })).toEqual({ type: "clear" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("huruf & pintasan browser diabaikan", () => {
|
|
34
|
+
expect(pinKeyAction({ key: "a" })).toBeNull();
|
|
35
|
+
expect(pinKeyAction({ key: "Tab" })).toBeNull();
|
|
36
|
+
expect(pinKeyAction({ key: "5", ctrlKey: true })).toBeNull();
|
|
37
|
+
expect(pinKeyAction({ key: "r", metaKey: true })).toBeNull();
|
|
38
|
+
expect(pinKeyAction({ key: "1", altKey: true })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("applyPinAction", () => {
|
|
43
|
+
test("tambah, hapus, kosongkan; submit tidak mengubah", () => {
|
|
44
|
+
expect(applyPinAction("12", { type: "digit", digit: "3" })).toBe("123");
|
|
45
|
+
expect(applyPinAction("123", { type: "backspace" })).toBe("12");
|
|
46
|
+
expect(applyPinAction("", { type: "backspace" })).toBe("");
|
|
47
|
+
expect(applyPinAction("123", { type: "clear" })).toBe("");
|
|
48
|
+
expect(applyPinAction("123", { type: "submit" })).toBe("123");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("tidak melebihi PIN_MAX", () => {
|
|
52
|
+
const full = "4".repeat(PIN_MAX);
|
|
53
|
+
expect(applyPinAction(full, { type: "digit", digit: "1" })).toBe(full);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("digitsFromPaste", () => {
|
|
58
|
+
test("ambil angka saja, digabung, dipotong PIN_MAX", () => {
|
|
59
|
+
expect(digitsFromPaste("48 29-15", "")).toBe("482915");
|
|
60
|
+
expect(digitsFromPaste("15", "4829")).toBe("482915");
|
|
61
|
+
expect(digitsFromPaste("1".repeat(20), "")).toBe("1".repeat(PIN_MAX));
|
|
62
|
+
expect(digitsFromPaste("abc", "12")).toBe("12");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test helper pemilihan model (src/lib/model-picker.ts) — logika murni,
|
|
3
|
+
* pola sama dengan `session-summary.test.ts`.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { ModelOption } from "@/server/services/opencode-client";
|
|
7
|
+
import {
|
|
8
|
+
activeModelLabel,
|
|
9
|
+
DEFAULT_MODEL_KEY,
|
|
10
|
+
describeModelCount,
|
|
11
|
+
filterModels,
|
|
12
|
+
groupModels,
|
|
13
|
+
keyOfSessionModel,
|
|
14
|
+
MODEL_NAME_MAX_LENGTH,
|
|
15
|
+
modelKey,
|
|
16
|
+
optionKey,
|
|
17
|
+
parseModelKey,
|
|
18
|
+
shortModelName,
|
|
19
|
+
} from "../model-picker";
|
|
20
|
+
|
|
21
|
+
function option(
|
|
22
|
+
providerID: string,
|
|
23
|
+
providerName: string,
|
|
24
|
+
modelID: string,
|
|
25
|
+
name: string,
|
|
26
|
+
): ModelOption {
|
|
27
|
+
return { providerID, providerName, modelID, name };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const OPUS = option("kcgcode", "KCG Code", "kiro/claude-opus-5", "Claude Opus 5");
|
|
31
|
+
const SONNET = option("kcgcode", "KCG Code", "kiro/claude-sonnet-4", "Claude Sonnet 4");
|
|
32
|
+
const GPT = option("openai", "OpenAI", "gpt-5", "GPT-5");
|
|
33
|
+
|
|
34
|
+
describe("kunci model", () => {
|
|
35
|
+
test("modelKey memakai pemisah non-slash (modelID boleh memuat '/')", () => {
|
|
36
|
+
const key = modelKey("kcgcode", "kiro/claude-opus-5");
|
|
37
|
+
expect(key).toContain("kiro/claude-opus-5");
|
|
38
|
+
expect(parseModelKey(key)).toEqual({
|
|
39
|
+
providerID: "kcgcode",
|
|
40
|
+
modelID: "kiro/claude-opus-5",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("optionKey konsisten dengan modelKey", () => {
|
|
45
|
+
expect(optionKey(OPUS)).toBe(modelKey(OPUS.providerID, OPUS.modelID));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("keyOfSessionModel: null -> kunci default", () => {
|
|
49
|
+
expect(keyOfSessionModel(null)).toBe(DEFAULT_MODEL_KEY);
|
|
50
|
+
expect(keyOfSessionModel({ providerID: "openai", modelID: "gpt-5" })).toBe(
|
|
51
|
+
modelKey("openai", "gpt-5"),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("parseModelKey: kunci default & kunci tak lengkap -> null", () => {
|
|
56
|
+
expect(parseModelKey(DEFAULT_MODEL_KEY)).toBeNull();
|
|
57
|
+
expect(parseModelKey("")).toBeNull();
|
|
58
|
+
expect(parseModelKey("hanya-provider")).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("round-trip kunci untuk sembarang provider/model", () => {
|
|
62
|
+
for (const m of [OPUS, SONNET, GPT]) {
|
|
63
|
+
expect(parseModelKey(optionKey(m))).toEqual({
|
|
64
|
+
providerID: m.providerID,
|
|
65
|
+
modelID: m.modelID,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("groupModels", () => {
|
|
72
|
+
test("mengelompokkan per provider, urutan kemunculan dipertahankan", () => {
|
|
73
|
+
const groups = groupModels([OPUS, GPT, SONNET]);
|
|
74
|
+
expect(groups.map((g) => g.providerID)).toEqual(["kcgcode", "openai"]);
|
|
75
|
+
expect(groups[0]?.models.map((m) => m.modelID)).toEqual([
|
|
76
|
+
"kiro/claude-opus-5",
|
|
77
|
+
"kiro/claude-sonnet-4",
|
|
78
|
+
]);
|
|
79
|
+
expect(groups[0]?.providerName).toBe("KCG Code");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("daftar kosong -> kosong", () => {
|
|
83
|
+
expect(groupModels([])).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("filterModels", () => {
|
|
88
|
+
const all = [OPUS, SONNET, GPT];
|
|
89
|
+
|
|
90
|
+
test("query kosong / whitespace -> semua model", () => {
|
|
91
|
+
expect(filterModels(all, "")).toHaveLength(3);
|
|
92
|
+
expect(filterModels(all, " ")).toHaveLength(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("cocok pada nama tanpa membedakan huruf besar/kecil", () => {
|
|
96
|
+
expect(filterModels(all, "OPUS").map((m) => m.modelID)).toEqual(["kiro/claude-opus-5"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("cocok pada modelID", () => {
|
|
100
|
+
expect(filterModels(all, "gpt-5").map((m) => m.name)).toEqual(["GPT-5"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("cocok pada nama provider", () => {
|
|
104
|
+
expect(filterModels(all, "openai").map((m) => m.name)).toEqual(["GPT-5"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("beberapa kata: semua harus cocok, urutan bebas", () => {
|
|
108
|
+
expect(filterModels(all, "opus 5").map((m) => m.modelID)).toEqual(["kiro/claude-opus-5"]);
|
|
109
|
+
expect(filterModels(all, "5 opus").map((m) => m.modelID)).toEqual(["kiro/claude-opus-5"]);
|
|
110
|
+
expect(filterModels(all, "claude code").map((m) => m.modelID)).toEqual([
|
|
111
|
+
"kiro/claude-opus-5",
|
|
112
|
+
"kiro/claude-sonnet-4",
|
|
113
|
+
]);
|
|
114
|
+
expect(filterModels(all, "code claude").map((m) => m.modelID)).toEqual([
|
|
115
|
+
"kiro/claude-opus-5",
|
|
116
|
+
"kiro/claude-sonnet-4",
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("tidak ada yang cocok -> kosong", () => {
|
|
121
|
+
expect(filterModels(all, "gemini")).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("input tidak dimutasi", () => {
|
|
125
|
+
const input = [OPUS, GPT];
|
|
126
|
+
filterModels(input, "opus");
|
|
127
|
+
expect(input).toHaveLength(2);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("activeModelLabel", () => {
|
|
132
|
+
test("model null -> label default", () => {
|
|
133
|
+
expect(activeModelLabel(null, [OPUS])).toBe("Default model");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("model dikenal -> nama ramah dari daftar", () => {
|
|
137
|
+
expect(activeModelLabel({ providerID: "kcgcode", modelID: "kiro/claude-opus-5" }, [OPUS])).toBe(
|
|
138
|
+
"Claude Opus 5",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("daftar belum termuat -> jatuh ke modelID (trigger tidak kosong)", () => {
|
|
143
|
+
expect(activeModelLabel({ providerID: "kcgcode", modelID: "kiro/claude-opus-5" }, [])).toBe(
|
|
144
|
+
"kiro/claude-opus-5",
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("describeModelCount", () => {
|
|
150
|
+
test("nol, satu, dan banyak", () => {
|
|
151
|
+
expect(describeModelCount(0)).toBe("No matching models");
|
|
152
|
+
expect(describeModelCount(1)).toBe("1 model");
|
|
153
|
+
expect(describeModelCount(7)).toBe("7 models");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("shortModelName", () => {
|
|
158
|
+
test("prefiks vendor dibuang, segmen terakhir dipakai", () => {
|
|
159
|
+
expect(shortModelName("kiro/claude-opus-5")).toBe("claude-opus-5");
|
|
160
|
+
expect(shortModelName("anthropic/claude-sonnet-4")).toBe("claude-sonnet-4");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("prefiks berlapis: tetap segmen terakhir", () => {
|
|
164
|
+
expect(shortModelName("vendor/family/gpt-5")).toBe("gpt-5");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("tanpa slash dibiarkan apa adanya", () => {
|
|
168
|
+
expect(shortModelName("Claude Opus 5")).toBe("Claude Opus 5");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("trailing slash diabaikan", () => {
|
|
172
|
+
expect(shortModelName("kiro/claude-opus-5/")).toBe("claude-opus-5");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("nama panjang dipotong dengan elipsis, total tepat maxLength", () => {
|
|
176
|
+
const long = "a".repeat(40);
|
|
177
|
+
const out = shortModelName(long, 10);
|
|
178
|
+
expect(out).toHaveLength(10);
|
|
179
|
+
expect(out.endsWith("…")).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("nama tepat sepanjang batas tidak dipotong", () => {
|
|
183
|
+
const exact = "b".repeat(MODEL_NAME_MAX_LENGTH);
|
|
184
|
+
expect(shortModelName(exact)).toBe(exact);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("spasi sebelum elipsis dirapikan", () => {
|
|
188
|
+
expect(shortModelName("Claude Opus Model Panjang", 12)).toBe("Claude Opus…");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("string kosong / hanya slash aman", () => {
|
|
192
|
+
expect(shortModelName("")).toBe("");
|
|
193
|
+
expect(shortModelName("/")).toBe("");
|
|
194
|
+
expect(shortModelName("///")).toBe("");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("maxLength nol atau negatif -> string kosong (tanpa crash)", () => {
|
|
198
|
+
expect(shortModelName("apa pun", 0)).toBe("");
|
|
199
|
+
expect(shortModelName("apa pun", -5)).toBe("");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("label default tetap terbaca", () => {
|
|
203
|
+
expect(shortModelName("Model default")).toBe("Model default");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
greeting,
|
|
4
|
+
nextStep,
|
|
5
|
+
ONBOARDING_STEPS,
|
|
6
|
+
PROGRESS_STEPS,
|
|
7
|
+
prevStep,
|
|
8
|
+
stepDirection,
|
|
9
|
+
} from "../onboarding";
|
|
10
|
+
|
|
11
|
+
describe("alur onboarding", () => {
|
|
12
|
+
test("urutan langkah welcome -> profile -> lock -> done", () => {
|
|
13
|
+
expect([...ONBOARDING_STEPS]).toEqual(["welcome", "profile", "lock", "done"]);
|
|
14
|
+
expect(nextStep("welcome")).toBe("profile");
|
|
15
|
+
expect(nextStep("profile")).toBe("lock");
|
|
16
|
+
expect(nextStep("lock")).toBe("done");
|
|
17
|
+
expect(nextStep("done")).toBe("done");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("mundur berhenti di welcome", () => {
|
|
21
|
+
expect(prevStep("lock")).toBe("profile");
|
|
22
|
+
expect(prevStep("profile")).toBe("welcome");
|
|
23
|
+
expect(prevStep("welcome")).toBe("welcome");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("arah animasi mengikuti navigasi", () => {
|
|
27
|
+
expect(stepDirection("welcome", "profile")).toBe(1);
|
|
28
|
+
expect(stepDirection("lock", "profile")).toBe(-1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("titik progres hanya untuk langkah pengaturan", () => {
|
|
32
|
+
expect([...PROGRESS_STEPS]).toEqual(["profile", "lock"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("sapaan sesuai jam", () => {
|
|
36
|
+
expect(greeting(8)).toBe("Good morning");
|
|
37
|
+
expect(greeting(14)).toBe("Good afternoon");
|
|
38
|
+
expect(greeting(20)).toBe("Good evening");
|
|
39
|
+
expect(greeting(2)).toBe("Good evening");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parsePermissionTitle } from "../permission-info";
|
|
3
|
+
|
|
4
|
+
describe("parsePermissionTitle", () => {
|
|
5
|
+
test("judul server: permission + pola ber-backtick", () => {
|
|
6
|
+
const info = parsePermissionTitle(
|
|
7
|
+
"external_directory — `/home/irsyadulibad/.config/opencode/*`",
|
|
8
|
+
);
|
|
9
|
+
expect(info.permission).toBe("external_directory");
|
|
10
|
+
expect(info.label).toBe("Access a folder outside the project");
|
|
11
|
+
expect(info.targets).toEqual(["/home/irsyadulibad/.config/opencode/*"]);
|
|
12
|
+
expect(info.risk).toBe("high");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("beberapa pola dipisah", () => {
|
|
16
|
+
const info = parsePermissionTitle("edit — `src/a.ts` — `src/b.ts`");
|
|
17
|
+
expect(info.label).toBe("Edit files");
|
|
18
|
+
expect(info.targets).toEqual(["src/a.ts", "src/b.ts"]);
|
|
19
|
+
expect(info.risk).toBe("medium");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("perintah bash yang memuat ' — ' di dalam backtick tidak dipecah sebagai pola baru", () => {
|
|
23
|
+
// Server membungkus tiap pola dengan backtick; pemisah ' — ' hanya di
|
|
24
|
+
// antara pola. Perintah tanpa ' — ' di dalamnya tetap utuh.
|
|
25
|
+
const info = parsePermissionTitle("bash — `mkdir -p /tmp/perm-test`");
|
|
26
|
+
expect(info.label).toBe("Run a shell command");
|
|
27
|
+
expect(info.targets).toEqual(["mkdir -p /tmp/perm-test"]);
|
|
28
|
+
expect(info.risk).toBe("high");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("permission tanpa pola", () => {
|
|
32
|
+
const info = parsePermissionTitle("webfetch");
|
|
33
|
+
expect(info.label).toBe("Fetch a web page");
|
|
34
|
+
expect(info.targets).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("baca-saja = risiko rendah", () => {
|
|
38
|
+
expect(parsePermissionTitle("read — `README.md`").risk).toBe("low");
|
|
39
|
+
expect(parsePermissionTitle("grep — `TODO`").risk).toBe("low");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("permission tak dikenal: nama ditampilkan (bukan disembunyikan), risiko sedang", () => {
|
|
43
|
+
const info = parsePermissionTitle("future_tool — `x`");
|
|
44
|
+
expect(info.permission).toBe("future_tool");
|
|
45
|
+
expect(info.label).toBe("Future tool");
|
|
46
|
+
expect(info.risk).toBe("medium");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("judul kosong / fallback server -> label umum", () => {
|
|
50
|
+
for (const t of [null, "", " ", "Izin tool"]) {
|
|
51
|
+
const info = parsePermissionTitle(t);
|
|
52
|
+
expect(info.permission).toBeNull();
|
|
53
|
+
expect(info.label).toBe("Use a tool");
|
|
54
|
+
expect(info.targets).toEqual([]);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("nama permission tidak peka huruf besar", () => {
|
|
59
|
+
expect(parsePermissionTitle("BASH — `ls`").label).toBe("Run a shell command");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test helper panel samping Project (src/lib/project-extensions.ts).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import type { SkillInfo } from "@/server/services/opencode-client";
|
|
6
|
+
import { describeMcpSummary, filterSkills, groupSkills } from "../project-extensions";
|
|
7
|
+
|
|
8
|
+
function skill(name: string, source: SkillInfo["source"], description: string | null = null) {
|
|
9
|
+
return { name, description, location: null, source } satisfies SkillInfo;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("describeMcpSummary", () => {
|
|
13
|
+
test("kosong -> None configured", () => {
|
|
14
|
+
expect(describeMcpSummary([])).toBe("None configured");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("hitung yang connected", () => {
|
|
18
|
+
expect(
|
|
19
|
+
describeMcpSummary([
|
|
20
|
+
{ name: "a", status: "connected", error: null },
|
|
21
|
+
{ name: "b", status: "failed", error: "x" },
|
|
22
|
+
{ name: "c", status: "connected", error: null },
|
|
23
|
+
]),
|
|
24
|
+
).toBe("2 of 3 connected");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("groupSkills", () => {
|
|
29
|
+
test("urutan project -> global -> builtin, kelompok kosong dibuang", () => {
|
|
30
|
+
const groups = groupSkills([
|
|
31
|
+
skill("b1", "builtin"),
|
|
32
|
+
skill("p1", "project"),
|
|
33
|
+
skill("p2", "project"),
|
|
34
|
+
]);
|
|
35
|
+
expect(groups.map((g) => g.source)).toEqual(["project", "builtin"]);
|
|
36
|
+
expect(groups[0]?.skills.map((s) => s.name)).toEqual(["p1", "p2"]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("filterSkills", () => {
|
|
41
|
+
const all = [skill("shadcn", "project", "UI components"), skill("firecrawl", "global", "Web")];
|
|
42
|
+
|
|
43
|
+
test("query kosong -> semua", () => {
|
|
44
|
+
expect(filterSkills(all, " ")).toHaveLength(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("cocok nama atau deskripsi, case-insensitive", () => {
|
|
48
|
+
expect(filterSkills(all, "SHAD").map((s) => s.name)).toEqual(["shadcn"]);
|
|
49
|
+
expect(filterSkills(all, "web").map((s) => s.name)).toEqual(["firecrawl"]);
|
|
50
|
+
expect(filterSkills(all, "zzz")).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test ringkasan Project homepage (src/lib/project-overview.ts).
|
|
3
|
+
*
|
|
4
|
+
* Yang diuji: penggabungan Project + Session, urutan aktivitas terbaru,
|
|
5
|
+
* filter pencarian, dan label waktu relatif — semuanya logika murni
|
|
6
|
+
* (pola yang sama dengan `routes.test.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Project, Session, SessionStatus } from "@/types";
|
|
10
|
+
import {
|
|
11
|
+
buildProjectOverviews,
|
|
12
|
+
filterProjectOverviews,
|
|
13
|
+
formatRelativeTime,
|
|
14
|
+
totalRunningSessions,
|
|
15
|
+
} from "../project-overview";
|
|
16
|
+
|
|
17
|
+
function project(id: string, name: string, createdAt: number, path = `/sandbox/${name}`): Project {
|
|
18
|
+
return { id, name, path, createdAt };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function session(id: string, projectId: string, status: SessionStatus, updatedAt: number): Session {
|
|
22
|
+
return {
|
|
23
|
+
id,
|
|
24
|
+
projectId,
|
|
25
|
+
agentType: "opencode",
|
|
26
|
+
cwd: "/sandbox",
|
|
27
|
+
status,
|
|
28
|
+
ocSessionId: null,
|
|
29
|
+
title: null,
|
|
30
|
+
model: null,
|
|
31
|
+
agent: null,
|
|
32
|
+
createdAt: updatedAt,
|
|
33
|
+
updatedAt,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("buildProjectOverviews", () => {
|
|
38
|
+
test("menghitung jumlah Session dan Session berjalan per Project", () => {
|
|
39
|
+
const projects = [project("p1", "alpha", 1_000), project("p2", "beta", 2_000)];
|
|
40
|
+
const sessions = [
|
|
41
|
+
session("s1", "p1", "running", 5_000),
|
|
42
|
+
session("s2", "p1", "stopped", 4_000),
|
|
43
|
+
session("s3", "p2", "crashed", 3_000),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const [first, second] = buildProjectOverviews(projects, sessions);
|
|
47
|
+
|
|
48
|
+
expect(first?.project.id).toBe("p1");
|
|
49
|
+
expect(first?.sessionCount).toBe(2);
|
|
50
|
+
expect(first?.runningCount).toBe(1);
|
|
51
|
+
expect(first?.lastSession?.id).toBe("s1");
|
|
52
|
+
expect(first?.lastActivityAt).toBe(5_000);
|
|
53
|
+
|
|
54
|
+
expect(second?.project.id).toBe("p2");
|
|
55
|
+
expect(second?.runningCount).toBe(0);
|
|
56
|
+
expect(second?.lastSession?.id).toBe("s3");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("Project tanpa Session memakai createdAt sebagai aktivitas terakhir", () => {
|
|
60
|
+
const [only] = buildProjectOverviews([project("p1", "alpha", 7_000)], []);
|
|
61
|
+
expect(only?.sessionCount).toBe(0);
|
|
62
|
+
expect(only?.lastSession).toBeNull();
|
|
63
|
+
expect(only?.lastActivityAt).toBe(7_000);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("urutan: aktivitas terbaru di atas", () => {
|
|
67
|
+
const projects = [
|
|
68
|
+
project("p1", "alpha", 1_000),
|
|
69
|
+
project("p2", "beta", 2_000),
|
|
70
|
+
project("p3", "gamma", 3_000),
|
|
71
|
+
];
|
|
72
|
+
const sessions = [session("s1", "p1", "stopped", 9_000)];
|
|
73
|
+
|
|
74
|
+
const ids = buildProjectOverviews(projects, sessions).map((o) => o.project.id);
|
|
75
|
+
expect(ids).toEqual(["p1", "p3", "p2"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("aktivitas sama diurut berdasarkan nama (case-insensitive)", () => {
|
|
79
|
+
const projects = [project("p1", "Zeta", 1_000), project("p2", "alpha", 1_000)];
|
|
80
|
+
const ids = buildProjectOverviews(projects, []).map((o) => o.project.id);
|
|
81
|
+
expect(ids).toEqual(["p2", "p1"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("Session dengan projectId tak dikenal diabaikan", () => {
|
|
85
|
+
const overviews = buildProjectOverviews(
|
|
86
|
+
[project("p1", "alpha", 1_000)],
|
|
87
|
+
[session("s1", "ghost", "running", 9_000)],
|
|
88
|
+
);
|
|
89
|
+
expect(overviews).toHaveLength(1);
|
|
90
|
+
expect(overviews[0]?.sessionCount).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("daftar Project kosong -> hasil kosong", () => {
|
|
94
|
+
expect(buildProjectOverviews([], [session("s1", "p1", "running", 1)])).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("filterProjectOverviews", () => {
|
|
99
|
+
const overviews = buildProjectOverviews(
|
|
100
|
+
[
|
|
101
|
+
project("p1", "web-app", 1_000, "/sandbox/web-app"),
|
|
102
|
+
project("p2", "api-service", 2_000, "/sandbox/backend/api"),
|
|
103
|
+
],
|
|
104
|
+
[],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
test("query kosong mengembalikan semua", () => {
|
|
108
|
+
expect(filterProjectOverviews(overviews, "")).toHaveLength(2);
|
|
109
|
+
expect(filterProjectOverviews(overviews, " ")).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("cocok pada nama tanpa membedakan huruf besar/kecil", () => {
|
|
113
|
+
const found = filterProjectOverviews(overviews, "WEB");
|
|
114
|
+
expect(found.map((o) => o.project.id)).toEqual(["p1"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("cocok pada path", () => {
|
|
118
|
+
const found = filterProjectOverviews(overviews, "backend");
|
|
119
|
+
expect(found.map((o) => o.project.id)).toEqual(["p2"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("tidak ada yang cocok -> kosong", () => {
|
|
123
|
+
expect(filterProjectOverviews(overviews, "zzz")).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("totalRunningSessions", () => {
|
|
128
|
+
test("menjumlahkan Session berjalan seluruh Project", () => {
|
|
129
|
+
const overviews = buildProjectOverviews(
|
|
130
|
+
[project("p1", "alpha", 1), project("p2", "beta", 2)],
|
|
131
|
+
[
|
|
132
|
+
session("s1", "p1", "running", 10),
|
|
133
|
+
session("s2", "p1", "running", 11),
|
|
134
|
+
session("s3", "p2", "stopped", 12),
|
|
135
|
+
],
|
|
136
|
+
);
|
|
137
|
+
expect(totalRunningSessions(overviews)).toBe(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("tanpa Session -> 0", () => {
|
|
141
|
+
expect(totalRunningSessions([])).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("formatRelativeTime", () => {
|
|
146
|
+
const now = Date.UTC(2026, 0, 15, 12, 0, 0);
|
|
147
|
+
const minute = 60_000;
|
|
148
|
+
|
|
149
|
+
test("kurang dari satu menit -> just now", () => {
|
|
150
|
+
expect(formatRelativeTime(now - 30_000, now)).toBe("just now");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("timestamp di masa depan tetap just now", () => {
|
|
154
|
+
expect(formatRelativeTime(now + minute, now)).toBe("just now");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("skala menit, jam, dan hari", () => {
|
|
158
|
+
expect(formatRelativeTime(now - 5 * minute, now)).toBe("5 minutes ago");
|
|
159
|
+
expect(formatRelativeTime(now - 3 * 60 * minute, now)).toBe("3 hours ago");
|
|
160
|
+
expect(formatRelativeTime(now - 2 * 24 * 60 * minute, now)).toBe("2 days ago");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("lebih dari sepekan -> tanggal absolut", () => {
|
|
164
|
+
const old = now - 30 * 24 * 60 * minute;
|
|
165
|
+
expect(formatRelativeTime(old, now)).toBe(
|
|
166
|
+
new Date(old).toLocaleDateString(undefined, {
|
|
167
|
+
day: "2-digit",
|
|
168
|
+
month: "short",
|
|
169
|
+
year: "numeric",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|