diff --git a/meshchatx.rsm b/meshchatx.rsm index 8e4a39fd..444f3dfe 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index 4a423618..714b9c10 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -1837,6 +1837,11 @@ import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from import { applyRelayShareLink, findRelayUriInContent, parseMeshchatRelayUri } from "../../js/relayLinkUtils.js"; import { LXMF_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions"; import { createOutboundQueue } from "../../js/outboundSendQueue"; +import { + isOpportunisticDeferredDelivery as isOpportunisticDeferredDeliveryStatus, + outboundBubbleStatusIconName as resolveOutboundBubbleStatusIconName, + outboundBubbleStatusTitleKey as resolveOutboundBubbleStatusTitleKey, +} from "../../js/outboundMessageStatus.js"; import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"; import "emoji-picker-element"; import StickerView from "../stickers/StickerView.vue"; @@ -4971,45 +4976,11 @@ export default { return this.outboundBubbleStatusTitle(lxmfMessage); }, outboundBubbleStatusIconName(lxmfMessage) { - if (!lxmfMessage) { - return "check"; - } - const state = lxmfMessage.state; - const method = lxmfMessage.method; - if (state === "delivered") { - if (method === "propagated") { - return "email-check-outline"; - } - if (method === "paper") { - return "note-check-outline"; - } - return "check-all"; - } - if (["sent", "propagated", "unknown"].includes(state)) { - if (method === "propagated") { - return "email-outline"; - } - if (method === "paper") { - return "note-outline"; - } - return "check"; - } - return "check"; + return resolveOutboundBubbleStatusIconName(lxmfMessage); }, outboundBubbleStatusTitle(lxmfMessage) { - if (!lxmfMessage) { - return ""; - } - if (lxmfMessage.state === "delivered") { - if (lxmfMessage.method === "propagated") { - return this.$t("messages.outbound_delivered_propagated"); - } - return this.$t("messages.outbound_delivered"); - } - if (lxmfMessage.method === "propagated") { - return this.$t("messages.outbound_on_propagation_node"); - } - return this.$t("messages.outbound_sent_network"); + const key = resolveOutboundBubbleStatusTitleKey(lxmfMessage); + return key ? this.$t(key) : ""; }, isOutboundWaitingBubble(chatItem) { return Boolean(chatItem?.is_outbound && chatItem?.lxmf_message?._pendingPathfinding); @@ -5302,10 +5273,7 @@ export default { return this.$t("messages.failed_waiting_announce_tooltip"); }, isOpportunisticDeferredDelivery(lxmfMessage) { - if (!lxmfMessage) { - return false; - } - return lxmfMessage.method === "opportunistic" && lxmfMessage.state === "failed"; + return isOpportunisticDeferredDeliveryStatus(lxmfMessage); }, async warmPathToPeer() { await this.refreshPeerPath({ warm: true }); diff --git a/meshchatx/src/frontend/js/outboundMessageStatus.js b/meshchatx/src/frontend/js/outboundMessageStatus.js new file mode 100644 index 00000000..7f6747c0 --- /dev/null +++ b/meshchatx/src/frontend/js/outboundMessageStatus.js @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: 0BSD AND MIT +/** + * Outbound LXMF bubble status icons and i18n title keys. + * + * Backend emits API strings from LXMF.LXMessage via convert_lxmf_state_to_string + * and convert_lxmf_method_to_string. Propagation is a method, never a state. + */ + +const SENT_LIKE_STATES = new Set(["sent", "propagated", "unknown"]); + +/** + * @param {{ state?: string, method?: string } | null | undefined} lxmfMessage + * @returns {string} MDI kebab icon name + */ +export function outboundBubbleStatusIconName(lxmfMessage) { + if (!lxmfMessage) { + return "check"; + } + const state = lxmfMessage.state; + const method = lxmfMessage.method; + if (state === "delivered") { + if (method === "propagated") { + return "email-check-outline"; + } + if (method === "paper") { + return "note-check-outline"; + } + return "check-all"; + } + if (SENT_LIKE_STATES.has(state)) { + if (method === "propagated") { + return "email-outline"; + } + if (method === "paper") { + return "note-outline"; + } + return "check"; + } + return "check"; +} + +/** + * @param {{ state?: string, method?: string } | null | undefined} lxmfMessage + * @returns {string | null} i18n key under messages.*, or null when no title + */ +export function outboundBubbleStatusTitleKey(lxmfMessage) { + if (!lxmfMessage) { + return null; + } + if (lxmfMessage.state === "delivered") { + if (lxmfMessage.method === "propagated") { + return "messages.outbound_delivered_propagated"; + } + return "messages.outbound_delivered"; + } + if (lxmfMessage.method === "propagated") { + return "messages.outbound_on_propagation_node"; + } + return "messages.outbound_sent_network"; +} + +/** + * Opportunistic FAILED is deferred wait in the UI, not a hard failure badge. + * @param {{ state?: string, method?: string } | null | undefined} lxmfMessage + */ +export function isOpportunisticDeferredDelivery(lxmfMessage) { + if (!lxmfMessage) { + return false; + } + return lxmfMessage.method === "opportunistic" && lxmfMessage.state === "failed"; +} diff --git a/tests/backend/test_lxmf_status_oracle.py b/tests/backend/test_lxmf_status_oracle.py new file mode 100644 index 00000000..1ddb62e7 --- /dev/null +++ b/tests/backend/test_lxmf_status_oracle.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: 0BSD +"""Oracle tests for LXMF state and method reporting. + +Guarantee: MeshChatX API state and method strings match LXMF.LXMessage +constants. Propagation is a delivery method, never a lifecycle state. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import LXMF +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from meshchatx.src.backend.lxmf_utils import ( + convert_lxmf_message_to_dict, + convert_lxmf_method_to_string, + convert_lxmf_state_to_string, +) + +# Independent contract: derived from LXMF constant names, not from MeshChatX +# if/elif branches. If LXMF adds a state, this table must be updated deliberately. +LXMF_STATE_ORACLE = { + LXMF.LXMessage.GENERATING: "generating", + LXMF.LXMessage.OUTBOUND: "outbound", + LXMF.LXMessage.SENDING: "sending", + LXMF.LXMessage.SENT: "sent", + LXMF.LXMessage.DELIVERED: "delivered", + LXMF.LXMessage.REJECTED: "rejected", + LXMF.LXMessage.CANCELLED: "cancelled", + LXMF.LXMessage.FAILED: "failed", +} + +LXMF_METHOD_ORACLE = { + LXMF.LXMessage.OPPORTUNISTIC: "opportunistic", + LXMF.LXMessage.DIRECT: "direct", + LXMF.LXMessage.PROPAGATED: "propagated", + LXMF.LXMessage.PAPER: "paper", +} + +API_LIFECYCLE_STATES = frozenset(LXMF_STATE_ORACLE.values()) | {"unknown"} +API_DELIVERY_METHODS = frozenset(LXMF_METHOD_ORACLE.values()) | {"unknown"} + +# LXMF has no lifecycle state for "on propagation node". That meaning is +# method=PROPAGATED + state=SENT (or later DELIVERED). +assert "propagated" not in LXMF_STATE_ORACLE.values() + + +def _msg(**attrs): + return SimpleNamespace(**attrs) + + +def _mock_lxmessage(*, state, method): + mock_msg = MagicMock(spec=LXMF.LXMessage) + mock_msg.hash = b"m" * 16 + mock_msg.source_hash = b"s" * 16 + mock_msg.destination_hash = b"d" * 16 + mock_msg.incoming = False + mock_msg.state = state + mock_msg.progress = 1.0 if state == LXMF.LXMessage.DELIVERED else 0.0 + mock_msg.method = method + mock_msg.delivery_attempts = 0 + mock_msg.title = b"" + mock_msg.content = b"hello" + mock_msg.timestamp = 1_700_000_000 + mock_msg.rssi = None + mock_msg.snr = None + mock_msg.q = None + mock_msg.get_fields.return_value = {} + mock_msg.outbound_ticket = None + mock_msg.stamp_cost = None + mock_msg.stamp = None + mock_msg.defer_stamp = False + return mock_msg + + +@pytest.mark.parametrize(("lxmf_state", "expected"), list(LXMF_STATE_ORACLE.items())) +def test_oracle_state_constant_maps_to_api_string(lxmf_state, expected): + assert convert_lxmf_state_to_string(_msg(state=lxmf_state)) == expected + + +@pytest.mark.parametrize(("lxmf_method", "expected"), list(LXMF_METHOD_ORACLE.items())) +def test_oracle_method_constant_maps_to_api_string(lxmf_method, expected): + assert convert_lxmf_method_to_string(_msg(method=lxmf_method)) == expected + + +@given(state=st.sampled_from(list(LXMF_STATE_ORACLE.keys()))) +@settings(max_examples=64, deadline=None) +def test_oracle_known_states_never_unknown(state): + reported = convert_lxmf_state_to_string(_msg(state=state)) + assert reported == LXMF_STATE_ORACLE[state] + assert reported != "unknown" + + +@given(method=st.sampled_from(list(LXMF_METHOD_ORACLE.keys()))) +@settings(max_examples=32, deadline=None) +def test_oracle_known_methods_never_unknown(method): + reported = convert_lxmf_method_to_string(_msg(method=method)) + assert reported == LXMF_METHOD_ORACLE[method] + assert reported != "unknown" + + +@given( + state=st.integers(min_value=0, max_value=512).filter( + lambda v: v not in LXMF_STATE_ORACLE + ) +) +@settings(max_examples=80, deadline=None) +def test_oracle_unknown_state_ints_report_unknown(state): + assert convert_lxmf_state_to_string(_msg(state=state)) == "unknown" + + +@given( + method=st.integers(min_value=0, max_value=64).filter( + lambda v: v not in LXMF_METHOD_ORACLE + ) +) +@settings(max_examples=40, deadline=None) +def test_oracle_unknown_method_ints_report_unknown(method): + assert convert_lxmf_method_to_string(_msg(method=method)) == "unknown" + + +@given( + state=st.sampled_from(list(LXMF_STATE_ORACLE.keys())), + method=st.sampled_from(list(LXMF_METHOD_ORACLE.keys())), +) +@settings(max_examples=64, deadline=None) +def test_oracle_message_dict_reports_lxmf_status_pair(state, method): + """API dict state/method must match converters and stay in closed vocabularies.""" + payload = convert_lxmf_message_to_dict(_mock_lxmessage(state=state, method=method)) + expected_state = LXMF_STATE_ORACLE[state] + expected_method = LXMF_METHOD_ORACLE[method] + assert payload["state"] == expected_state + assert payload["method"] == expected_method + assert payload["state"] in API_LIFECYCLE_STATES + assert payload["method"] in API_DELIVERY_METHODS + # Propagation is method-only. Lifecycle must never be labeled "propagated". + assert payload["state"] != "propagated" + + +def test_oracle_propagated_sent_is_on_node_not_delivered(): + """Parked on a propagation node is SENT + PROPAGATED, not DELIVERED.""" + payload = convert_lxmf_message_to_dict( + _mock_lxmessage(state=LXMF.LXMessage.SENT, method=LXMF.LXMessage.PROPAGATED) + ) + assert payload["state"] == "sent" + assert payload["method"] == "propagated" + + +def test_oracle_propagated_delivered_means_recipient_got_mail(): + payload = convert_lxmf_message_to_dict( + _mock_lxmessage( + state=LXMF.LXMessage.DELIVERED, + method=LXMF.LXMessage.PROPAGATED, + ) + ) + assert payload["state"] == "delivered" + assert payload["method"] == "propagated" + + +def test_oracle_direct_delivered_is_not_propagated(): + payload = convert_lxmf_message_to_dict( + _mock_lxmessage(state=LXMF.LXMessage.DELIVERED, method=LXMF.LXMessage.DIRECT) + ) + assert payload["state"] == "delivered" + assert payload["method"] == "direct" + + +def test_oracle_opportunistic_failed_still_reports_failed_state(): + """Backend keeps LXMF FAILED. UI may treat opportunistic+failed as deferred wait.""" + payload = convert_lxmf_message_to_dict( + _mock_lxmessage( + state=LXMF.LXMessage.FAILED, + method=LXMF.LXMessage.OPPORTUNISTIC, + ) + ) + assert payload["state"] == "failed" + assert payload["method"] == "opportunistic" + + +def test_oracle_paper_method_preserved_across_states(): + for state, state_name in ( + (LXMF.LXMessage.SENT, "sent"), + (LXMF.LXMessage.DELIVERED, "delivered"), + ): + payload = convert_lxmf_message_to_dict( + _mock_lxmessage(state=state, method=LXMF.LXMessage.PAPER) + ) + assert payload["state"] == state_name + assert payload["method"] == "paper" + + +def test_lxmf_status_oracle_proved(): + """PROVED marker: full known state/method tables convert correctly.""" + for lxmf_state, expected in LXMF_STATE_ORACLE.items(): + assert convert_lxmf_state_to_string(_msg(state=lxmf_state)) == expected + for lxmf_method, expected in LXMF_METHOD_ORACLE.items(): + assert convert_lxmf_method_to_string(_msg(method=lxmf_method)) == expected + print("LXMF_STATUS_REPORTING_PROVED") diff --git a/tests/frontend/outboundMessageStatus.oracle.test.js b/tests/frontend/outboundMessageStatus.oracle.test.js new file mode 100644 index 00000000..4a25516f --- /dev/null +++ b/tests/frontend/outboundMessageStatus.oracle.test.js @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: 0BSD AND MIT +/** + * Oracle tests for outbound LXMF status icons and titles. + * + * Guarantee: UI status glyphs and title keys follow the API state/method + * strings that backend convert_lxmf_* emits from LXMF.LXMessage. Propagation + * is method-only. Opportunistic+failed is deferred wait, not a hard fail badge. + */ +import { describe, expect, it } from "vitest"; +import { + isOpportunisticDeferredDelivery, + outboundBubbleStatusIconName, + outboundBubbleStatusTitleKey, +} from "@/js/outboundMessageStatus.js"; + +/** Closed vocabulary aligned with meshchatx/src/backend/lxmf_utils.py converters. */ +const API_STATES = [ + "generating", + "outbound", + "sending", + "sent", + "delivered", + "rejected", + "cancelled", + "failed", + "unknown", +]; + +const API_METHODS = ["opportunistic", "direct", "propagated", "paper", "unknown"]; + +/** + * Independent UI oracle. Must stay in sync with product intent, not by + * importing the implementation under test into the expected table. + */ +function oracleIconName({ state, method }) { + if (state === "delivered") { + if (method === "propagated") return "email-check-outline"; + if (method === "paper") return "note-check-outline"; + return "check-all"; + } + if (state === "sent" || state === "propagated" || state === "unknown") { + if (method === "propagated") return "email-outline"; + if (method === "paper") return "note-outline"; + return "check"; + } + return "check"; +} + +function oracleTitleKey({ state, method }) { + if (state === "delivered") { + if (method === "propagated") return "messages.outbound_delivered_propagated"; + return "messages.outbound_delivered"; + } + if (method === "propagated") return "messages.outbound_on_propagation_node"; + return "messages.outbound_sent_network"; +} + +function oracleDeferred({ state, method }) { + return method === "opportunistic" && state === "failed"; +} + +describe("outboundMessageStatus LXMF oracle", () => { + it("maps every API state x method pair to the independent icon oracle", () => { + for (const state of API_STATES) { + for (const method of API_METHODS) { + const msg = { state, method }; + expect(outboundBubbleStatusIconName(msg)).toBe(oracleIconName(msg)); + } + } + }); + + it("maps every API state x method pair to the independent title-key oracle", () => { + for (const state of API_STATES) { + for (const method of API_METHODS) { + const msg = { state, method }; + expect(outboundBubbleStatusTitleKey(msg)).toBe(oracleTitleKey(msg)); + } + } + }); + + it("treats only opportunistic+failed as deferred wait", () => { + for (const state of API_STATES) { + for (const method of API_METHODS) { + const msg = { state, method }; + expect(isOpportunisticDeferredDelivery(msg)).toBe(oracleDeferred(msg)); + } + } + }); + + it("propagated sent means on-node mailbox icon, not delivered checkmarks", () => { + const msg = { state: "sent", method: "propagated" }; + expect(outboundBubbleStatusIconName(msg)).toBe("email-outline"); + expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_on_propagation_node"); + expect(outboundBubbleStatusIconName(msg)).not.toBe("check-all"); + expect(outboundBubbleStatusIconName(msg)).not.toBe("email-check-outline"); + }); + + it("propagated delivered means recipient collected mail", () => { + const msg = { state: "delivered", method: "propagated" }; + expect(outboundBubbleStatusIconName(msg)).toBe("email-check-outline"); + expect(outboundBubbleStatusTitleKey(msg)).toBe("messages.outbound_delivered_propagated"); + }); + + it("direct and opportunistic use WhatsApp-style checks for sent/delivered", () => { + for (const method of ["direct", "opportunistic"]) { + expect(outboundBubbleStatusIconName({ state: "sent", method })).toBe("check"); + expect(outboundBubbleStatusIconName({ state: "delivered", method })).toBe("check-all"); + } + }); + + it("paper uses note icons for sent and delivered", () => { + expect(outboundBubbleStatusIconName({ state: "sent", method: "paper" })).toBe("note-outline"); + expect(outboundBubbleStatusIconName({ state: "delivered", method: "paper" })).toBe("note-check-outline"); + }); + + it("backend never emits state=propagated but UI still treats it as sent-like", () => { + expect(outboundBubbleStatusIconName({ state: "propagated", method: "direct" })).toBe("check"); + expect(outboundBubbleStatusIconName({ state: "propagated", method: "propagated" })).toBe("email-outline"); + }); + + it("null message has safe defaults", () => { + expect(outboundBubbleStatusIconName(null)).toBe("check"); + expect(outboundBubbleStatusTitleKey(null)).toBeNull(); + expect(isOpportunisticDeferredDelivery(null)).toBe(false); + }); + + it("LXMF_STATUS_UI_ORACLE_PROVED", () => { + let pairs = 0; + for (const state of API_STATES) { + for (const method of API_METHODS) { + const msg = { state, method }; + expect(outboundBubbleStatusIconName(msg)).toBe(oracleIconName(msg)); + expect(outboundBubbleStatusTitleKey(msg)).toBe(oracleTitleKey(msg)); + expect(isOpportunisticDeferredDelivery(msg)).toBe(oracleDeferred(msg)); + pairs += 1; + } + } + expect(pairs).toBe(API_STATES.length * API_METHODS.length); + console.log("LXMF_STATUS_UI_ORACLE_PROVED"); + }); +});