eletrotupi / tcc/ commit / dcee93d

frontend: test the history mappers and feed helpers

Add device-free tests for the four history card mappers and the feed's
merge, sort, group, and filter helpers. This pins the mood, sleep,
trigger, and care-action summaries, badges, and label fallbacks — the
exact 7 and 5 hour sleep thresholds, the link pluralisation, and the
mood-summary shapes — so the chronological feed stops depending on a
device to check. Also drop a stray debug log from the sleep mapper
Pedro Lucas Porcellis porcellis@eletrotupi.com · 9 days ago dcee93d0c11ceff985805b7785c7db54c2acc4dd Browse files
Parents b0167ef
Changed files
3
Additions
+525
Deletions
-1
frontend/lib/history/mappers.ts
1 1
import type { HistoryCard } from "@/lib/history/types";
2 2
import type { MoodEntry, SleepRecord, Trigger, CareAction } from "@/lib/api";
3 3
import { getMood } from "@/constants/moods";
4 4

5 5

35 35
    variant: "red",
36 36
  };
37 37
}
38 38

39 39
export function mapSleepToHistoryCard(record: SleepRecord): HistoryCard {
40 -
  console.log("mapping...", record)
41 40
  const hours = Math.floor(record.average);
42 41
  const minutes = Math.round((record.average - hours) * 60);
43 42
  const duration = `${hours}h ${minutes.toString().padStart(2, "0")}min`;
44 43
  const timestamp = typeof record.createdAt === 'string'
45 44
    ? record.createdAt
160 159
        summary: "",
161 160
        raw: record,
162 161
      };
163 162
  }
164 163
}
/dev/null → frontend/tests/history/mappers.unit.test.ts
1 +
import {
2 +
  mapCareActionToHistoryCard,
3 +
  mapMoodToHistoryCard,
4 +
  mapSleepToHistoryCard,
5 +
  mapTriggerToHistoryCard,
6 +
} from "@/lib/history/mappers";
7 +
import type {
8 +
  ActivityType,
9 +
  AppointmentType,
10 +
  CareAction,
11 +
  MoodEntry,
12 +
  SleepRecord,
13 +
  Trigger,
14 +
} from "@/lib/api";
15 +
import type { MoodComponent } from "@/lib/api/types";
16 +

17 +
const FIXED = new Date(2025, 0, 15, 10, 0, 0);
18 +

19 +
function mood(overrides: Partial<MoodEntry> = {}): MoodEntry {
20 +
  return {
21 +
    id: 1,
22 +
    annotation: "",
23 +
    moment: FIXED,
24 +
    selectedMood: "good",
25 +
    anxietyLevel: 2,
26 +
    energyLevel: 4,
27 +
    stressLevel: 1,
28 +
    moodComponents: [],
29 +
    createdAt: FIXED,
30 +
    updatedAt: FIXED,
31 +
    ...overrides,
32 +
  };
33 +
}
34 +

35 +
function moodComponent(component: string): MoodComponent {
36 +
  return {
37 +
    id: 1,
38 +
    component,
39 +
    intensity: "MILD",
40 +
    createdAt: FIXED,
41 +
    updatedAt: FIXED,
42 +
  };
43 +
}
44 +

45 +
function sleep(overrides: Partial<SleepRecord> = {}): SleepRecord {
46 +
  return {
47 +
    id: 1,
48 +
    annotations: "",
49 +
    date: FIXED,
50 +
    average: 8,
51 +
    createdAt: FIXED,
52 +
    updatedAt: FIXED,
53 +
    ...overrides,
54 +
  };
55 +
}
56 +

57 +
function trigger(overrides: Partial<Trigger> = {}): Trigger {
58 +
  return {
59 +
    id: 1,
60 +
    comment: "",
61 +
    category: "Trabalho",
62 +
    moment: FIXED,
63 +
    createdAt: FIXED,
64 +
    updatedAt: FIXED,
65 +
    ...overrides,
66 +
  };
67 +
}
68 +

69 +
function careAction(overrides: Partial<CareAction> = {}): CareAction {
70 +
  return {
71 +
    id: 1,
72 +
    type: "MEDICINE",
73 +
    moment: FIXED,
74 +
    userId: 1,
75 +
    createdAt: FIXED,
76 +
    updatedAt: FIXED,
77 +
    ...overrides,
78 +
  };
79 +
}
80 +

