Skip to content

Commit 26ac9b2

Browse files
test(web): add DTO parsing helpers and unit coverage
- introduce dtoParsers for chamber stats + formation progress - wire Chambers/Proposals to shared parsers - add unit tests for DTO parsing + formation progress - keep proposal submit error formatting tests
1 parent 4cab465 commit 26ac9b2

File tree

10 files changed

+389
-148
lines changed

10 files changed

+389
-148
lines changed

src/lib/dtoParsers.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { ChamberDto, FormationProposalPageDto } from "@/types/api";
2+
3+
export function parseCommaNumber(value: string): number {
4+
const cleaned = value.replace(/,/g, "").trim();
5+
const parsed = Number.parseInt(cleaned, 10);
6+
return Number.isFinite(parsed) ? parsed : 0;
7+
}
8+
9+
export function parsePercent(value: string): number {
10+
const cleaned = value.replace(/%/g, "").trim();
11+
const parsed = Number.parseInt(cleaned, 10);
12+
return Number.isFinite(parsed) ? parsed : 0;
13+
}
14+
15+
export function parseRatio(value: string): { a: number; b: number } {
16+
const parts = value
17+
.split("/")
18+
.map((part) => Number.parseInt(part.trim(), 10));
19+
if (parts.length !== 2) return { a: 0, b: 0 };
20+
const [a, b] = parts;
21+
return {
22+
a: Number.isFinite(a) ? a : 0,
23+
b: Number.isFinite(b) ? b : 0,
24+
};
25+
}
26+
27+
export function getChamberNumericStats(chamber: ChamberDto) {
28+
return {
29+
governors: parseCommaNumber(chamber.stats.governors),
30+
acm: parseCommaNumber(chamber.stats.acm),
31+
lcm: parseCommaNumber(chamber.stats.lcm),
32+
mcm: parseCommaNumber(chamber.stats.mcm),
33+
};
34+
}
35+
36+
export function computeChamberMetrics(chambers: ChamberDto[]) {
37+
const totalAcm = chambers.reduce((sum, chamber) => {
38+
const { acm } = getChamberNumericStats(chamber);
39+
return sum + acm;
40+
}, 0);
41+
const liveProposals = chambers.reduce(
42+
(sum, chamber) => sum + (chamber.pipeline.vote ?? 0),
43+
0,
44+
);
45+
return {
46+
totalChambers: chambers.length,
47+
totalAcm,
48+
liveProposals,
49+
};
50+
}
51+
52+
export function getFormationProgress(formationPage: FormationProposalPageDto) {
53+
return {
54+
progressValue: parsePercent(formationPage.progress),
55+
team: parseRatio(formationPage.teamSlots),
56+
milestones: parseRatio(formationPage.milestones),
57+
};
58+
}

