test(dawarich): pin the movement block, the channels and the mocks; document the connection

Covers the day section, the model projection without the travel country and
the two correlation channels; the backup and correlation mocks learn the new
delegate. Adds the operator page for DAWARICH_PRIVATE_ORIGINS and regenerates
the API contract.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 01:38:05 +02:00
1 parent b99bcaeec6
commit c03f79afd5
7 files changed
+372 -58

No files matched your search

+145 -58
View File
@@ -20466,12 +20466,12 @@ paths:
tags:
- Integrations
summary: Read every integration's status in one call
description: "The consolidated envelope behind the integrations settings surface: all eight providers, always, in a
fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this
over eight per-provider round-trips. Pure read. Auth via cookie or Bearer."
description: "The consolidated envelope behind the integrations settings surface: all nine providers, always, in a fixed
order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over nine
per-provider round-trips. Pure read. Auth via cookie or Bearer."
responses:
"200":
description: All eight provider entries plus the alert threshold.
description: All nine provider entries plus the alert threshold.
content:
application/json:
schema:
@@ -24359,7 +24359,6 @@ paths:
"403": *a19
"422": *a4
"429": *a5
"501": *a69
/api/dawarich/status:
get:
tags:
@@ -24376,7 +24375,6 @@ paths:
"403": *a19
"422": *a4
"429": *a5
"501": *a69
/api/dawarich/places:
get:
tags:
@@ -24407,7 +24405,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
"502":
description: "`dawarich.unreachable`."
content:
@@ -24419,7 +24416,10 @@ paths:
tags:
- Integrations
summary: Dawarich home place and country (v1.43)
description: 422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.
description: 422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have; 409
`dawarich.not_connected`; 502 `dawarich.unreachable` when the place cannot be checked. A new home country
re-judges the stored travel days at once; a new home place fetches the stored days again in the background (up
to a year).
requestBody:
required: true
content:
@@ -24435,16 +24435,29 @@ paths:
$ref: "#/components/schemas/DawarichSettingsEnvelope"
"401": *a1
"403": *a19
"409":
description: "`dawarich.not_connected`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
"502":
description: "`dawarich.unreachable`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/dawarich/test:
post:
tags:
- Integrations
summary: Probe the Dawarich connection (v1.43)
description: Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those
of the other `/test` routes.
description: Answers the shared integration probe shape. A failed probe is a 502 whose `errorCode` is one of the shared
probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `url_not_public`,
`connection_failed`); 422 `not_configured` without a connection; 429 `rate_limited_self` past five probes a
minute.
responses:
"200":
description: The probe reached the instance.
@@ -24456,13 +24469,20 @@ paths:
"403": *a19
"422": *a4
"429": *a5
"501": *a69
"502":
description: A probe class; see the description.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/dawarich/sync:
post:
tags:
- Integrations
summary: Fetch Dawarich daily values again (v1.43)
description: From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.
description: From `fromDate` (inside the 365 days ending yesterday; without it the nightly window of seven days).
`enqueued` is false when a run for this account is already queued or no worker is bound. 409
`dawarich.not_connected`; 422 `dawarich.invalid`.
requestBody:
required: false
content:
@@ -24491,13 +24511,13 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
/api/dawarich/disconnect:
post:
tags:
- Integrations
summary: Disconnect Dawarich (v1.43)
description: "Forgets the connection; with `deleteData: true` the stored days go too."
description: "Forgets the connection; with `deleteData: true` the stored days go too. No body is `{ deleteData: false
}`. Disconnecting twice is harmless."
requestBody:
required: false
content:
@@ -24520,12 +24540,13 @@ paths:
"403": *a19
"422": *a4
"429": *a5
"501": *a69
/api/environment/travel/suggestions:
get:
tags:
- Environment
summary: Suggested trips from Dawarich travel days (v1.43)
description: Runs of at least two consecutive travel days in one country, inside the last year, that no stored trip
overlaps and the person has not dismissed; newest first. Empty without a Dawarich connection.
responses:
"200":
description: The suggestions.
@@ -24537,7 +24558,6 @@ paths:
"403": *a19
"422": *a4
"429": *a5
"501": *a69
/api/environment/travel/suggestions/{id}/dismiss:
post:
tags:
@@ -24567,7 +24587,6 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"501": *a69
/api/medications/{id}/links:
post:
tags:
@@ -35713,42 +35732,43 @@ components:
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
dawarich — dawarich.invalid, dawarich.rate_limited; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
documents.sources.organizationRequired, documents.sources.originNotAllowed,
dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid,
dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo —
demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid,
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption
— encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.backfill_pending,
environment.backfill_rate_limited, environment.invalid, environment.no_home,
environment.range_too_large, environment.travel.not_found; export — export.selection.empty,
export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid;
health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness —
illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid,
illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import —
import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights —
insights.generate.budgetExceeded, insights.generate.outboundScreened,
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate,
labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound,
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch,
labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found;
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
insights.generate.outboundScreened, insights.generate.profileScopeChanged,
insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed,
labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic,
labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound,
labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
@@ -43343,6 +43363,71 @@ components:
- lon
- label
additionalProperties: {}
dawarichDays:
default: []
type: array
items:
type: object
properties:
date:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
totalDistanceM:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
drivingDistanceM:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
trainDistanceM:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
placesVisited:
anyOf:
- type: integer
minimum: 0
maximum: 9007199254740991
- type: "null"
awayFromHomeMinutes:
anyOf:
- type: integer
minimum: 0
maximum: 1500
- type: "null"
countryCode:
anyOf:
- type: string
pattern: ^[A-Z]{2}$
- type: "null"
countryCodeEncrypted:
anyOf:
- type: string
minLength: 1
- type: "null"
isTravelDay:
anyOf:
- type: boolean
- type: "null"
fetchedAt:
type: string
minLength: 1
createdAt:
type: string
minLength: 1
updatedAt:
type: string
minLength: 1
required:
- date
additionalProperties: {}
ecgRecordings:
default: []
type: array
@@ -43541,6 +43626,7 @@ components:
- userAchievements
- environmentContexts
- environmentTravelLocations
- dawarichDays
- ecgRecordings
- manifest
- decryptFailures
@@ -77836,26 +77922,26 @@ components:
metricFreshnessDegraded:
type: boolean
description: "The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of
what the record actually holds. One flag rather than one per entry, because it is a single query for all
eight providers: it fails for all of them or for none. When true, render the freshness section as
unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire,
which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is
unaffected and still complete."
what the record actually holds. One flag rather than one per entry, because it is a single query for every
provider: it fails for all of them or for none. When true, render the freshness section as unavailable
rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned
the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still
complete."
integrations:
type: array
items:
$ref: "#/components/schemas/IntegrationStatusEntry"
description: "All eight providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura,
strava, nightscout. A provider the user has never touched still appears, with `connected: false` and a
synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever
absent from it."
description: "All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura,
strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false`
and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is
ever absent from it."
required:
- threshold
- metricFreshnessDegraded
- integrations
additionalProperties: false
description: The one read behind the integrations settings surface. It carries every field the per-provider status
routes carry, so a client has the choice of eight round-trips or this one; the per-provider routes remain for
routes carry, so a client has the choice of nine round-trips or this one; the per-provider routes remain for
callers that want a single provider.
IntegrationStatusEntry:
type: object
@@ -77871,6 +77957,7 @@ components:
- oura
- strava
- nightscout
- dawarich
description: Which provider this entry describes.
state:
type: string
+82
View File
@@ -0,0 +1,82 @@
# Dawarich integration
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
instance: how far you moved, how much of it by car or by train, how many places
you visited, how long you were away from home, and whether a day was spent in
another country. It is part of the Environment module and runs only while that
module is on.
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
the day summaries and keeps one row of numbers per day. The place list is read
live when you pick your home and is not saved.
## What is read
Once a night (03:20, server time) HealthLog fetches the last seven days, ending
yesterday. Connecting fetches the last 30 days. Each request carries your API
key as `Authorization: Bearer <key>`.
| Dawarich endpoint | What HealthLog keeps |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
| `GET /api/v1/users/me` | nothing; used to check the key |
Time away from home is the day's length minus the time a visit at your home
place covers. Without a home place, or on a day without a single visit, it is
left empty rather than shown as zero. Dawarich leaves out a mode that moved
less than half a kilometre; HealthLog then reads that mode as zero for the day.
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
has it. Without it there are no travel days, and everything else still works.
Dawarich keys residency by UTC date, so on a day that crosses a border late in
the evening the country can be the next morning's.
## Connect
1. In Dawarich, open your account settings and copy the API key.
2. In HealthLog, turn on the Environment module, then open
**Settings → Integrations → Dawarich**.
3. Enter the address of the instance and the key, and connect.
4. Pick your home place from the list, and set your home country if you like.
Without one, the country you spent most days in over the last year counts.
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
timeline by its own zone, HealthLog by yours; if they differ, a day can come
back without its distances.
## A Dawarich instance on your own network
A public instance works without any server setting. An instance on a private
network (a LAN address, a `.lan` name, a container on the same host) is refused
unless the operator lists its exact origin:
```env
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
```
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
separated, no path, no wildcard and no range. Listing a host does not open its
subdomains or another port. The unspecified address, link-local and the
cloud-metadata range can never be listed. Restart HealthLog after changing it.
The variable is on the `environment:` whitelist in `docker-compose.yml`.
## Where the values show up
- The day view has an **On the move** section with the day's figures.
- Time away from home and the distance travelled join the correlation scan as
two more factors.
- Runs of at least two travel days that no trip covers yet are offered as
trips under **Settings → Environment → Location periods**. Using one fills
in the dates; you pick the place.
- The Coach and the MCP `get_day` tool see the figures and whether it was a
travel day, never the country.
## Disconnect
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
and delete days** removes them as well. The days are part of the backup; the
address and key are not, so a restored account connects again.
@@ -216,6 +216,7 @@ beforeEach(() => {
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -160,6 +160,7 @@ beforeEach(() => {
).mockResolvedValue([]);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(false);
});
@@ -0,0 +1,63 @@
/**
* "On the move" renders the server's values as they come and leaves out what
* the day has no value for: a null is no coverage, never a zero.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { DayMovementSection } from "../day-movement";
function render(
movement: Parameters<typeof DayMovementSection>[0]["movement"],
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DayMovementSection movement={movement} />
</I18nProvider>,
);
}
const base = {
source: "dawarich" as const,
distance: { unit: "km" as const, total: 48.3, driving: 12, train: 0 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true, countryCode: "ES" },
fetchedAt: "2026-06-16T01:20:00.000Z",
};
describe("DayMovementSection", () => {
it("shows each value the day has", () => {
const html = render(base);
expect(html).toContain('data-movement="total"');
expect(html).toContain("48.3 km");
expect(html).toContain('data-movement="driving"');
expect(html).toContain('data-movement="away"');
expect(html).toContain("Spain");
// No train that day: no row, rather than "0 km".
expect(html).not.toContain('data-movement="train"');
});
it("leaves out time away from home without coverage", () => {
const html = render({
...base,
awayFromHomeMinutes: null,
travel: null,
});
expect(html).not.toContain('data-movement="away"');
expect(html).not.toContain('data-movement="travel"');
});
it("renders nothing when the day has no value at all", () => {
const html = render({
...base,
distance: { unit: "mi", total: null, driving: null, train: null },
placesVisited: null,
awayFromHomeMinutes: null,
travel: null,
});
expect(html).toBe("");
});
});
@@ -222,3 +222,36 @@ describe("scores", () => {
expect(JSON.stringify(projected.scores)).not.toContain("COMPUTED");
});
});
describe("the movement block (v1.43)", () => {
const withMovement: DayResponse = {
...day,
movement: {
source: "dawarich",
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true, countryCode: "ES" },
fetchedAt: "2026-03-30T01:20:00.000Z",
},
};
it("reaches a model without the country of the travel day", () => {
const model = projectDayForModel(withMovement, []);
expect(model.movement).toEqual({
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true },
});
expect(JSON.stringify(model)).not.toContain("ES");
});
it("stays out when the caller excludes the environment section", () => {
const model = projectDayForModel(withMovement, [], undefined, {
sections: new Set(["environment"]),
types: new Set(),
});
expect(model).not.toHaveProperty("movement");
});
});
@@ -0,0 +1,47 @@
/**
* The two Dawarich movement channels: hours away from home and kilometres
* travelled, one point per stored day that has the value. A day without it is
* no coverage and gets no point, never a zero.
*/
import { describe, expect, it, vi } from "vitest";
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: { dawarichDay: { findMany } } }));
import { fetchMovementSeries } from "@/lib/insights/correlation-channel-series";
import { metricFamily } from "@/lib/insights/correlation-discovery";
describe("fetchMovementSeries", () => {
it("emits a point only where the day has the value", async () => {
findMany.mockResolvedValue([
{ date: "2026-06-14", awayFromHomeMinutes: 90, totalDistanceM: null },
{ date: "2026-06-15", awayFromHomeMinutes: null, totalDistanceM: 48_300 },
]);
const [away, distance] = await fetchMovementSeries(
"u1",
"Europe/Berlin",
new Date("2026-06-01T12:00:00Z"),
);
expect(away).toMatchObject({
key: "MOVEMENT_AWAY",
role: "behaviour",
points: [{ day: "2026-06-14", value: 1.5 }],
});
expect(distance).toMatchObject({
key: "MOVEMENT_DISTANCE",
role: "behaviour",
points: [{ day: "2026-06-15", value: 48.3 }],
});
expect(findMany.mock.calls[0][0].where).toEqual({
userId: "u1",
date: { gte: "2026-06-01" },
});
});
it("keeps the two channels in one family so they never pair with each other", () => {
expect(metricFamily("MOVEMENT_AWAY")).toBe(
metricFamily("MOVEMENT_DISTANCE"),
);
expect(metricFamily("MOVEMENT_AWAY")).not.toBe(metricFamily("MOOD"));
});
});