81 +
describe("mapMoodToHistoryCard", () => {
82 +
  it("maps the record into a mood card", () => {
83 +
    const record = mood({
84 +
      id: 7,
85 +
      annotation: "Me senti bem hoje",
86 +
      moment: new Date(2025, 5, 10, 14, 30),
87 +
      selectedMood: "great",
88 +
      moodComponents: [moodComponent("ANXIOUS"), moodComponent("CALM")],
89 +
    });
90 +

91 +
    expect(mapMoodToHistoryCard(record)).toStrictEqual({
92 +
      id: "mood-7",
93 +
      category: "mood",
94 +
      timestamp: record.moment.toString(),
95 +
      title: "Ótimo",
96 +
      summary: "Anxious, Calm · Me senti bem hoje",
97 +
      badge: undefined,
98 +
      raw: record,
99 +
    });
100 +
  });
101 +

102 +
  it("falls back to a generic title for an unknown mood", () => {
103 +
    expect(mapMoodToHistoryCard(mood({ selectedMood: "unmapped" })).title).toBe(
104 +
      "Humor",
105 +
    );
106 +
  });
107 +

108 +
  describe("summary", () => {
109 +
    it("joins only the components when there is no annotation", () => {
110 +
      const card = mapMoodToHistoryCard(
111 +
        mood({
112 +
          annotation: "",
113 +
          moodComponents: [moodComponent("ANXIOUS"), moodComponent("CALM")],
114 +
        }),
115 +
      );
116 +

117 +
      expect(card.summary).toBe("Anxious, Calm");
118 +
    });
119 +

120 +
    it("uses only the annotation when there are no components", () => {
121 +
      const card = mapMoodToHistoryCard(
122 +
        mood({ annotation: "Tarde tranquila", moodComponents: [] }),
123 +
      );
124 +

125 +
      expect(card.summary).toBe("Tarde tranquila");
126 +
    });
127 +

128 +
    it("joins components and annotation when both are present", () => {
129 +
      const card = mapMoodToHistoryCard(
130 +
        mood({
131 +
          annotation: "Tarde tranquila",
132 +
          moodComponents: [moodComponent("CALM")],
133 +
        }),
134 +
      );
135 +

136 +
      expect(card.summary).toBe("Calm · Tarde tranquila");
137 +
    });
138 +

139 +
    it("falls back when neither is present", () => {
140 +
      const card = mapMoodToHistoryCard(
141 +
        mood({ annotation: "", moodComponents: [] }),
142 +
      );
143 +

144 +
      expect(card.summary).toBe("Sem detalhes");
145 +
    });
146 +
  });
147 +
});
148 +

149 +
describe("mapSleepToHistoryCard", () => {
150 +
  it("maps the record into a sleep card", () => {
151 +
    const record = sleep({
152 +
      id: 4,
153 +
      average: 7.5,
154 +
      annotations: "Dormi bem",
155 +
      createdAt: new Date(2025, 0, 15, 8, 0, 0),
156 +
    });
157 +

158 +
    expect(mapSleepToHistoryCard(record)).toStrictEqual({
159 +
      id: "sleep-4",
160 +
      category: "sleep",
161 +
      timestamp: record.createdAt.toISOString(),
162 +
      title: "Sono",
163 +
      summary: "7h 30min · Dormi bem",
164 +
      badge: { label: "Excelente", variant: "green" },
165 +
      raw: record,
166 +
    });
167 +
  });
168 +

169 +
  it("keeps a string timestamp as-is", () => {
170 +
    const timestamp = "2025-01-15T13:00:00.000Z";
171 +
    const card = mapSleepToHistoryCard(
172 +
      sleep({ createdAt: timestamp as unknown as Date }),
173 +
    );
174 +

175 +
    expect(card.timestamp).toBe(timestamp);
176 +
  });
177 +

178 +
  it("omits the separator when there is no annotation", () => {
179 +
    expect(mapSleepToHistoryCard(sleep({ average: 8 })).summary).toBe(
180 +
      "8h 00min",
181 +
    );
182 +
  });
183 +

184 +
  describe("quality badge thresholds", () => {
185 +
    it.each([
186 +
      [9, "Excelente", "green"],
187 +
      [7, "Excelente", "green"],
188 +
      [6.99, "Regular", "yellow"],
189 +
      [5, "Regular", "yellow"],
190 +
      [4.99, "Ruim", "red"],
191 +
      [0, "Ruim", "red"],
192 +
    ])("labels %p hours as %p", (average, label, variant) => {
193 +
      expect(mapSleepToHistoryCard(sleep({ average })).badge).toEqual({
194 +
        label,
195 +
        variant,
196 +
      });
197 +
    });
198 +
  });
199 +

200 +
  it("pads the minutes to two digits", () => {
201 +
    expect(mapSleepToHistoryCard(sleep({ average: 6.5 })).summary).toBe(
202 +
      "6h 30min",
203 +
    );
204 +
    expect(mapSleepToHistoryCard(sleep({ average: 6.0167 })).summary).toBe(
205 +
      "6h 01min",
206 +
    );
207 +
  });
208 +
});
209 +