src/lib/proposalSubmitErrors.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { getApiErrorPayload } from "@/lib/apiClient";
2+
import { formatProposalType } from "@/lib/proposalTypes";
3+
4+
export function formatProposalSubmitError(error: unknown): string {
5+
const payload = getApiErrorPayload(error);
6+
const details = payload?.error ?? null;
7+
if (!details) return (error as Error).message ?? "Submit failed.";
8+
9+
const code = typeof details.code === "string" ? details.code : "";
10+
if (code === "proposal_type_ineligible" || code === "tier_ineligible") {
11+
const requiredTier =
12+
typeof details.requiredTier === "string"
13+
? details.requiredTier
14+
: "a higher tier";
15+
const proposalType =
16+
typeof details.proposalType === "string"
17+
? formatProposalType(details.proposalType)
18+
: "this";
19+
return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`;
20+
}
21+
22+
if (code === "proposal_submit_ineligible") {
23+
const chamberId =
24+
typeof details.chamberId === "string" ? details.chamberId : "";
25+
if (chamberId === "general") {
26+
return "General chamber proposals require voting rights in any chamber.";
27+
}
28+
if (chamberId) {
29+
return `Only chamber members can submit to ${formatProposalType(chamberId)}.`;
30+
}
31+
}
32+
33+
if (code === "draft_not_submittable") {
34+
return "Draft is incomplete. Fill required fields before submitting.";
35+
}
36+
37+
return details.message ?? (error as Error).message ?? "Submit failed.";
38+
}

src/lib/proposalTypes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const proposalTypeLabel: Record<string, string> = {
2+
basic: "Basic",
3+
fee: "Fee distribution",
4+
monetary: "Monetary system",
5+
core: "Core infrastructure",
6+
administrative: "Administrative",
7+
"dao-core": "DAO core",
8+
};
9+
10+
export function formatProposalType(value: string): string {
11+
return proposalTypeLabel[value] ?? value.replace(/-/g, " ");
12+
}

src/pages/chambers/Chambers.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { Link } from "react-router";
1313
import { InlineHelp } from "@/components/InlineHelp";
1414
import { NoDataYetBar } from "@/components/NoDataYetBar";
1515
import { apiChambers } from "@/lib/apiClient";
16+
import {
17+
computeChamberMetrics,
18+
getChamberNumericStats,
19+
} from "@/lib/dtoParsers";
1620
import type { ChamberDto } from "@/types/api";
1721
import { Surface } from "@/components/Surface";
1822

@@ -77,32 +81,21 @@ const Chambers: React.FC = () => {
7781
})
7882
.sort((a, b) => {
7983
if (sortBy === "name") return a.name.localeCompare(b.name);
80-
if (sortBy === "governors")
81-
return (
82-
parseInt(b.stats.governors, 10) - parseInt(a.stats.governors, 10)
83-
);
84-
return (
85-
parseInt(b.stats.acm.replace(/[,]/g, ""), 10) -
86-
parseInt(a.stats.acm.replace(/[,]/g, ""), 10)
87-
);
84+
const statsA = getChamberNumericStats(a);
85+
const statsB = getChamberNumericStats(b);
86+
if (sortBy === "governors") return statsB.governors - statsA.governors;
87+
return statsB.acm - statsA.acm;
8888
});
8989
}, [chambers, search, pipelineFilter, sortBy]);
9090

9191
const computedMetrics = useMemo((): Metric[] => {
9292
if (!chambers) return metricCards;
93-
const totalAcm = chambers.reduce((sum, chamber) => {
94-
const parsed = Number(chamber.stats.acm.replace(/,/g, ""));
95-
return sum + (Number.isFinite(parsed) ? parsed : 0);
96-
}, 0);
97-
const live = chambers.reduce(
98-
(sum, chamber) => sum + (chamber.pipeline.vote ?? 0),
99-
0,
100-
);
93+
const { totalAcm, liveProposals } = computeChamberMetrics(chambers);
10194
return [
10295
{ label: "Total chambers", value: String(chambers.length) },
10396
{ label: "Active governors", value: "150" },
10497
{ label: "Total ACM", value: totalAcm.toLocaleString() },
105-
{ label: "Live proposals", value: String(live) },
98+
{ label: "Live proposals", value: String(liveProposals) },
10699
];
107100
}, [chambers]);
108101

src/pages/proposals/ProposalCreation.tsx

Lines changed: 2 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { Tabs } from "@/components/primitives/tabs";
1111
import { PageHint } from "@/components/PageHint";
1212
import { SIM_AUTH_ENABLED } from "@/lib/featureFlags";
1313
import { useAuth } from "@/app/auth/AuthContext";
14+
import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors";
1415
import {
1516
apiChambers,
1617
apiProposalDraftDelete,
1718
apiProposalDraftSave,
1819
apiProposalSubmitToPool,
19-
getApiErrorPayload,
2020
} from "@/lib/apiClient";
2121
import type { ChamberDto } from "@/types/api";
2222
import { BudgetStep } from "./proposalCreation/steps/BudgetStep";
@@ -43,54 +43,6 @@ import {
4343
} from "./proposalCreation/types";
4444
import { getWizardTemplate } from "./proposalCreation/templates/registry";
4545

46-
const proposalTypeLabel: Record<string, string> = {
47-
basic: "Basic",
48-
fee: "Fee distribution",
49-
monetary: "Monetary system",
50-
core: "Core infrastructure",
51-
administrative: "Administrative",
52-
"dao-core": "DAO core",
53-
};
54-
55-
const formatProposalType = (value: string): string =>
56-
proposalTypeLabel[value] ?? value.replace(/-/g, " ");
57-
58-
const formatSubmitError = (error: unknown): string => {
59-
const payload = getApiErrorPayload(error);
60-
const details = payload?.error ?? null;
61-
if (!details) return (error as Error).message ?? "Submit failed.";
62-
63-
const code = typeof details.code === "string" ? details.code : "";
64-
if (code === "proposal_type_ineligible" || code === "tier_ineligible") {
65-
const requiredTier =
66-
typeof details.requiredTier === "string"
67-
? details.requiredTier
68-
: "a higher tier";
69-
const proposalType =
70-
typeof details.proposalType === "string"
71-
? formatProposalType(details.proposalType)
72-
: "this";
73-
return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`;
74-
}
75-
76-
if (code === "proposal_submit_ineligible") {
77-
const chamberId =
78-
typeof details.chamberId === "string" ? details.chamberId : "";
79-
if (chamberId === "general") {
80-
return "General chamber proposals require voting rights in any chamber.";
81-
}
82-
if (chamberId) {
83-
return `Only chamber members can submit to ${formatProposalType(chamberId)}.`;
84-
}
85-
}
86-
87-
if (code === "draft_not_submittable") {
88-
return "Draft is incomplete. Fill required fields before submitting.";
89-
}
90-
91-
return details.message ?? (error as Error).message ?? "Submit failed.";
92-
};
93-
9446
const ProposalCreation: React.FC = () => {
9547
const auth = useAuth();
9648
const navigate = useNavigate();
@@ -435,7 +387,7 @@ const ProposalCreation: React.FC = () => {
435387
clearDraftStorage();
436388
navigate(`/app/proposals/${res.proposalId}/pp`);
437389
} catch (error) {
438-
setSubmitError(formatSubmitError(error));
390+
setSubmitError(formatProposalSubmitError(error));
439391
} finally {
440392
setSubmitting(false);
441393
}

src/pages/proposals/ProposalDraft.tsx

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -17,61 +17,10 @@ import { AttachmentList } from "@/components/AttachmentList";
1717
import { TitledSurface } from "@/components/TitledSurface";
1818
import { SIM_AUTH_ENABLED } from "@/lib/featureFlags";
1919
import { useAuth } from "@/app/auth/AuthContext";
20-
import {
21-
apiProposalDraft,
22-
apiProposalSubmitToPool,
23-
getApiErrorPayload,
24-
} from "@/lib/apiClient";
20+
import { formatProposalSubmitError } from "@/lib/proposalSubmitErrors";
21+
import { apiProposalDraft, apiProposalSubmitToPool } from "@/lib/apiClient";
2522
import type { ProposalDraftDetailDto } from "@/types/api";
2623

27-
const proposalTypeLabel: Record<string, string> = {
28-
basic: "Basic",
29-
fee: "Fee distribution",
30-
monetary: "Monetary system",
31-
core: "Core infrastructure",
32-
administrative: "Administrative",
33-
"dao-core": "DAO core",
34-
};
35-
36-
const formatProposalType = (value: string): string =>
37-
proposalTypeLabel[value] ?? value.replace(/-/g, " ");
38-
39-
const formatSubmitError = (error: unknown): string => {
40-
const payload = getApiErrorPayload(error);
41-
const details = payload?.error ?? null;
42-
if (!details) return (error as Error).message ?? "Submit failed.";
43-
44-
const code = typeof details.code === "string" ? details.code : "";
45-
if (code === "proposal_type_ineligible" || code === "tier_ineligible") {
46-
const requiredTier =
47-
typeof details.requiredTier === "string"
48-
? details.requiredTier
49-
: "a higher tier";
50-
const proposalType =
51-
typeof details.proposalType === "string"
52-
? formatProposalType(details.proposalType)
53-
: "this";
54-
return `Not eligible for ${proposalType} proposals. Required tier: ${requiredTier}.`;
55-
}
56-
57-
if (code === "proposal_submit_ineligible") {
58-
const chamberId =
59-
typeof details.chamberId === "string" ? details.chamberId : "";
60-
if (chamberId === "general") {
61-
return "General chamber proposals require voting rights in any chamber.";
62-
}
63-
if (chamberId) {
64-
return `Only chamber members can submit to ${formatProposalType(chamberId)}.`;
65-
}
66-
}
67-
68-
if (code === "draft_not_submittable") {
69-
return "Draft is incomplete. Fill required fields before submitting.";
70-
}
71-
72-
return details.message ?? (error as Error).message ?? "Submit failed.";
73-
};
74-
7524
const ProposalDraft: React.FC = () => {
7625
const auth = useAuth();
7726
const { id } = useParams();
@@ -159,7 +108,7 @@ const ProposalDraft: React.FC = () => {
159108
const res = await apiProposalSubmitToPool({ draftId: id });
160109
window.location.href = `/app/proposals/${res.proposalId}/pp`;
161110
} catch (error) {
162-
setSubmitError(formatSubmitError(error));
111+
setSubmitError(formatProposalSubmitError(error));
163112
} finally {
164113
setSubmitting(false);
165114
}

src/pages/proposals/Proposals.tsx

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { ProposalStage } from "@/types/stages";
1414
import { CardActionsRow } from "@/components/CardActionsRow";
1515
import { Surface } from "@/components/Surface";
1616
import { NoDataYetBar } from "@/components/NoDataYetBar";
17+
import { getFormationProgress } from "@/lib/dtoParsers";
1718
import {
1819
apiProposalChamberPage,
1920
apiProposalFormationPage,
@@ -373,33 +374,7 @@ const Proposals: React.FC = () => {
373374

374375
const formationStats =
375376
proposal.stage === "build" && formationPage
376-
? (() => {
377-
const progressRaw = Number.parseInt(
378-
formationPage.progress.replace("%", ""),
379-
10,
380-
);
381-
const progressValue = Number.isFinite(progressRaw)
382-
? progressRaw
383-
: 0;
384-
385-
const parsePair = (value: string) => {
386-
const parts = value
387-
.split("/")
388-
.map((v) => Number(v.trim()));
389-
if (parts.length !== 2) return { a: 0, b: 0 };
390-
const [a, b] = parts;
391-
return {
392-
a: Number.isFinite(a) ? a : 0,
393-
b: Number.isFinite(b) ? b : 0,
394-
};
395-
};
396-
397-
return {
398-
progressValue,
399-
team: parsePair(formationPage.teamSlots),
400-
milestones: parsePair(formationPage.milestones),
401-
};
402-
})()
377+
? getFormationProgress(formationPage)
403378
: null;
404379

405380
return (

0 commit comments

Comments
 (0)