Skip to content

Commit 398c82c

Browse files
author
QuantCode Agent
committed
feat: implement remove, update, sortBy, and truncate with comprehensive tests
- TaskManager.remove(id): removes task by ID, returns true/false - TaskManager.update(id, changes): updates title/description/priority only, returns updated task or undefined - TaskManager.sortBy(field): sorts by priority (high>medium>low), status (in_progress>pending>completed), or createdAt (ascending) - truncate(str, maxLength): word-boundary truncation with '...' counting towards maxLength - 36 tests across 3 files, all passing
1 parent 18a7ef6 commit 398c82c

4 files changed

Lines changed: 290 additions & 6 deletions

File tree

src/string-utils.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,29 @@ export function reverse(str: string): string {
1111
return str.split("").reverse().join("")
1212
}
1313

14-
// TODO: implement truncate function
15-
// Should truncate a string to maxLength and add "..." if truncated
16-
// Should not truncate in the middle of a word
14+
export function truncate(str: string, maxLength: number): string {
15+
if (maxLength <= 0) return ""
16+
if (str.length <= maxLength) return str
17+
if (maxLength <= 3) return ".".repeat(maxLength)
18+
19+
// We need to fit text + "..." within maxLength
20+
const available = maxLength - 3
21+
const candidate = str.slice(0, available)
22+
23+
// If the character right after the cut is a space or we're at end, clean word boundary
24+
if (str[available] === " " || available >= str.length) {
25+
return candidate + "..."
26+
}
27+
28+
// Find last space in candidate to avoid cutting mid-word
29+
const lastSpace = candidate.lastIndexOf(" ")
30+
if (lastSpace === -1) {
31+
// No space found — truncate at character level
32+
return candidate + "..."
33+
}
34+
35+
return candidate.slice(0, lastSpace) + "..."
36+
}
1737

1838
export function slugify(str: string): string {
1939
return str

src/task-manager.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,29 @@ export class TaskManager {
5252
return true
5353
}
5454

55-
// TODO: implement remove method
56-
// TODO: implement update method to change title/description/priority
57-
// TODO: implement sortBy method (by priority, createdAt, or status)
55+
remove(id: string): boolean {
56+
return this.tasks.delete(id)
57+
}
58+
59+
update(id: string, changes: Partial<Pick<Task, "title" | "description" | "priority">>): Task | undefined {
60+
const task = this.tasks.get(id)
61+
if (!task) return undefined
62+
if (changes.title !== undefined) task.title = changes.title
63+
if (changes.description !== undefined) task.description = changes.description
64+
if (changes.priority !== undefined) task.priority = changes.priority
65+
return task
66+
}
67+
68+
sortBy(field: "priority" | "createdAt" | "status"): Task[] {
69+
const tasks = Array.from(this.tasks.values())
70+
const priorityOrder: Record<Priority, number> = { high: 0, medium: 1, low: 2 }
71+
const statusOrder: Record<Status, number> = { in_progress: 0, pending: 1, completed: 2 }
72+
73+
return tasks.slice().sort((a, b) => {
74+
if (field === "priority") return priorityOrder[a.priority] - priorityOrder[b.priority]
75+
if (field === "status") return statusOrder[a.status] - statusOrder[b.status]
76+
// createdAt ascending
77+
return a.createdAt.getTime() - b.createdAt.getTime()
78+
})
79+
}
5880
}