210 +
describe("mapTriggerToHistoryCard", () => {
211 +
  it("maps the record into a trigger card", () => {
212 +
    const record = trigger({
213 +
      id: 3,
214 +
      category: "Trabalho",
215 +
      comment: "Muito estresse",
216 +
      moment: new Date(2025, 2, 1, 9, 0, 0),
217 +
    });
218 +

219 +
    expect(mapTriggerToHistoryCard(record)).toStrictEqual({
220 +
      id: "trigger-3",
221 +
      category: "trigger",
222 +
      timestamp: record.moment.toString(),
223 +
      title: "Gatilho",
224 +
      summary: "Trabalho · Muito estresse",
225 +
      badge: undefined,
226 +
      raw: record,
227 +
    });
228 +
  });
229 +

230 +
  it("drops the separator when there is no comment", () => {
231 +
    const card = mapTriggerToHistoryCard(
232 +
      trigger({ category: "Sono", comment: "" }),
233 +
    );
234 +

235 +
    expect(card.summary).toBe("Sono");
236 +
  });
237 +
});
238 +

239 +
describe("mapCareActionToHistoryCard", () => {
240 +
  describe("medicine", () => {
241 +
    it("uses the regimen name and dosage", () => {
242 +
      const record = careAction({
243 +
        id: 10,
244 +
        type: "MEDICINE",
245 +
        medicineLog: {
246 +
          id: 1,
247 +
          careActionId: 10,
248 +
          takenAt: FIXED,
249 +
          regimen: {
250 +
            id: 1,
251 +
            name: "Ritalina",
252 +
            dosage: "10mg",
253 +
            periodicity: "DAILY",
254 +
            scheduledAt: [],
255 +
            active: true,
256 +
            userId: 1,
257 +
            createdAt: FIXED,
258 +
            updatedAt: FIXED,
259 +
          },
260 +
        },
261 +
      });
262 +

263 +
      expect(mapCareActionToHistoryCard(record)).toStrictEqual({
264 +
        id: "care_action-10",
265 +
        category: "care_action",
266 +
        subtype: "medicine",
267 +
        timestamp: record.moment.toString(),
268 +
        title: "Ritalina",
269 +
        summary: "10mg",
270 +
        raw: record,
271 +
      });
272 +
    });
273 +

274 +
    it("falls back when there is no regimen", () => {
275 +
      const card = mapCareActionToHistoryCard(careAction({ type: "MEDICINE" }));
276 +

277 +
      expect(card.title).toBe("Medicamento");
278 +
      expect(card.summary).toBe("Sem dosagem registrada");
279 +
    });
280 +
  });
281 +

282 +
  describe("appointment", () => {
283 +
    it("joins the duration and the note", () => {
284 +
      const record = careAction({
285 +
        id: 20,
286 +
        type: "APPOINTMENT",
287 +
        appointment: {
288 +
          id: 1,
289 +
          careActionId: 20,
290 +
          type: "ANALYST",
291 +
          duration: 50,
292 +
          note: "Boa sessão",
293 +
        },
294 +
      });
295 +

296 +
      expect(mapCareActionToHistoryCard(record)).toStrictEqual({
297 +
        id: "care_action-20",
298 +
        category: "care_action",
299 +
        subtype: "appointment",
300 +
        timestamp: record.moment.toString(),
301 +
        title: "Consulta · Analista",
302 +
        summary: "50 min · Boa sessão",
303 +
        badge: undefined,
304 +
        raw: record,
305 +
      });
306 +
    });
307 +

308 +
    it("falls back when there are no details", () => {
309 +
      const card = mapCareActionToHistoryCard(
310 +
        careAction({ type: "APPOINTMENT", appointment: undefined }),
311 +
      );
312 +

313 +
      expect(card.summary).toBe("Sem anotações");
314 +
      expect(card.title).toBe("Consulta · Consulta");
315 +
    });
316 +

317 +
    it.each([
318 +
      ["ANALYST", "Analista"],
319 +
      ["PSYCHIATRIST", "Psiquiatra"],
320 +
      ["GP", "Clínico geral"],
321 +
      ["NUTRITIONIST", "Nutricionista"],
322 +
      ["PHYSIOTHERAPIST", "Fisioterapeuta"],
323 +
      ["OTHER", "Consulta"],
324 +
      ["unmapped", "Consulta"],
325 +
    ] as const)("labels the %p appointment as %p", (type, label) => {
326 +
      const card = mapCareActionToHistoryCard(
327 +
        careAction({
328 +
          type: "APPOINTMENT",
329 +
          appointment: {
330 +
            id: 1,
331 +
            careActionId: 1,
332 +
            type: type as AppointmentType,
333 +
            duration: 30,
334 +
          },
335 +
        }),
336 +
      );
337 +

338 +
      expect(card.title).toBe(`Consulta · ${label}`);
339 +
    });
340 +

341 +
    describe("vínculo badge", () => {
342 +
      it("is absent with no links", () => {
343 +
        expect(
344 +
          mapCareActionToHistoryCard(careAction({ type: "APPOINTMENT" })).badge,
345 +
        ).toBeUndefined();
346 +
      });
347 +

348 +
      it("uses the singular for a single link", () => {
349 +
        expect(
350 +
          mapCareActionToHistoryCard(
351 +
            careAction({ type: "APPOINTMENT", triggerId: 5 }),
352 +
          ).badge,
353 +
        ).toEqual({ label: "1 vínculo", variant: "blue" });
354 +
      });
355 +

356 +
      it("uses the plural for two links", () => {
357 +
        expect(
358 +
          mapCareActionToHistoryCard(
359 +
            careAction({ type: "APPOINTMENT", triggerId: 5, moodId: 6 }),
360 +
          ).badge,
361 +
        ).toEqual({ label: "2 vínculos", variant: "blue" });
362 +
      });
363 +
    });
364 +
  });
365 +

366 +
  describe("activity", () => {
367 +
    it("uses the activity label and duration", () => {
368 +
      const record = careAction({
369 +
        id: 30,
370 +
        type: "ACTIVITY",
371 +
        activity: { id: 1, careActionId: 30, type: "WALK", duration: 30 },
372 +
      });
373 +

374 +
      expect(mapCareActionToHistoryCard(record)).toStrictEqual({
375 +
        id: "care_action-30",
376 +
        category: "care_action",
377 +
        subtype: "activity",
378 +
        timestamp: record.moment.toString(),
379 +
        title: "Caminhada",
380 +
        summary: "30 min",
381 +
        raw: record,
382 +
      });
383 +
    });
384 +

385 +
    it("falls back when there is no duration", () => {
386 +
      const card = mapCareActionToHistoryCard(
387 +
        careAction({
388 +
          type: "ACTIVITY",
389 +
          activity: { id: 1, careActionId: 1, type: "YOGA" },
390 +
        }),
391 +
      );
392 +

393 +
      expect(card.summary).toBe("Sem duração registrada");
394 +
    });
395 +

396 +
    it.each([
397 +
      ["WALK", "Caminhada"],
398 +
      ["YOGA", "Yoga"],
399 +
      ["GYM", "Academia"],
400 +
      ["MEDITATION", "Meditação"],
401 +
      ["SOCIAL", "Momento social"],
402 +
      ["CREATIVE", "Atividade criativa"],
403 +
      ["OTHER", "Atividade"],
404 +
      ["unmapped", "Atividade"],
405 +
    ] as const)("labels the %p activity as %p", (type, label) => {
406 +
      const card = mapCareActionToHistoryCard(
407 +
        careAction({
408 +
          type: "ACTIVITY",
409 +
          activity: {
410 +
            id: 1,
411 +
            careActionId: 1,
412 +
            type: type as ActivityType,
413 +
          },
414 +
        }),
415 +
      );
416 +

417 +
      expect(card.title).toBe(label);
418 +
    });
419 +
  });
420 +

421 +
  it("falls back for an unknown care-action type", () => {
422 +
    const record = careAction({
423 +
      id: 40,
424 +
      type: "SOMETHING_ELSE" as unknown as CareAction["type"],
425 +
    });
426 +

427 +
    expect(mapCareActionToHistoryCard(record)).toStrictEqual({
428 +
      id: "care_action-40",
429 +
      category: "care_action",
430 +
      timestamp: record.moment.toString(),
431 +
      title: "Ação de cuidado",
432 +
      summary: "",
433 +
      raw: record,
434 +
    });
435 +
  });
436 +
});
/dev/null → frontend/tests/utils/history.unit.test.ts
1 +
import {
2 +
  filterByCategory,
3 +
  groupByDate,
4 +
  mergeAndSort,
5 +
} from "@/lib/utils/history";
6 +
import type { HistoryCard, HistoryCategory } from "@/lib/history/types";
7 +

