From 4b8f5b02b86630d46c9ef63f3574f0e27bcb50e1 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 09:24:23 +0200 Subject: [PATCH] test(sync): close lifecycle validation evidence --- CHANGELOG.md | 8 + .../trainlog/data/TrainlogRepository.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 170 ++++++++++++++++++ docs/current_state.md | 11 ++ docs/design/sync_gap_contract_v1.md | 8 + docs/tests.md | 42 +++-- tests/support/roundtrip_desktop_lifecycle.py | 67 +++++++ 7 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 tests/support/roundtrip_desktop_lifecycle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 86507f7..f519526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ remains V3; no service, user database, phone, tag or release artifact was changed or deployed. +- Closed the lifecycle validation evidence with real Android/desktop V4 + history and execution-draft round trips in both producer directions. Fixed + Android V4 import so validated target, load, and rest fields are persisted, + reconciled the 73/75 native count to the two `web=auto` frontend tests, and + documented identical pre-existing GCC 16 `-Wmisleading-indentation` + failures on baseline and branch while retaining Clang 22 strict green + builds. V4 remains staged and inactive. + - Completed `TRAINLOG_SYNC_CHARACTERIZATION_V1` without changing production synchronization, schemas, or formats. Added a real Android V3 exporter → desktop importer/exporter → fresh Android importer round trip, and completed diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 2a65760..c871bdd 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -4072,7 +4072,7 @@ class TrainlogRepository( put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId) put("position", position); put("recording_mode", recording); put("tracking_mode", tracking) put("data_fields", entry.optInt("data_fields", 0)) - if (version == 3) { + if (version >= 3) { val target = entry.optJSONObject("target") put("load_mode", entry.getString("load_mode")) put("rest_seconds", entry.getInt("rest_seconds")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index e921b67..6311bf3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -38,6 +38,8 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.io.File import java.nio.file.Files +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID @RunWith(RobolectricTestRunner::class) @@ -507,6 +509,146 @@ class TrainlogRepositoryDraftTest { } } + @Test + fun v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations() { + val sourceName = "v4-source-${UUID.randomUUID()}.db" + val destinationName = "v4-destination-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + val root = Files.createTempDirectory("trainlog-v4-roundtrip-").toFile() + try { + val exercise = createExercise(source, "V4 échange", RecordingMode.SETS, TrackingMode.REPS) + val entryId = "sxe_40000000-0000-4000-8000-000000000001" + assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft( + entryId = entryId, exercise = exercise, equipmentId = "leg_press", + plan = SessionExercisePlan(2, reps = 8, weightKg = 42.5, + loadMode = SessionLoadMode.EXTERNAL, restSeconds = 90), + sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0), + SessionSetDraft(reps = 6, weightKg = 42.5)), + )))) is SaveSessionResult.Saved) + val sourceArtifact = JSONObject(source.buildMobileExportV4Json()) + val session = sourceArtifact.getJSONArray("sessions").getJSONObject(0) + val sessionId = session.getString("session_id") + assertTrue(source.updateSyncNote("session", sessionId, + "quoted \"note\" \\ newline\né") is SyncNoteEditResult.Saved) + assertTrue(source.updateSyncNote("occurrence", entryId, "x".repeat(4096)) + is SyncNoteEditResult.Saved) + assertEquals(SyncNoteEditResult.Invalid, + source.updateSyncNote("occurrence", entryId, "x".repeat(4097))) + val observation = source.saveBodyObservation( + BodyObservationDraft(bodyWeightKg = 81.25, waistCm = 84.0), + ) as SaveBodyObservationResult.Saved + assertTrue(source.linkBodyObservationToSession(observation.observationId, sessionId)) + assertTrue(source.updateSyncNote("observation", observation.observationId, "") + is SyncNoteEditResult.Saved) + val androidExport = File(root, "android-v4.json") + val completed = JSONObject(source.buildMobileExportV4Json()) + val completedSession = completed.getJSONArray("sessions").getJSONObject(0) + val started = OffsetDateTime.parse(completedSession.getString("started_at")) + completedSession.put("ended_at", started.plusMinutes(1).toString()) + androidExport.writeText(completed.toString()) + val desktopDatabase = File(root, "desktop.sqlite") + val desktopExport = File(root, "desktop-v4.json") + runLifecycleBridge("history", androidExport, desktopDatabase, desktopExport) + + val returned = JSONObject(desktopExport.readText()) + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", JSONArray(returned.getJSONArray("exercises").toString())) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + val equalEnd = JSONObject(returned.toString()) + equalEnd.getJSONArray("sessions").getJSONObject(0) + .put("ended_at", started.toString()) + assertTrue(destination.applyPcMobileExportV4Json(equalEnd.toString()) + is MobileSessionImportResult.Invalid) + val equivalentOffset = JSONObject(returned.toString()) + equivalentOffset.getJSONArray("sessions").getJSONObject(0) + .put("ended_at", started.withOffsetSameInstant(ZoneOffset.UTC).toString()) + assertTrue(destination.applyPcMobileExportV4Json(equivalentOffset.toString()) + is MobileSessionImportResult.Invalid) + assertEquals(MobileSessionImportResult.Applied(1, 0, 1, 0), + destination.applyPcMobileExportV4Json(returned.toString())) + assertEquals(MobileSessionImportResult.Applied(0, 1, 0, 1), + destination.applyPcMobileExportV4Json(returned.toString())) + val detail = destination.getSessionDetail(sessionId)!!.exercises.single() + assertEquals(entryId, detail.entryId) + assertEquals(90, detail.plan!!.restSeconds) + assertEquals(listOf(8, 6), detail.sets.map { it.reps }) + assertEquals(listOf(40.0, 42.5), detail.sets.map { it.weightKg }) + assertEquals(sessionId, JSONObject(destination.buildMobileExportV4Json()) + .getJSONArray("body_observations").getJSONObject(0).getString("session_id")) + assertEquals(semanticV4Projection(JSONObject(androidExport.readText())), + semanticV4Projection(JSONObject(destination.buildMobileExportV4Json()))) + + val secondDesktop = File(root, "desktop-fresh.sqlite") + val finalDesktopExport = File(root, "desktop-final-v4.json") + val destinationExport = File(root, "destination-v4.json") + destinationExport.writeText(destination.buildMobileExportV4Json()) + runLifecycleBridge("history", destinationExport, secondDesktop, finalDesktopExport) + assertEquals(semanticV4Projection(JSONObject(destinationExport.readText())), + semanticV4Projection(JSONObject(finalDesktopExport.readText()))) + } finally { + source.close(); destination.close() + context.deleteDatabase(sourceName); context.deleteDatabase(destinationName) + root.deleteRecursively() + } + } + + @Test + fun executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations() { + val sourceName = "draft-source-${UUID.randomUUID()}.db" + val destinationName = "draft-destination-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + val root = Files.createTempDirectory("trainlog-draft-roundtrip-").toFile() + try { + val exercise = createExercise(source, "Draft bridge", RecordingMode.SETS, TrackingMode.REPS) + val entryId = "sxe_50000000-0000-4000-8000-000000000001" + assertEquals(ActiveDraftMutationResult.Saved, source.saveActiveSessionDraft( + ActiveSessionDraft(exercises = listOf(SessionExerciseDraft( + entryId = entryId, exercise = exercise, + plan = SessionExercisePlan(3, reps = 7, restSeconds = 75), + sets = listOf(SessionSetDraft(reps = 7)), + )), form = SessionDraftForm(weightText = "unfinished 42.")), + )) + val initial = JSONObject(source.buildExecutionDraftExportV1Json()) + assertFalse(initial.toString().contains("unfinished 42.")) + val draft = initial.getJSONArray("drafts").getJSONObject(0) + val sessionId = draft.getString("session_id") + val androidExport = File(root, "android-draft.json").apply { writeText(initial.toString()) } + val desktopDatabase = File(root, "desktop.sqlite") + val desktopExport = File(root, "desktop-draft.json") + runLifecycleBridge("draft", androidExport, desktopDatabase, desktopExport) + + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", JSONObject(source.buildMobileExportV4Json()).getJSONArray("exercises")) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertEquals(ExecutionDraftImportResult.Applied(1, 0, 0, 0), + destination.applyExecutionDraftExportV1Json(desktopExport.readText())) + assertEquals(ExecutionDraftImportResult.Applied(0, 0, 1, 0), + destination.applyExecutionDraftExportV1Json(desktopExport.readText())) + val destinationArtifact = File(root, "destination-draft.json").apply { + writeText(destination.buildExecutionDraftExportV1Json()) + } + val freshDesktop = File(root, "desktop-fresh.sqlite") + val finalArtifact = File(root, "desktop-final-draft.json") + runLifecycleBridge("draft", destinationArtifact, freshDesktop, finalArtifact) + assertEquals(semanticDraftProjection(JSONObject(androidExport.readText())), + semanticDraftProjection(JSONObject(finalArtifact.readText()))) + assertEquals(FinalizeActiveDraftResult.Saved(sessionId), + destination.finalizeExecutionDraft(sessionId)) + destination.close() + val reopened = TrainlogRepository(context, destinationName) + assertEquals(ExecutionDraftImportResult.Applied(0, 0, 0, 1), + reopened.applyExecutionDraftExportV1Json(desktopExport.readText())) + reopened.close() + } finally { + source.close() + try { destination.close() } catch (_: Exception) { } + context.deleteDatabase(sourceName); context.deleteDatabase(destinationName) + root.deleteRecursively() + } + } + @Test fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() { val repo = openRepository() @@ -2583,6 +2725,34 @@ class TrainlogRepositoryDraftTest { return canonicalJson(copy).toString() } + private fun semanticV4Projection(value: JSONObject): String = + semanticV3Projection(value) + + private fun semanticDraftProjection(value: JSONObject): String { + val copy = JSONObject(value.toString()) + copy.remove("generated_at") + val source = copy.getJSONArray("drafts") + val sorted = (0 until source.length()).map { source.getJSONObject(it) } + .sortedBy { it.getString("session_id") } + copy.put("drafts", JSONArray().also { output -> sorted.forEach(output::put) }) + return canonicalJson(copy).toString() + } + + private fun runLifecycleBridge( + kind: String, + input: File, + database: File, + output: File, + ) { + val bridge = findRepositoryFile("tests/support/roundtrip_desktop_lifecycle.py") + val process = ProcessBuilder( + "python3", bridge.absolutePath, kind, input.absolutePath, + database.absolutePath, output.absolutePath, + ).redirectErrorStream(true).start() + val text = process.inputStream.bufferedReader().readText() + assertEquals("desktop lifecycle bridge failed: $text", 0, process.waitFor()) + } + private fun canonicalJson(value: Any?): Any = when (value) { is JSONObject -> JSONObject().also { normalized -> value.keys().asSequence().toList().sorted().forEach { key -> diff --git a/docs/current_state.md b/docs/current_state.md index 65653c3..81dc511 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -223,6 +223,17 @@ four skips remain the documented optional historical v9, v12, v13, and v14 database fixtures. No hardware MTP, real Drive, adb, instrumented device test, user database, exchange directory, or user service participated. +For the `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` closeout on 2026-09-17, real staged +V4 history and execution-draft entry points completed both Android/desktop +producer directions through fresh destination databases. The closeout fixed +one Android V4 import defect that validated but failed to persist occurrence +plan fields. Isolated validation passed **75/75 normal Meson tests**, +**75/75 ASan/UBSan Meson tests**, Android debug assembly, and **207 Android +tests (203 passed, 4 skipped, 0 failed/error)**. The four skips remain the +optional real historical v9, v12, v13, and v14 database fixtures. The active +transport remains V3; no phone, MTP, Drive, service, user database, deployment, +or instrumented device test participated. + The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and **62/62 ASan/UBSan Meson tests**, including the standalone public-header and Core characterization targets, plus both canonical JSON validators. diff --git a/docs/design/sync_gap_contract_v1.md b/docs/design/sync_gap_contract_v1.md index 95f6dac..f6b0670 100644 --- a/docs/design/sync_gap_contract_v1.md +++ b/docs/design/sync_gap_contract_v1.md @@ -443,6 +443,14 @@ on the real private-XDG lock. The assertion-level matrix is owned by tombstones, coherent generations, causal merge, or a peer-consumption acknowledgement. +**[CURRENT EVIDENCE]** `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` now has +real cross-implementation round trips for enriched mobile V4 history and the +separate execution-draft V1 artifact in both producer directions. The exact +entry points, assertions, commands, compiler comparison, test inventory, and +remaining transport boundary are recorded in `docs/tests.md`. This evidence +does not select V4 transport and does not implement causal deletion, +generation manifests, peer-consumption acknowledgement, or orchestration. + ## 16. Sources propriétaires - état/roadmap : `docs/current_state.md`, `docs/roadmap.md` ; diff --git a/docs/tests.md b/docs/tests.md index 0fa713c..83d7d6c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -402,11 +402,27 @@ python3 tools/validate_sync_isolated.py --suite full --jdk /usr/lib/jvm/java-17- `TRAINLOG_SYNC_DATA_LIFECYCLE_V1` adds schema v19/v18 migration assertions and Android lifecycle coverage for confirmed-only serialization, persistent pending state, occupied-singleton refusal, explicit activation, identity- -preserving idempotent finalization and stale replay. The full isolated run at -closure passed 73/73 normal native tests, 73/73 ASan/UBSan native tests and 205 -Android tests (201 passed, four unchanged historical-fixture skips), plus both -Python validators and `assembleDebug`. These are artifact/repository proofs; -they do not claim transport activation or global synchronization atomicity. +preserving idempotent finalization and stale replay. The closeout adds +`v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` and +`executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations`. Their +bridge provisions only synthetic schema and invokes the real Android and +desktop staged entry points; it does not reconstruct wire data. The final +isolated run passed 75/75 normal native tests, 75/75 ASan/UBSan native tests +and 207 Android tests (203 passed, four unchanged historical-fixture skips), +plus both Python validators and `assembleDebug`. These are artifact/repository +proofs; they do not claim transport activation or global synchronization +atomicity. + +The 75-versus-73 inventory contains no removed, renamed, merged, or disabled +native tests. With `web=auto` and no `web/node_modules`, Meson selects 73 tests +and omits exactly `web_frontend_typecheck` and `web_frontend`. After `npm ci` +from the locked package file and Meson reconfiguration, both the normal and +ASan/UBSan selections contain and pass all 75. Clang 22.1.8 is the compiler +used for those green builds. GCC 16.2.1 fails both baseline `77c1517` and the +lifecycle branch under the unchanged strict `-Werror` policy on +`-Wmisleading-indentation` in unchanged `json_writer.c`, +`web_dashboard_json.c`, and `web_dashboard.c`; this is a pre-existing +compiler-version limitation, not lifecycle evidence and not a relaxed gate. Use `--jdk /absolute/jdk17/home` when auto-detection is unsuitable and `--run-parent /absolute/private/parent` to select another parent outside the @@ -695,14 +711,16 @@ passing tests: | Request, receipt, and processed marker | `sync_body_zone_wiring` through `trainlog_sync_run()` | Receipt and report carry the exact `request_id`; identical request replay is refused; receipt-send failure leaves the prior processed marker unchanged | Added | | Android post-receipt consumption | Desktop request/receipt proof plus Android typed receipt/inbox component tests | Desktop processing success and Android artifact-import success are separate component results; V1 has no durable peer-consumption acknowledgement | Component-scoped only | | Inter-process lock | `sync_body_zone_wiring` against the real `sync.lock` acquisition with private `XDG_DATA_HOME` | A pipe-coordinated child holds the lock; the second trigger returns conflict before any MTP probe; every child is joined | Added | -| V4 drafts, general tombstones, coherent generations, causal merge, and consumption acknowledgement | Frozen gap contract only | No active production implementation exists | Future criterion, not tested green | +| Enriched V4 completed history | `TrainlogRepositoryDraftTest.v4HistoryRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android V4 repository methods and the real desktop V4 importer/exporter | Android → desktop → fresh Android and desktop → Android → fresh desktop preserve stable identities, order, heterogeneous sets, plans, equipment, causal null/empty/escaped UTF-8 notes and idempotent replay | Added cross-implementation proof | +| Execution-draft V1 | `TrainlogRepositoryDraftTest.executionDraftRoundTripsAcrossRealAndroidAndDesktopImplementations` through Android repository methods and `execution_draft_exchange.py` | Both implementation directions preserve stable session/entry identity and confirmed facts; raw unfinished form text remains local; replay is unchanged and pre-finalization replay is stale after reopen/finalization | Added cross-implementation proof | +| General tombstones, coherent generations, causal deletion, and consumption acknowledgement | Frozen gap contract only | No active production implementation exists | Future criterion, not tested green | -The cross-implementation helper only provisions the established minimal desktop -test schema and launches the production Python tools; it does not duplicate -their business rules. The Kotlin test uses the real Android V3 exporter and -importer on two distinct databases. Its comparison removes only the volatile -`generated_at`, canonicalizes object-key and unordered top-level collection -order, and retains occurrence/set array order as business data. +The cross-implementation helpers only provision minimal production-compatible +desktop schemas and launch production Python tools; they do not duplicate +their business rules. Kotlin tests use real Android exporters and importers on +distinct databases. Comparisons remove only volatile `generated_at`, +canonicalize object-key and unordered top-level collection order, and retain +occurrence/set array order as business data. Request/receipt evidence remains deliberately bounded: the native test proves desktop correlation, publication, replay handling, and marking order, while diff --git a/tests/support/roundtrip_desktop_lifecycle.py b/tests/support/roundtrip_desktop_lifecycle.py new file mode 100644 index 0000000..c5986b6 --- /dev/null +++ b/tests/support/roundtrip_desktop_lifecycle.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Bridge lifecycle artifacts through the real staged desktop entry points.""" + +import argparse +import sqlite3 +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tests")) + +from test_session_exchange_v3 import V11_SCHEMA # noqa: E402 + + +V19_ADDITIONS = """ +ALTER TABLE session_exercises ADD COLUMN tracking_mode TEXT; +CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id) ON DELETE RESTRICT,CHECK(source_exercise_id<>canonical_exercise_id)); +CREATE TABLE exercise_feedback(id INTEGER PRIMARY KEY,feedback_id TEXT NOT NULL UNIQUE,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,observed_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE exercise_feedback_revisions(revision_id TEXT PRIMARY KEY,feedback_id TEXT NOT NULL REFERENCES exercise_feedback(feedback_id) ON DELETE CASCADE,created_at TEXT NOT NULL,raw_text TEXT NOT NULL); +CREATE TABLE sync_note_revisions(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,note TEXT,CHECK(note IS NULL OR length(CAST(note AS BLOB))<=4096),PRIMARY KEY(owner_kind,owner_id,revision_id)); +CREATE TABLE sync_note_state(owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL,PRIMARY KEY(owner_kind,owner_id),FOREIGN KEY(owner_kind,owner_id,revision_id) REFERENCES sync_note_revisions(owner_kind,owner_id,revision_id)); +CREATE TABLE execution_drafts(session_id TEXT PRIMARY KEY,session_type TEXT NOT NULL,source_session_id TEXT,started_at TEXT,revision_id TEXT NOT NULL,parent_revision_id TEXT,state TEXT NOT NULL,payload_json TEXT NOT NULL); +CREATE TABLE execution_draft_revisions(session_id TEXT NOT NULL,revision_id TEXT NOT NULL,parent_revision_id TEXT,payload_json TEXT NOT NULL,PRIMARY KEY(session_id,revision_id)); +CREATE TABLE execution_draft_finalizations(session_id TEXT PRIMARY KEY,final_revision_id TEXT NOT NULL,finalized_at TEXT NOT NULL); +PRAGMA user_version=19; +""" + + +def run(arguments: list[str]) -> None: + result = subprocess.run(arguments, text=True, capture_output=True, check=False) + if result.returncode != 0: + raise RuntimeError(result.stdout + result.stderr) + + +def create_v19(path: Path) -> None: + # CONTRACT: fixture provisioning supplies only the production schema. All + # artifact semantics remain owned by the real desktop tools below. + with sqlite3.connect(path) as connection: + connection.executescript(V11_SCHEMA) + connection.executescript(V19_ADDITIONS) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("kind", choices=("history", "draft")) + parser.add_argument("input", type=Path) + parser.add_argument("database", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + create_v19(args.database) + if args.kind == "history": + run([sys.executable, str(ROOT / "tools/import_mobile_export.py"), + str(args.input), "--database", str(args.database)]) + run([sys.executable, str(ROOT / "tools/export_pc_mobile.py"), + str(args.output), "--database", str(args.database), "--version", "4"]) + else: + tool = str(ROOT / "tools/execution_draft_exchange.py") + run([sys.executable, tool, "import", str(args.input), + "--database", str(args.database)]) + run([sys.executable, tool, "export", str(args.output), + "--database", str(args.database)]) + + +if __name__ == "__main__": + main()