test/string-utils.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { truncate } from "../src/string-utils"
3+
4+
describe("truncate", () => {
5+
test("returns string unchanged when shorter than maxLength", () => {
6+
expect(truncate("hello", 10)).toBe("hello")
7+
})
8+
9+
test("returns string unchanged when exactly equal to maxLength", () => {
10+
expect(truncate("hello", 5)).toBe("hello")
11+
})
12+
13+
test("truncates at word boundary, not mid-word", () => {
14+
// "Hello world" (11 chars), maxLength=8 → available=5 → "Hello" + "..." = "Hello..."
15+
expect(truncate("Hello world", 8)).toBe("Hello...")
16+
})
17+
18+
test("appended '...' counts toward maxLength", () => {
19+
const result = truncate("Hello world foo", 8)
20+
expect(result.length).toBeLessThanOrEqual(8)
21+
expect(result.endsWith("...")).toBe(true)
22+
})
23+
24+
test("truncates a longer sentence at word boundary", () => {
25+
// "The quick brown fox" maxLength=13 → available=10 → "The quick " → last space at 9
26+
// "The quick" + "..." = "The quick..." (12 chars ≤ 13)
27+
const result = truncate("The quick brown fox", 13)
28+
expect(result).toBe("The quick...")
29+
expect(result.length).toBeLessThanOrEqual(13)
30+
})
31+
32+
test("edge case: maxLength=0 returns empty string", () => {
33+
expect(truncate("hello", 0)).toBe("")
34+
})
35+
36+
test("edge case: maxLength=1 returns '.'", () => {
37+
expect(truncate("hello", 1)).toBe(".")
38+
})
39+
40+
test("edge case: maxLength=2 returns '..'", () => {
41+
expect(truncate("hello", 2)).toBe("..")
42+
})
43+
44+
test("edge case: maxLength=3 returns '...'", () => {
45+
expect(truncate("hello world", 3)).toBe("...")
46+
})
47+
48+
test("no spaces before cut point — truncates at character level", () => {
49+
// "superlongword" maxLength=8 → available=5 → "super" no space → "super..."
50+
expect(truncate("superlongword", 8)).toBe("super...")
51+
})
52+
53+
test("empty string returns empty string", () => {
54+
expect(truncate("", 10)).toBe("")
55+
})
56+
57+
test("empty string with maxLength=0 returns empty string", () => {
58+
expect(truncate("", 0)).toBe("")
59+
})
60+
61+
test("does not leave trailing space before '...'", () => {
62+
// "Hello world" maxLength=9 → available=6 → "Hello " → last space at 5 → "Hello" + "..."
63+
const result = truncate("Hello world", 9)
64+
expect(result).not.toMatch(/ \.\.\./)
65+
expect(result.endsWith("...")).toBe(true)
66+
})
67+
68+
test("word at exact boundary — no truncation needed", () => {
69+
// "Hi there" = 8 chars, maxLength=8 → unchanged
70+
expect(truncate("Hi there", 8)).toBe("Hi there")
71+
})
72+
})