8 +
function card(
9 +
  id: string,
10 +
  category: HistoryCategory,
11 +
  at: Date,
12 +
): HistoryCard {
13 +
  return {
14 +
    id,
15 +
    category,
16 +
    timestamp: at.toISOString(),
17 +
    title: id,
18 +
    summary: "",
19 +
  } as HistoryCard;
20 +
}
21 +

22 +
describe("mergeAndSort", () => {
23 +
  it("flattens the buckets into one descending list", () => {
24 +
    const oldest = card("oldest", "mood", new Date(2025, 0, 1, 10, 0));
25 +
    const newest = card("newest", "sleep", new Date(2025, 0, 3, 10, 0));
26 +
    const middle = card("middle", "trigger", new Date(2025, 0, 2, 10, 0));
27 +

28 +
    const merged = mergeAndSort([[oldest, middle], [newest]]);
29 +

30 +
    expect(merged.map((c) => c.id)).toEqual(["newest", "middle", "oldest"]);
31 +
  });
32 +

33 +
  it("returns an empty list for no buckets", () => {
34 +
    expect(mergeAndSort([])).toEqual([]);
35 +
  });
36 +
});
37 +

38 +
describe("groupByDate", () => {
39 +
  it("groups into fixed, ordered date buckets", () => {
40 +
    const morning = card("m1", "mood", new Date(2025, 0, 15, 9, 0));
41 +
    const evening = card("s1", "sleep", new Date(2025, 0, 15, 20, 0));
42 +
    const previous = card("t1", "trigger", new Date(2025, 0, 14, 12, 0));
43 +

44 +
    const grouped = groupByDate([morning, evening, previous]);
45 +

46 +
    expect(Object.keys(grouped)).toEqual([
47 +
      "quarta-feira, 15 de janeiro",
48 +
      "terça-feira, 14 de janeiro",
49 +
    ]);
50 +
    expect(grouped["quarta-feira, 15 de janeiro"].map((c) => c.id)).toEqual([
51 +
      "m1",
52 +
      "s1",
53 +
    ]);
54 +
    expect(grouped["terça-feira, 14 de janeiro"].map((c) => c.id)).toEqual([
55 +
      "t1",
56 +
    ]);
57 +
  });
58 +

59 +
  it("returns no buckets for an empty list", () => {
60 +
    expect(groupByDate([])).toEqual({});
61 +
  });
62 +
});
63 +

64 +
describe("filterByCategory", () => {
65 +
  const cards = [
66 +
    card("m", "mood", new Date(2025, 0, 1, 10, 0)),
67 +
    card("s", "sleep", new Date(2025, 0, 2, 10, 0)),
68 +
    card("t", "trigger", new Date(2025, 0, 3, 10, 0)),
69 +
    card("c", "care_action", new Date(2025, 0, 4, 10, 0)),
70 +
  ];
71 +

72 +
  it.each([
73 +
    ["mood", ["m"]],
74 +
    ["sleep", ["s"]],
75 +
    ["trigger", ["t"]],
76 +
    ["care_action", ["c"]],
77 +
  ] as const)("keeps only the %s cards", (category, ids) => {
78 +
    expect(filterByCategory(cards, category).map((c) => c.id)).toEqual(ids);
79 +
  });
80 +

81 +
  it("keeps everything for 'all'", () => {
82 +
    expect(filterByCategory(cards, "all").map((c) => c.id)).toEqual([
83 +
      "m",
84 +
      "s",
85 +
      "t",
86 +
      "c",
87 +
    ]);
88 +
  });
89 +
});