test/task-manager.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, test, expect, beforeEach } from "bun:test"
2+
import { TaskManager } from "../src/task-manager"
3+
4+
describe("TaskManager", () => {
5+
let tm: TaskManager
6+
7+
beforeEach(() => {
8+
tm = new TaskManager()
9+
})
10+
11+
// ── remove ────────────────────────────────────────────────────────────────
12+
13+
describe("remove", () => {
14+
test("returns true and removes an existing task", () => {
15+
const task = tm.add("Task A")
16+
expect(tm.remove(task.id)).toBe(true)
17+
expect(tm.get(task.id)).toBeUndefined()
18+
})
19+
20+
test("returns false for a non-existent id", () => {
21+
expect(tm.remove("999")).toBe(false)
22+
})
23+
24+
test("task is no longer in list after removal", () => {
25+
const task = tm.add("Task B")
26+
tm.remove(task.id)
27+
expect(tm.list().find((t) => t.id === task.id)).toBeUndefined()
28+
})
29+
30+
test("removing one task does not affect others", () => {
31+
const a = tm.add("A")
32+
const b = tm.add("B")
33+
tm.remove(a.id)
34+
expect(tm.get(b.id)).toBeDefined()
35+
})
36+
})
37+
38+
// ── update ────────────────────────────────────────────────────────────────
39+
40+
describe("update", () => {
41+
test("updates title", () => {
42+
const task = tm.add("Original")
43+
const updated = tm.update(task.id, { title: "Updated" })
44+
expect(updated?.title).toBe("Updated")
45+
})
46+
47+
test("updates description", () => {
48+
const task = tm.add("Task", "medium", "old desc")
49+
const updated = tm.update(task.id, { description: "new desc" })
50+
expect(updated?.description).toBe("new desc")
51+
})
52+
53+
test("updates priority", () => {
54+
const task = tm.add("Task", "low")
55+
const updated = tm.update(task.id, { priority: "high" })
56+
expect(updated?.priority).toBe("high")
57+
})
58+
59+
test("updates multiple fields at once", () => {
60+
const task = tm.add("Old title", "low", "old desc")
61+
const updated = tm.update(task.id, { title: "New title", priority: "high", description: "new desc" })
62+
expect(updated?.title).toBe("New title")
63+
expect(updated?.priority).toBe("high")
64+
expect(updated?.description).toBe("new desc")
65+
})
66+
67+
test("returns undefined for non-existent id", () => {
68+
expect(tm.update("999", { title: "X" })).toBeUndefined()
69+
})
70+
71+
test("does not change id", () => {
72+
const task = tm.add("Task")
73+
const originalId = task.id
74+
tm.update(task.id, { title: "New" })
75+
expect(tm.get(originalId)?.id).toBe(originalId)
76+
})
77+
78+
test("does not change status", () => {
79+
const task = tm.add("Task")
80+
tm.complete(task.id)
81+
tm.update(task.id, { title: "New" })
82+
expect(tm.get(task.id)?.status).toBe("completed")
83+
})
84+
85+
test("does not change createdAt", () => {
86+
const task = tm.add("Task")
87+
const originalCreatedAt = task.createdAt
88+
tm.update(task.id, { title: "New" })
89+
expect(tm.get(task.id)?.createdAt).toEqual(originalCreatedAt)
90+
})
91+
92+
test("does not change completedAt", () => {
93+
const task = tm.add("Task")
94+
tm.complete(task.id)
95+
const completedAt = tm.get(task.id)!.completedAt
96+
tm.update(task.id, { title: "New" })
97+
expect(tm.get(task.id)?.completedAt).toEqual(completedAt)
98+
})
99+
100+
test("returns the updated task object", () => {
101+
const task = tm.add("Task")
102+
const result = tm.update(task.id, { title: "Updated" })
103+
expect(result).toBeDefined()
104+
expect(result?.title).toBe("Updated")
105+
})
106+
})
107+
108+
// ── sortBy ────────────────────────────────────────────────────────────────
109+
110+
describe("sortBy", () => {
111+
test("sorts by priority: high first, then medium, then low", () => {
112+
tm.add("Low task", "low")
113+
tm.add("High task", "high")
114+
tm.add("Medium task", "medium")
115+
116+
const sorted = tm.sortBy("priority")
117+
expect(sorted[0].priority).toBe("high")
118+
expect(sorted[1].priority).toBe("medium")
119+
expect(sorted[2].priority).toBe("low")
120+
})
121+
122+
test("sorts by status: in_progress first, then pending, then completed", () => {
123+
const t1 = tm.add("Pending task")
124+
const t2 = tm.add("Completed task")
125+
const t3 = tm.add("In-progress task")
126+
tm.complete(t2.id)
127+
// manually set in_progress
128+
const task3 = tm.get(t3.id)!
129+
task3.status = "in_progress"
130+
131+
const sorted = tm.sortBy("status")
132+
expect(sorted[0].status).toBe("in_progress")
133+
expect(sorted[1].status).toBe("pending")
134+
expect(sorted[2].status).toBe("completed")
135+
})
136+
137+
test("sorts by createdAt ascending (earliest first)", async () => {
138+
const t1 = tm.add("First")
139+
await new Promise((r) => setTimeout(r, 5))
140+
const t2 = tm.add("Second")
141+
await new Promise((r) => setTimeout(r, 5))
142+
const t3 = tm.add("Third")
143+
144+
const sorted = tm.sortBy("createdAt")
145+
expect(sorted[0].id).toBe(t1.id)
146+
expect(sorted[1].id).toBe(t2.id)
147+
expect(sorted[2].id).toBe(t3.id)
148+
})
149+
150+
test("returns a new array (does not mutate internal state)", () => {
151+
tm.add("Low", "low")
152+
tm.add("High", "high")
153+
154+
const before = tm.list()
155+
const sorted = tm.sortBy("priority")
156+
157+
// sorted is a different array reference
158+
expect(sorted).not.toBe(before)
159+
// internal list order is unchanged
160+
expect(tm.list()[0].priority).toBe("low")
161+
})
162+
163+
test("returns all tasks when sorting", () => {
164+
tm.add("A", "high")
165+
tm.add("B", "medium")
166+
tm.add("C", "low")
167+
expect(tm.sortBy("priority")).toHaveLength(3)
168+
})
169+
})
170+
})

0 commit comments

Comments
 (0)