@invarn/cibuild 2.9.4 → 2.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-35SI42CD.mjs +1 -0
- package/dist/chunk-IC45KVLJ.mjs +1 -0
- package/dist/chunk-XDY4V23V.mjs +1 -0
- package/dist/cli.cjs +14 -14
- package/dist/commands.mjs +1 -0
- package/dist/lib.mjs +3934 -0
- package/package.json +6 -10
- package/dist/src/cli.d.ts +0 -3
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -399
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +0 -38
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +0 -1
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +0 -222
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +0 -41
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +0 -1
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +0 -324
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts +0 -2
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts.map +0 -1
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.js +0 -382
- package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts +0 -29
- package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map +0 -1
- package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.js +0 -160
- package/dist/src/commands/android-gms-guard.test.d.ts +0 -9
- package/dist/src/commands/android-gms-guard.test.d.ts.map +0 -1
- package/dist/src/commands/android-gms-guard.test.js +0 -78
- package/dist/src/commands/android-java-version.test.d.ts +0 -12
- package/dist/src/commands/android-java-version.test.d.ts.map +0 -1
- package/dist/src/commands/android-java-version.test.js +0 -209
- package/dist/src/commands/android-scanner-java.test.d.ts +0 -2
- package/dist/src/commands/android-scanner-java.test.d.ts.map +0 -1
- package/dist/src/commands/android-scanner-java.test.js +0 -130
- package/dist/src/commands/android-scanner.d.ts +0 -250
- package/dist/src/commands/android-scanner.d.ts.map +0 -1
- package/dist/src/commands/android-scanner.js +0 -1639
- package/dist/src/commands/build.d.ts +0 -215
- package/dist/src/commands/build.d.ts.map +0 -1
- package/dist/src/commands/build.js +0 -1433
- package/dist/src/commands/build.test.d.ts +0 -13
- package/dist/src/commands/build.test.d.ts.map +0 -1
- package/dist/src/commands/build.test.js +0 -121
- package/dist/src/commands/detect-platform.d.ts +0 -13
- package/dist/src/commands/detect-platform.d.ts.map +0 -1
- package/dist/src/commands/detect-platform.js +0 -51
- package/dist/src/commands/detect-project.d.ts +0 -7
- package/dist/src/commands/detect-project.d.ts.map +0 -1
- package/dist/src/commands/detect-project.js +0 -12
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +0 -26
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +0 -1
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +0 -196
- package/dist/src/commands/edit.d.ts +0 -12
- package/dist/src/commands/edit.d.ts.map +0 -1
- package/dist/src/commands/edit.js +0 -652
- package/dist/src/commands/edit.test.d.ts +0 -6
- package/dist/src/commands/edit.test.d.ts.map +0 -1
- package/dist/src/commands/edit.test.js +0 -45
- package/dist/src/commands/fidelity-trim.d.ts +0 -13
- package/dist/src/commands/fidelity-trim.d.ts.map +0 -1
- package/dist/src/commands/fidelity-trim.js +0 -21
- package/dist/src/commands/fidelity-trim.test.d.ts +0 -8
- package/dist/src/commands/fidelity-trim.test.d.ts.map +0 -1
- package/dist/src/commands/fidelity-trim.test.js +0 -79
- package/dist/src/commands/file-secret-collector.d.ts +0 -50
- package/dist/src/commands/file-secret-collector.d.ts.map +0 -1
- package/dist/src/commands/file-secret-collector.js +0 -217
- package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts +0 -38
- package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map +0 -1
- package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.js +0 -300
- package/dist/src/commands/github-workflow.d.ts +0 -5
- package/dist/src/commands/github-workflow.d.ts.map +0 -1
- package/dist/src/commands/github-workflow.js +0 -45
- package/dist/src/commands/index.d.ts +0 -33
- package/dist/src/commands/index.d.ts.map +0 -1
- package/dist/src/commands/index.js +0 -33
- package/dist/src/commands/init.d.ts +0 -20
- package/dist/src/commands/init.d.ts.map +0 -1
- package/dist/src/commands/init.js +0 -264
- package/dist/src/commands/ios-scanner.d.ts +0 -104
- package/dist/src/commands/ios-scanner.d.ts.map +0 -1
- package/dist/src/commands/ios-scanner.js +0 -492
- package/dist/src/commands/ios-scheme-ranking.test.d.ts +0 -2
- package/dist/src/commands/ios-scheme-ranking.test.d.ts.map +0 -1
- package/dist/src/commands/ios-scheme-ranking.test.js +0 -264
- package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts +0 -30
- package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts.map +0 -1
- package/dist/src/commands/local-properties-reaches-every-workflow.test.js +0 -146
- package/dist/src/commands/reset.d.ts +0 -7
- package/dist/src/commands/reset.d.ts.map +0 -1
- package/dist/src/commands/reset.js +0 -81
- package/dist/src/commands/run.d.ts +0 -22
- package/dist/src/commands/run.d.ts.map +0 -1
- package/dist/src/commands/run.js +0 -141
- package/dist/src/commands/secrets-sync-workflow.d.ts +0 -15
- package/dist/src/commands/secrets-sync-workflow.d.ts.map +0 -1
- package/dist/src/commands/secrets-sync-workflow.js +0 -255
- package/dist/src/commands/secrets-upload.d.ts +0 -21
- package/dist/src/commands/secrets-upload.d.ts.map +0 -1
- package/dist/src/commands/secrets-upload.js +0 -177
- package/dist/src/commands/secrets-upload.test.d.ts +0 -5
- package/dist/src/commands/secrets-upload.test.d.ts.map +0 -1
- package/dist/src/commands/secrets-upload.test.js +0 -60
- package/dist/src/commands/validate.d.ts +0 -10
- package/dist/src/commands/validate.d.ts.map +0 -1
- package/dist/src/commands/validate.js +0 -46
- package/dist/src/config.d.ts +0 -3
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -47
- package/dist/src/envman/cli.d.ts +0 -21
- package/dist/src/envman/cli.d.ts.map +0 -1
- package/dist/src/envman/cli.js +0 -240
- package/dist/src/envman/envman.d.ts +0 -83
- package/dist/src/envman/envman.d.ts.map +0 -1
- package/dist/src/envman/envman.js +0 -361
- package/dist/src/envman/envman.test.d.ts +0 -5
- package/dist/src/envman/envman.test.d.ts.map +0 -1
- package/dist/src/envman/envman.test.js +0 -236
- package/dist/src/envman/envstore-round-trip.test.d.ts +0 -27
- package/dist/src/envman/envstore-round-trip.test.d.ts.map +0 -1
- package/dist/src/envman/envstore-round-trip.test.js +0 -130
- package/dist/src/envman/index.d.ts +0 -23
- package/dist/src/envman/index.d.ts.map +0 -1
- package/dist/src/envman/index.js +0 -23
- package/dist/src/envman/shim.d.ts +0 -16
- package/dist/src/envman/shim.d.ts.map +0 -1
- package/dist/src/envman/shim.js +0 -66
- package/dist/src/envman/types.d.ts +0 -55
- package/dist/src/envman/types.d.ts.map +0 -1
- package/dist/src/envman/types.js +0 -12
- package/dist/src/lib.d.ts +0 -33
- package/dist/src/lib.d.ts.map +0 -1
- package/dist/src/lib.js +0 -37
- package/dist/src/pipeline.d.ts +0 -3
- package/dist/src/pipeline.d.ts.map +0 -1
- package/dist/src/pipeline.js +0 -57
- package/dist/src/runner-node-step.test.d.ts +0 -12
- package/dist/src/runner-node-step.test.d.ts.map +0 -1
- package/dist/src/runner-node-step.test.js +0 -61
- package/dist/src/runner.d.ts +0 -31
- package/dist/src/runner.d.ts.map +0 -1
- package/dist/src/runner.js +0 -240
- package/dist/src/shared/detect-project.d.ts +0 -57
- package/dist/src/shared/detect-project.d.ts.map +0 -1
- package/dist/src/shared/detect-project.js +0 -219
- package/dist/src/shared/detect-project.test.d.ts +0 -2
- package/dist/src/shared/detect-project.test.d.ts.map +0 -1
- package/dist/src/shared/detect-project.test.js +0 -161
- package/dist/src/shared/git.d.ts +0 -25
- package/dist/src/shared/git.d.ts.map +0 -1
- package/dist/src/shared/git.js +0 -53
- package/dist/src/shared/git.test.d.ts +0 -9
- package/dist/src/shared/git.test.d.ts.map +0 -1
- package/dist/src/shared/git.test.js +0 -66
- package/dist/src/shared/gitignore.d.ts +0 -6
- package/dist/src/shared/gitignore.d.ts.map +0 -1
- package/dist/src/shared/gitignore.js +0 -26
- package/dist/src/shared/gradle-settings.d.ts +0 -49
- package/dist/src/shared/gradle-settings.d.ts.map +0 -1
- package/dist/src/shared/gradle-settings.js +0 -138
- package/dist/src/shared/gradle-settings.test.d.ts +0 -2
- package/dist/src/shared/gradle-settings.test.d.ts.map +0 -1
- package/dist/src/shared/gradle-settings.test.js +0 -65
- package/dist/src/shared/prompts.d.ts +0 -30
- package/dist/src/shared/prompts.d.ts.map +0 -1
- package/dist/src/shared/prompts.js +0 -121
- package/dist/src/shared/xcode-container.test.d.ts +0 -2
- package/dist/src/shared/xcode-container.test.d.ts.map +0 -1
- package/dist/src/shared/xcode-container.test.js +0 -123
- package/dist/src/step-marker.d.ts +0 -12
- package/dist/src/step-marker.d.ts.map +0 -1
- package/dist/src/step-marker.js +0 -29
- package/dist/src/step-marker.test.d.ts +0 -11
- package/dist/src/step-marker.test.d.ts.map +0 -1
- package/dist/src/step-marker.test.js +0 -45
- package/dist/src/types.d.ts +0 -58
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -2
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +0 -28
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +0 -1
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +0 -223
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts +0 -20
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts.map +0 -1
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.js +0 -201
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +0 -37
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +0 -1
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +0 -255
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts +0 -32
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts.map +0 -1
- package/dist/src/yaml/a-missing-credential-is-loud.test.js +0 -301
- package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts +0 -18
- package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts.map +0 -1
- package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.js +0 -66
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +0 -34
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +0 -1
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +0 -218
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +0 -27
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +0 -1
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +0 -126
- package/dist/src/yaml/bitrise-compat.d.ts +0 -65
- package/dist/src/yaml/bitrise-compat.d.ts.map +0 -1
- package/dist/src/yaml/bitrise-compat.js +0 -206
- package/dist/src/yaml/bitrise-compat.test.d.ts +0 -5
- package/dist/src/yaml/bitrise-compat.test.d.ts.map +0 -1
- package/dist/src/yaml/bitrise-compat.test.js +0 -347
- package/dist/src/yaml/cache-key-missing-file.test.d.ts +0 -12
- package/dist/src/yaml/cache-key-missing-file.test.d.ts.map +0 -1
- package/dist/src/yaml/cache-key-missing-file.test.js +0 -116
- package/dist/src/yaml/converter.d.ts +0 -103
- package/dist/src/yaml/converter.d.ts.map +0 -1
- package/dist/src/yaml/converter.js +0 -420
- package/dist/src/yaml/converter.test.d.ts +0 -5
- package/dist/src/yaml/converter.test.d.ts.map +0 -1
- package/dist/src/yaml/converter.test.js +0 -548
- package/dist/src/yaml/e2e.test.d.ts +0 -6
- package/dist/src/yaml/e2e.test.d.ts.map +0 -1
- package/dist/src/yaml/e2e.test.js +0 -446
- package/dist/src/yaml/env-resolver.d.ts +0 -218
- package/dist/src/yaml/env-resolver.d.ts.map +0 -1
- package/dist/src/yaml/env-resolver.js +0 -591
- package/dist/src/yaml/env-resolver.test.d.ts +0 -5
- package/dist/src/yaml/env-resolver.test.d.ts.map +0 -1
- package/dist/src/yaml/env-resolver.test.js +0 -502
- package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts +0 -16
- package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts.map +0 -1
- package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.js +0 -126
- package/dist/src/yaml/fidelity-input-digest.d.ts +0 -37
- package/dist/src/yaml/fidelity-input-digest.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-input-digest.js +0 -208
- package/dist/src/yaml/fidelity-input-digest.test.d.ts +0 -2
- package/dist/src/yaml/fidelity-input-digest.test.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-input-digest.test.js +0 -167
- package/dist/src/yaml/fidelity-manifest.d.ts +0 -126
- package/dist/src/yaml/fidelity-manifest.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-manifest.js +0 -134
- package/dist/src/yaml/fidelity-manifest.test.d.ts +0 -12
- package/dist/src/yaml/fidelity-manifest.test.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-manifest.test.js +0 -251
- package/dist/src/yaml/interactive-prompts.d.ts +0 -71
- package/dist/src/yaml/interactive-prompts.d.ts.map +0 -1
- package/dist/src/yaml/interactive-prompts.js +0 -260
- package/dist/src/yaml/meta-helpers.d.ts +0 -28
- package/dist/src/yaml/meta-helpers.d.ts.map +0 -1
- package/dist/src/yaml/meta-helpers.js +0 -46
- package/dist/src/yaml/missing-env-handler.d.ts +0 -45
- package/dist/src/yaml/missing-env-handler.d.ts.map +0 -1
- package/dist/src/yaml/missing-env-handler.js +0 -64
- package/dist/src/yaml/parser.d.ts +0 -33
- package/dist/src/yaml/parser.d.ts.map +0 -1
- package/dist/src/yaml/parser.js +0 -148
- package/dist/src/yaml/parser.test.d.ts +0 -5
- package/dist/src/yaml/parser.test.d.ts.map +0 -1
- package/dist/src/yaml/parser.test.js +0 -83
- package/dist/src/yaml/pipeline-with-secrets.d.ts +0 -25
- package/dist/src/yaml/pipeline-with-secrets.d.ts.map +0 -1
- package/dist/src/yaml/pipeline-with-secrets.js +0 -76
- package/dist/src/yaml/platform-detector.d.ts +0 -83
- package/dist/src/yaml/platform-detector.d.ts.map +0 -1
- package/dist/src/yaml/platform-detector.js +0 -189
- package/dist/src/yaml/platform-detector.test.d.ts +0 -5
- package/dist/src/yaml/platform-detector.test.d.ts.map +0 -1
- package/dist/src/yaml/platform-detector.test.js +0 -525
- package/dist/src/yaml/preflight-validation.d.ts +0 -29
- package/dist/src/yaml/preflight-validation.d.ts.map +0 -1
- package/dist/src/yaml/preflight-validation.js +0 -21
- package/dist/src/yaml/secrets-manager.d.ts +0 -87
- package/dist/src/yaml/secrets-manager.d.ts.map +0 -1
- package/dist/src/yaml/secrets-manager.js +0 -230
- package/dist/src/yaml/shell-assignments.d.ts +0 -40
- package/dist/src/yaml/shell-assignments.d.ts.map +0 -1
- package/dist/src/yaml/shell-assignments.js +0 -77
- package/dist/src/yaml/step-validator-check-failure.test.d.ts +0 -24
- package/dist/src/yaml/step-validator-check-failure.test.d.ts.map +0 -1
- package/dist/src/yaml/step-validator-check-failure.test.js +0 -130
- package/dist/src/yaml/step-validator-self-assigned.test.d.ts +0 -23
- package/dist/src/yaml/step-validator-self-assigned.test.d.ts.map +0 -1
- package/dist/src/yaml/step-validator-self-assigned.test.js +0 -191
- package/dist/src/yaml/step-validator.d.ts +0 -67
- package/dist/src/yaml/step-validator.d.ts.map +0 -1
- package/dist/src/yaml/step-validator.js +0 -466
- package/dist/src/yaml/step-validator.test.d.ts +0 -18
- package/dist/src/yaml/step-validator.test.d.ts.map +0 -1
- package/dist/src/yaml/step-validator.test.js +0 -140
- package/dist/src/yaml/steps/android-sign.d.ts +0 -35
- package/dist/src/yaml/steps/android-sign.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-sign.js +0 -147
- package/dist/src/yaml/steps/android-variant-resolution.test.d.ts +0 -2
- package/dist/src/yaml/steps/android-variant-resolution.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-variant-resolution.test.js +0 -260
- package/dist/src/yaml/steps/android-version.d.ts +0 -26
- package/dist/src/yaml/steps/android-version.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-version.js +0 -128
- package/dist/src/yaml/steps/android-version.test.d.ts +0 -5
- package/dist/src/yaml/steps/android-version.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-version.test.js +0 -196
- package/dist/src/yaml/steps/android.d.ts +0 -156
- package/dist/src/yaml/steps/android.d.ts.map +0 -1
- package/dist/src/yaml/steps/android.js +0 -1084
- package/dist/src/yaml/steps/app-store-deploy.d.ts +0 -48
- package/dist/src/yaml/steps/app-store-deploy.d.ts.map +0 -1
- package/dist/src/yaml/steps/app-store-deploy.js +0 -167
- package/dist/src/yaml/steps/base.d.ts +0 -263
- package/dist/src/yaml/steps/base.d.ts.map +0 -1
- package/dist/src/yaml/steps/base.js +0 -380
- package/dist/src/yaml/steps/bitrise-android-tools.d.ts +0 -26
- package/dist/src/yaml/steps/bitrise-android-tools.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-android-tools.js +0 -200
- package/dist/src/yaml/steps/bitrise-android-tools.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-android-tools.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-android-tools.test.js +0 -280
- package/dist/src/yaml/steps/bitrise-apk-info.d.ts +0 -22
- package/dist/src/yaml/steps/bitrise-apk-info.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-apk-info.js +0 -144
- package/dist/src/yaml/steps/bitrise-apk-info.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-apk-info.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-apk-info.test.js +0 -331
- package/dist/src/yaml/steps/bitrise-slack.d.ts +0 -49
- package/dist/src/yaml/steps/bitrise-slack.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-slack.js +0 -280
- package/dist/src/yaml/steps/bitrise-slack.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-slack.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-slack.test.js +0 -484
- package/dist/src/yaml/steps/bitrise-ssh.d.ts +0 -27
- package/dist/src/yaml/steps/bitrise-ssh.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-ssh.js +0 -134
- package/dist/src/yaml/steps/bitrise-ssh.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-ssh.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-ssh.test.js +0 -205
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts +0 -27
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache-pull-daemon.test.js +0 -212
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts +0 -42
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.js +0 -209
- package/dist/src/yaml/steps/cache-push-daemon.test.d.ts +0 -27
- package/dist/src/yaml/steps/cache-push-daemon.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache-push-daemon.test.js +0 -171
- package/dist/src/yaml/steps/cache.d.ts +0 -59
- package/dist/src/yaml/steps/cache.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache.js +0 -1027
- package/dist/src/yaml/steps/cocoapods-deployment-floor.d.ts +0 -42
- package/dist/src/yaml/steps/cocoapods-deployment-floor.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-deployment-floor.js +0 -140
- package/dist/src/yaml/steps/cocoapods-deployment-floor.test.d.ts +0 -11
- package/dist/src/yaml/steps/cocoapods-deployment-floor.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-deployment-floor.test.js +0 -176
- package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts +0 -22
- package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.js +0 -97
- package/dist/src/yaml/steps/cocoapods-locked-bundler.test.d.ts +0 -17
- package/dist/src/yaml/steps/cocoapods-locked-bundler.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-locked-bundler.test.js +0 -282
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +0 -17
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +0 -132
- package/dist/src/yaml/steps/envstore-loader.test.d.ts +0 -15
- package/dist/src/yaml/steps/envstore-loader.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/envstore-loader.test.js +0 -143
- package/dist/src/yaml/steps/fastlane.d.ts +0 -27
- package/dist/src/yaml/steps/fastlane.d.ts.map +0 -1
- package/dist/src/yaml/steps/fastlane.js +0 -79
- package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts +0 -53
- package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-aspect-guard.js +0 -72
- package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts +0 -11
- package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-aspect-guard.test.js +0 -50
- package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts +0 -87
- package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-reference-provenance.js +0 -111
- package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts +0 -22
- package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-reference-provenance.test.js +0 -118
- package/dist/src/yaml/steps/file.d.ts +0 -52
- package/dist/src/yaml/steps/file.d.ts.map +0 -1
- package/dist/src/yaml/steps/file.js +0 -108
- package/dist/src/yaml/steps/flutter.d.ts +0 -63
- package/dist/src/yaml/steps/flutter.d.ts.map +0 -1
- package/dist/src/yaml/steps/flutter.js +0 -215
- package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts +0 -16
- package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.js +0 -127
- package/dist/src/yaml/steps/git-clone.d.ts +0 -32
- package/dist/src/yaml/steps/git-clone.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-clone.js +0 -171
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts +0 -10
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.js +0 -162
- package/dist/src/yaml/steps/git-fetch-retry.d.ts +0 -62
- package/dist/src/yaml/steps/git-fetch-retry.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-fetch-retry.js +0 -155
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts +0 -5
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-fetch-retry.test.js +0 -86
- package/dist/src/yaml/steps/google-play-deploy.d.ts +0 -37
- package/dist/src/yaml/steps/google-play-deploy.d.ts.map +0 -1
- package/dist/src/yaml/steps/google-play-deploy.js +0 -193
- package/dist/src/yaml/steps/google-play-deploy.test.d.ts +0 -5
- package/dist/src/yaml/steps/google-play-deploy.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/google-play-deploy.test.js +0 -310
- package/dist/src/yaml/steps/gradle-memory.d.ts +0 -61
- package/dist/src/yaml/steps/gradle-memory.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-memory.js +0 -311
- package/dist/src/yaml/steps/gradle-memory.test.d.ts +0 -2
- package/dist/src/yaml/steps/gradle-memory.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-memory.test.js +0 -297
- package/dist/src/yaml/steps/gradle-sdk-cmake.d.ts +0 -28
- package/dist/src/yaml/steps/gradle-sdk-cmake.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-sdk-cmake.js +0 -107
- package/dist/src/yaml/steps/gradle-sdk-cmake.test.d.ts +0 -2
- package/dist/src/yaml/steps/gradle-sdk-cmake.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-sdk-cmake.test.js +0 -183
- package/dist/src/yaml/steps/index.d.ts +0 -10
- package/dist/src/yaml/steps/index.d.ts.map +0 -1
- package/dist/src/yaml/steps/index.js +0 -1461
- package/dist/src/yaml/steps/ios-deps.d.ts +0 -78
- package/dist/src/yaml/steps/ios-deps.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-deps.js +0 -279
- package/dist/src/yaml/steps/ios-deps.test.d.ts +0 -5
- package/dist/src/yaml/steps/ios-deps.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-deps.test.js +0 -90
- package/dist/src/yaml/steps/ios-signing.d.ts +0 -31
- package/dist/src/yaml/steps/ios-signing.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-signing.js +0 -144
- package/dist/src/yaml/steps/ios-version.d.ts +0 -47
- package/dist/src/yaml/steps/ios-version.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-version.js +0 -151
- package/dist/src/yaml/steps/java-home-version-spec.test.d.ts +0 -10
- package/dist/src/yaml/steps/java-home-version-spec.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/java-home-version-spec.test.js +0 -58
- package/dist/src/yaml/steps/linting.d.ts +0 -47
- package/dist/src/yaml/steps/linting.d.ts.map +0 -1
- package/dist/src/yaml/steps/linting.js +0 -148
- package/dist/src/yaml/steps/phase2.test.d.ts +0 -6
- package/dist/src/yaml/steps/phase2.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase2.test.js +0 -197
- package/dist/src/yaml/steps/phase3.test.d.ts +0 -5
- package/dist/src/yaml/steps/phase3.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase3.test.js +0 -144
- package/dist/src/yaml/steps/phase4.test.d.ts +0 -5
- package/dist/src/yaml/steps/phase4.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase4.test.js +0 -166
- package/dist/src/yaml/steps/phase5.test.d.ts +0 -6
- package/dist/src/yaml/steps/phase5.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase5.test.js +0 -263
- package/dist/src/yaml/steps/registry.d.ts +0 -88
- package/dist/src/yaml/steps/registry.d.ts.map +0 -1
- package/dist/src/yaml/steps/registry.js +0 -125
- package/dist/src/yaml/steps/registry.test.d.ts +0 -5
- package/dist/src/yaml/steps/registry.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/registry.test.js +0 -235
- package/dist/src/yaml/steps/release.d.ts +0 -50
- package/dist/src/yaml/steps/release.d.ts.map +0 -1
- package/dist/src/yaml/steps/release.js +0 -154
- package/dist/src/yaml/steps/render-post-processor.d.ts +0 -100
- package/dist/src/yaml/steps/render-post-processor.d.ts.map +0 -1
- package/dist/src/yaml/steps/render-post-processor.js +0 -270
- package/dist/src/yaml/steps/render-post-processor.test.d.ts +0 -13
- package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/render-post-processor.test.js +0 -220
- package/dist/src/yaml/steps/script.d.ts +0 -23
- package/dist/src/yaml/steps/script.d.ts.map +0 -1
- package/dist/src/yaml/steps/script.js +0 -63
- package/dist/src/yaml/steps/spec-validation.test.d.ts +0 -6
- package/dist/src/yaml/steps/spec-validation.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/spec-validation.test.js +0 -130
- package/dist/src/yaml/steps/steps.test.d.ts +0 -6
- package/dist/src/yaml/steps/steps.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/steps.test.js +0 -881
- package/dist/src/yaml/steps/structural-facts.d.ts +0 -992
- package/dist/src/yaml/steps/structural-facts.d.ts.map +0 -1
- package/dist/src/yaml/steps/structural-facts.js +0 -3276
- package/dist/src/yaml/steps/structural-facts.test.d.ts +0 -12
- package/dist/src/yaml/steps/structural-facts.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/structural-facts.test.js +0 -3039
- package/dist/src/yaml/steps/test-config.d.ts +0 -5
- package/dist/src/yaml/steps/test-config.d.ts.map +0 -1
- package/dist/src/yaml/steps/test-config.js +0 -25
- package/dist/src/yaml/steps/trim-align-corpus.test.d.ts +0 -28
- package/dist/src/yaml/steps/trim-align-corpus.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/trim-align-corpus.test.js +0 -383
- package/dist/src/yaml/steps/trim-align.d.ts +0 -87
- package/dist/src/yaml/steps/trim-align.d.ts.map +0 -1
- package/dist/src/yaml/steps/trim-align.js +0 -367
- package/dist/src/yaml/steps/trim-align.test.d.ts +0 -11
- package/dist/src/yaml/steps/trim-align.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/trim-align.test.js +0 -261
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +0 -66
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview-android.js +0 -251
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +0 -13
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +0 -302
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +0 -134
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview.js +0 -173
- package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts +0 -11
- package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview.test.js +0 -210
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +0 -155
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +0 -1389
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +0 -2
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +0 -1106
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -230
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +0 -1597
- package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts +0 -18
- package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +0 -1871
- package/dist/src/yaml/steps/xcode-app-product.d.ts +0 -90
- package/dist/src/yaml/steps/xcode-app-product.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-app-product.js +0 -247
- package/dist/src/yaml/steps/xcode-app-product.test.d.ts +0 -2
- package/dist/src/yaml/steps/xcode-app-product.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-app-product.test.js +0 -202
- package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +0 -30
- package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-derived-data.test.js +0 -253
- package/dist/src/yaml/steps/xcode-destination.d.ts +0 -47
- package/dist/src/yaml/steps/xcode-destination.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-destination.js +0 -202
- package/dist/src/yaml/steps/xcode-destination.test.d.ts +0 -9
- package/dist/src/yaml/steps/xcode-destination.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-destination.test.js +0 -227
- package/dist/src/yaml/steps/xcode-new.test.d.ts +0 -5
- package/dist/src/yaml/steps/xcode-new.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-new.test.js +0 -211
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts +0 -9
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-resolve-retry.test.js +0 -146
- package/dist/src/yaml/steps/xcode-simulator-archs.d.ts +0 -53
- package/dist/src/yaml/steps/xcode-simulator-archs.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-simulator-archs.js +0 -98
- package/dist/src/yaml/steps/xcode-simulator-archs.test.d.ts +0 -21
- package/dist/src/yaml/steps/xcode-simulator-archs.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-simulator-archs.test.js +0 -155
- package/dist/src/yaml/steps/xcode.d.ts +0 -230
- package/dist/src/yaml/steps/xcode.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode.js +0 -1340
- package/dist/src/yaml/types.d.ts +0 -76
- package/dist/src/yaml/types.d.ts.map +0 -1
- package/dist/src/yaml/types.js +0 -5
- package/dist/src/yaml/validation-types.d.ts +0 -102
- package/dist/src/yaml/validation-types.d.ts.map +0 -1
- package/dist/src/yaml/validation-types.js +0 -8
- package/dist/src/yaml/yaml-updater.d.ts +0 -24
- package/dist/src/yaml/yaml-updater.d.ts.map +0 -1
- package/dist/src/yaml/yaml-updater.js +0 -128
|
@@ -1,3039 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure unit tests for the structural-facts deriver (UI-fidelity structural
|
|
3
|
-
* pass, Signal A). Fixture-driven, no toolchain — the deriver turns a
|
|
4
|
-
* per-node layout dump into intent-agnostic geometric facts the fidelity
|
|
5
|
-
* judge checks instead of eyeballing.
|
|
6
|
-
*
|
|
7
|
-
* The canonical regression fixture is the motivating miss: a card clips an
|
|
8
|
-
* overflowing voucher tag; the deriver must surface the bottom clip as a
|
|
9
|
-
* fact so it is never again skipped by eye.
|
|
10
|
-
*/
|
|
11
|
-
import { deriveStructuralFacts, derivePresenceFacts, deriveCopyFacts, deriveSpanFacts, deriveLineFacts, deriveColorFacts, deriveBorderFacts, deriveTypefaceFacts, deriveTypeMetricFacts, ciede2000, hexToLab, diffKeyGeometry, buildStructuralBlock, buildReviewScaffold, appearanceReferenceFromPreviousRender, buildAppearanceReferenceFromParams, getStructuralFactsStageInternals, getGeometryStageInternals, } from './structural-facts.js';
|
|
12
|
-
// 200x400 canvas in px for all fixtures unless noted.
|
|
13
|
-
const CANVAS = { left: 0, top: 0, right: 200, bottom: 400 };
|
|
14
|
-
describe('deriveStructuralFacts — containment', () => {
|
|
15
|
-
test('a fully-contained child produces no clip/overflow fact', () => {
|
|
16
|
-
const dump = {
|
|
17
|
-
canvas: CANVAS,
|
|
18
|
-
nodes: [
|
|
19
|
-
{ id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
|
|
20
|
-
{ id: 'label', bounds: { left: 30, top: 30, right: 170, bottom: 60 }, parentId: 'card', clipsChildren: false },
|
|
21
|
-
],
|
|
22
|
-
};
|
|
23
|
-
expect(deriveStructuralFacts(dump)).toEqual([]);
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
describe('deriveStructuralFacts — overflow vs clip', () => {
|
|
27
|
-
test('child overflowing a NON-clipping parent is reported as overflow, not clipped', () => {
|
|
28
|
-
const dump = {
|
|
29
|
-
canvas: CANVAS,
|
|
30
|
-
nodes: [
|
|
31
|
-
{ id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
|
|
32
|
-
// sticks out 15px past the row's right edge
|
|
33
|
-
{ id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
|
|
34
|
-
],
|
|
35
|
-
};
|
|
36
|
-
const facts = deriveStructuralFacts(dump);
|
|
37
|
-
expect(facts).toHaveLength(1);
|
|
38
|
-
expect(facts[0]).toMatchObject({
|
|
39
|
-
kind: 'overflow',
|
|
40
|
-
nodeId: 'chip',
|
|
41
|
-
parentId: 'row',
|
|
42
|
-
edges: ['right'],
|
|
43
|
-
overflowPx: { right: 15 },
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
test('child overflowing a CLIPPING parent is reported as clipped-by-parent with the clipped extent', () => {
|
|
47
|
-
const dump = {
|
|
48
|
-
canvas: CANVAS,
|
|
49
|
-
nodes: [
|
|
50
|
-
{ id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
|
|
51
|
-
// overflows the card's bottom by 24px → that extent is clipped away
|
|
52
|
-
{ id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
|
|
53
|
-
],
|
|
54
|
-
};
|
|
55
|
-
const facts = deriveStructuralFacts(dump);
|
|
56
|
-
expect(facts).toHaveLength(1);
|
|
57
|
-
expect(facts[0]).toMatchObject({
|
|
58
|
-
kind: 'clipped_by_parent',
|
|
59
|
-
nodeId: 'tag',
|
|
60
|
-
clipperId: 'card',
|
|
61
|
-
edges: ['bottom'],
|
|
62
|
-
clippedPx: { bottom: 24 },
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
describe('deriveStructuralFacts — unclippedBounds path (real clamping renders)', () => {
|
|
67
|
-
// A real clipping parent CLAMPS the child's boundsInRoot to its own edge, so
|
|
68
|
-
// the clipped `bounds` show no excess — the overflow lives only in
|
|
69
|
-
// `unclippedBounds`. This is the case bounds-only v1 went blind on.
|
|
70
|
-
test('a clamped child is reported clipped-by-parent, recovered from unclippedBounds', () => {
|
|
71
|
-
const dump = {
|
|
72
|
-
canvas: { left: 0, top: 0, right: 360, bottom: 720 },
|
|
73
|
-
nodes: [
|
|
74
|
-
{
|
|
75
|
-
id: 'HomeOfferBanner',
|
|
76
|
-
bounds: { left: 16, top: 100, right: 344, bottom: 262 },
|
|
77
|
-
unclippedBounds: { left: 16, top: 100, right: 344, bottom: 262 },
|
|
78
|
-
parentId: null,
|
|
79
|
-
clipsChildren: false,
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
// visible bounds CLAMPED to the card bottom (262); the true extent runs
|
|
83
|
-
// 18px past it → 18px cut away. The current emitter writes clipsChildren
|
|
84
|
-
// false, so only unclippedBounds can reveal this.
|
|
85
|
-
id: 'voucherTag',
|
|
86
|
-
bounds: { left: 250, top: 210, right: 330, bottom: 262 },
|
|
87
|
-
unclippedBounds: { left: 250, top: 210, right: 330, bottom: 280 },
|
|
88
|
-
parentId: 'HomeOfferBanner',
|
|
89
|
-
clipsChildren: false,
|
|
90
|
-
},
|
|
91
|
-
],
|
|
92
|
-
};
|
|
93
|
-
const facts = deriveStructuralFacts(dump);
|
|
94
|
-
expect(facts).toHaveLength(1);
|
|
95
|
-
expect(facts[0]).toMatchObject({
|
|
96
|
-
kind: 'clipped_by_parent',
|
|
97
|
-
nodeId: 'voucherTag',
|
|
98
|
-
clipperId: 'HomeOfferBanner',
|
|
99
|
-
edges: ['bottom'],
|
|
100
|
-
clippedPx: { bottom: 18 },
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
test('a child sticking out of a non-clipping parent (bounds == unclippedBounds) is overflow', () => {
|
|
104
|
-
const dump = {
|
|
105
|
-
canvas: CANVAS,
|
|
106
|
-
nodes: [
|
|
107
|
-
{
|
|
108
|
-
id: 'row',
|
|
109
|
-
bounds: { left: 20, top: 20, right: 180, bottom: 100 },
|
|
110
|
-
unclippedBounds: { left: 20, top: 20, right: 180, bottom: 100 },
|
|
111
|
-
parentId: null,
|
|
112
|
-
clipsChildren: false,
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
// not clamped (bounds == unclipped) → the 15px excess is visible.
|
|
116
|
-
id: 'chip',
|
|
117
|
-
bounds: { left: 150, top: 30, right: 195, bottom: 80 },
|
|
118
|
-
unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 },
|
|
119
|
-
parentId: 'row',
|
|
120
|
-
clipsChildren: false,
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
};
|
|
124
|
-
const facts = deriveStructuralFacts(dump);
|
|
125
|
-
expect(facts).toHaveLength(1);
|
|
126
|
-
expect(facts[0]).toMatchObject({
|
|
127
|
-
kind: 'overflow',
|
|
128
|
-
nodeId: 'chip',
|
|
129
|
-
parentId: 'row',
|
|
130
|
-
edges: ['right'],
|
|
131
|
-
overflowPx: { right: 15 },
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
describe('deriveStructuralFacts — off-screen', () => {
|
|
136
|
-
test('a node extending past the canvas is reported off-screen on those edges', () => {
|
|
137
|
-
const dump = {
|
|
138
|
-
canvas: CANVAS,
|
|
139
|
-
nodes: [
|
|
140
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
141
|
-
// bottom edge 30px below the canvas
|
|
142
|
-
{ id: 'sheet', bounds: { left: 0, top: 360, right: 200, bottom: 430 }, parentId: 'root', clipsChildren: false },
|
|
143
|
-
],
|
|
144
|
-
};
|
|
145
|
-
const facts = deriveStructuralFacts(dump);
|
|
146
|
-
const offscreen = facts.filter((f) => f.kind === 'off_screen');
|
|
147
|
-
expect(offscreen).toHaveLength(1);
|
|
148
|
-
expect(offscreen[0]).toMatchObject({
|
|
149
|
-
kind: 'off_screen',
|
|
150
|
-
nodeId: 'sheet',
|
|
151
|
-
edges: ['bottom'],
|
|
152
|
-
offscreenPx: { bottom: 30 },
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
describe('deriveStructuralFacts — overlap', () => {
|
|
157
|
-
test('two significantly overlapping siblings are reported as an overlap', () => {
|
|
158
|
-
const dump = {
|
|
159
|
-
canvas: CANVAS,
|
|
160
|
-
nodes: [
|
|
161
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
162
|
-
{ id: 'a', bounds: { left: 20, top: 20, right: 120, bottom: 120 }, parentId: 'root', clipsChildren: false },
|
|
163
|
-
{ id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
|
|
164
|
-
],
|
|
165
|
-
};
|
|
166
|
-
const overlap = deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap');
|
|
167
|
-
expect(overlap).toHaveLength(1);
|
|
168
|
-
expect(overlap[0]).toMatchObject({ kind: 'overlap', nodeIds: ['a', 'b'] });
|
|
169
|
-
});
|
|
170
|
-
// --- judged on INK, not the container (icon-ink-ratio 02 Half B) ---
|
|
171
|
-
//
|
|
172
|
-
// The live case in render px at density 2: the funnel is an Icon(24.dp) whose
|
|
173
|
-
// drawable paints only 20x14 of its 24x24 viewport, offset 2 in and 5 down; the
|
|
174
|
-
// green badge is a plain Box, so its box IS its ink.
|
|
175
|
-
//
|
|
176
|
-
// vector container 48..96 x, 268..316 y vector INK 52..92 x, 278..306 y
|
|
177
|
-
// badge 80..96 x, 268..284 y
|
|
178
|
-
//
|
|
179
|
-
// Against the container the badge is entirely inside (ratio 1). Against the ink
|
|
180
|
-
// it clips one corner: 12 x 6 px.
|
|
181
|
-
const INK = { kind: 'vector', viewport: { w: 24, h: 24 }, ink: { w: 20, h: 14, x: 2, y: 5 } };
|
|
182
|
-
const funnelDump = (paintedInk) => ({
|
|
183
|
-
canvas: { left: 0, top: 0, right: 750, bottom: 356 },
|
|
184
|
-
nodes: [
|
|
185
|
-
{ id: 'row', bounds: { left: 0, top: 0, right: 750, bottom: 356 }, parentId: null, clipsChildren: false },
|
|
186
|
-
{ id: 'vector', bounds: { left: 48, top: 268, right: 96, bottom: 316 }, parentId: 'row', clipsChildren: false, ...(paintedInk ? { paintedInk } : {}) },
|
|
187
|
-
{ id: 'badge', bounds: { left: 80, top: 268, right: 96, bottom: 284 }, parentId: 'row', clipsChildren: false },
|
|
188
|
-
],
|
|
189
|
-
});
|
|
190
|
-
test('intersects the painted ink when the node declares where its ink sits', () => {
|
|
191
|
-
const overlap = deriveStructuralFacts(funnelDump(INK)).filter((f) => f.kind === 'overlap');
|
|
192
|
-
expect(overlap).toHaveLength(1);
|
|
193
|
-
expect(overlap[0]).toMatchObject({ nodeIds: ['badge', 'vector'], overlapPx: { width: 12, height: 6 } });
|
|
194
|
-
// Ratio against the smaller PAINTED area (badge 16x16 = 256), not the container.
|
|
195
|
-
expect(overlap[0].ratio).toBeCloseTo((12 * 6) / 256, 10);
|
|
196
|
-
});
|
|
197
|
-
test('falls back to the container when the ink is unmeasurable', () => {
|
|
198
|
-
const overlap = deriveStructuralFacts(funnelDump({ kind: 'unmeasurable', why: 'the drawable rotates' })).filter((f) => f.kind === 'overlap');
|
|
199
|
-
expect(overlap[0]).toMatchObject({ overlapPx: { width: 16, height: 16 } });
|
|
200
|
-
});
|
|
201
|
-
test('falls back to the container when the ink declares an extent but no ORIGIN', () => {
|
|
202
|
-
// Every dump written before the origin was captured. The extent alone cannot
|
|
203
|
-
// place the ink, and assuming it centred would be a guess.
|
|
204
|
-
const overlap = deriveStructuralFacts(funnelDump({ kind: 'vector', viewport: { w: 24, h: 24 }, ink: { w: 20, h: 14 } })).filter((f) => f.kind === 'overlap');
|
|
205
|
-
expect(overlap[0]).toMatchObject({ overlapPx: { width: 16, height: 16 } });
|
|
206
|
-
});
|
|
207
|
-
test('drops the overlap entirely when the ink clears the other element', () => {
|
|
208
|
-
// The false positive Half B exists to kill: the badge sits in the container's
|
|
209
|
-
// corner, which the ink never reaches.
|
|
210
|
-
const dump = funnelDump(INK);
|
|
211
|
-
dump.nodes[2].bounds = { left: 92, top: 268, right: 108, bottom: 278 };
|
|
212
|
-
expect(deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap')).toEqual([]);
|
|
213
|
-
});
|
|
214
|
-
test('barely-touching siblings within tolerance are not an overlap', () => {
|
|
215
|
-
const dump = {
|
|
216
|
-
canvas: CANVAS,
|
|
217
|
-
nodes: [
|
|
218
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
219
|
-
{ id: 'a', bounds: { left: 20, top: 20, right: 100, bottom: 100 }, parentId: 'root', clipsChildren: false },
|
|
220
|
-
{ id: 'b', bounds: { left: 100, top: 20, right: 180, bottom: 100 }, parentId: 'root', clipsChildren: false },
|
|
221
|
-
],
|
|
222
|
-
};
|
|
223
|
-
expect(deriveStructuralFacts(dump).filter((f) => f.kind === 'overlap')).toEqual([]);
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
describe('embedded render-side stage parity', () => {
|
|
227
|
-
// The render script ships a JS-source port of the deriver. It must produce
|
|
228
|
-
// byte-identical facts to the canonical typed module, or render-side facts
|
|
229
|
-
// would silently diverge from what the tests guarantee.
|
|
230
|
-
const { deriveStructuralFacts: embedded } = getStructuralFactsStageInternals();
|
|
231
|
-
const fixtures = {
|
|
232
|
-
clip: {
|
|
233
|
-
canvas: CANVAS,
|
|
234
|
-
nodes: [
|
|
235
|
-
{ id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: true },
|
|
236
|
-
{ id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
|
|
237
|
-
],
|
|
238
|
-
},
|
|
239
|
-
overflow: {
|
|
240
|
-
canvas: CANVAS,
|
|
241
|
-
nodes: [
|
|
242
|
-
{ id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
|
|
243
|
-
{ id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
|
|
244
|
-
],
|
|
245
|
-
},
|
|
246
|
-
overlap: {
|
|
247
|
-
canvas: CANVAS,
|
|
248
|
-
nodes: [
|
|
249
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
250
|
-
{ id: 'a', bounds: { left: 20, top: 20, right: 120, bottom: 120 }, parentId: 'root', clipsChildren: false },
|
|
251
|
-
{ id: 'b', bounds: { left: 80, top: 60, right: 180, bottom: 160 }, parentId: 'root', clipsChildren: false },
|
|
252
|
-
],
|
|
253
|
-
},
|
|
254
|
-
// Painted ink with an ORIGIN: the embedded port must map viewport units onto
|
|
255
|
-
// the layout box exactly as the module does, or production overlaps would be
|
|
256
|
-
// measured on containers while the tests guarantee ink.
|
|
257
|
-
overlapInk: {
|
|
258
|
-
canvas: { left: 0, top: 0, right: 750, bottom: 356 },
|
|
259
|
-
nodes: [
|
|
260
|
-
{ id: 'row', bounds: { left: 0, top: 0, right: 750, bottom: 356 }, parentId: null, clipsChildren: false },
|
|
261
|
-
{ id: 'vector', bounds: { left: 48, top: 268, right: 96, bottom: 316 }, parentId: 'row', clipsChildren: false, paintedInk: { kind: 'vector', viewport: { w: 24, h: 24 }, ink: { w: 20, h: 14, x: 2, y: 5 } } },
|
|
262
|
-
{ id: 'badge', bounds: { left: 80, top: 268, right: 96, bottom: 284 }, parentId: 'row', clipsChildren: false },
|
|
263
|
-
],
|
|
264
|
-
},
|
|
265
|
-
// The same ink WITHOUT an origin — the pre-origin fallback must agree too.
|
|
266
|
-
overlapInkNoOrigin: {
|
|
267
|
-
canvas: { left: 0, top: 0, right: 750, bottom: 356 },
|
|
268
|
-
nodes: [
|
|
269
|
-
{ id: 'row', bounds: { left: 0, top: 0, right: 750, bottom: 356 }, parentId: null, clipsChildren: false },
|
|
270
|
-
{ id: 'vector', bounds: { left: 48, top: 268, right: 96, bottom: 316 }, parentId: 'row', clipsChildren: false, paintedInk: { kind: 'vector', viewport: { w: 24, h: 24 }, ink: { w: 20, h: 14 } } },
|
|
271
|
-
{ id: 'badge', bounds: { left: 80, top: 268, right: 96, bottom: 284 }, parentId: 'row', clipsChildren: false },
|
|
272
|
-
],
|
|
273
|
-
},
|
|
274
|
-
// New unclippedBounds path: a clamped child (clip) and a visible-excess child
|
|
275
|
-
// (overflow) — exercises the embedded port's new branch, not just the legacy one.
|
|
276
|
-
clipReal: {
|
|
277
|
-
canvas: CANVAS,
|
|
278
|
-
nodes: [
|
|
279
|
-
{ id: 'card', bounds: { left: 20, top: 20, right: 180, bottom: 200 }, unclippedBounds: { left: 20, top: 20, right: 180, bottom: 200 }, parentId: null, clipsChildren: false },
|
|
280
|
-
{ id: 'tag', bounds: { left: 60, top: 150, right: 140, bottom: 200 }, unclippedBounds: { left: 60, top: 150, right: 140, bottom: 224 }, parentId: 'card', clipsChildren: false },
|
|
281
|
-
],
|
|
282
|
-
},
|
|
283
|
-
overflowReal: {
|
|
284
|
-
canvas: CANVAS,
|
|
285
|
-
nodes: [
|
|
286
|
-
{ id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, unclippedBounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
|
|
287
|
-
{ id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, unclippedBounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
|
|
288
|
-
],
|
|
289
|
-
},
|
|
290
|
-
// A duplicated testTag: the collision guard must withhold the child's
|
|
291
|
-
// (mispairable) overflow and emit the duplicate_tag fact identically.
|
|
292
|
-
duplicateTag: {
|
|
293
|
-
canvas: CANVAS,
|
|
294
|
-
nodes: [
|
|
295
|
-
{ id: 'list', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
296
|
-
{ id: 'copy_button', bounds: { left: 20, top: 20, right: 180, bottom: 60 }, parentId: 'list', clipsChildren: false },
|
|
297
|
-
{ id: 'copy_button', bounds: { left: 20, top: 80, right: 180, bottom: 120 }, parentId: 'list', clipsChildren: false },
|
|
298
|
-
{ id: 'copy_button', bounds: { left: 20, top: 140, right: 180, bottom: 180 }, parentId: 'list', clipsChildren: false },
|
|
299
|
-
{ id: 'copy_label', bounds: { left: 30, top: 30, right: 120, bottom: 50 }, parentId: 'copy_button', clipsChildren: false },
|
|
300
|
-
],
|
|
301
|
-
},
|
|
302
|
-
};
|
|
303
|
-
for (const [name, dump] of Object.entries(fixtures)) {
|
|
304
|
-
test(`embedded port matches the module for the ${name} fixture`, () => {
|
|
305
|
-
expect(embedded(dump)).toEqual(deriveStructuralFacts(dump));
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
describe('deriveStructuralFacts — motivating regression (voucher tag bottom-clip)', () => {
|
|
310
|
-
// The render-side layout dump for the HomeOfferBanner-style case: a card
|
|
311
|
-
// clips children, and the voucher tag (with its euro coin) overflows the
|
|
312
|
-
// card's bottom. The deriver MUST surface the bottom clip so the verdict
|
|
313
|
-
// can compare its extent to the design and never miss it by eye again.
|
|
314
|
-
test('reports the voucher tag as clipped by the card at the bottom, with the clipped px', () => {
|
|
315
|
-
const dump = {
|
|
316
|
-
canvas: { left: 0, top: 0, right: 360, bottom: 720 },
|
|
317
|
-
nodes: [
|
|
318
|
-
{ id: 'HomeOfferBanner', bounds: { left: 16, top: 100, right: 344, bottom: 262 }, parentId: null, clipsChildren: true },
|
|
319
|
-
{ id: 'promoText', bounds: { left: 32, top: 116, right: 240, bottom: 180 }, parentId: 'HomeOfferBanner', clipsChildren: false },
|
|
320
|
-
// voucher tag overflows the card bottom (262) down to 280 → 18px clipped
|
|
321
|
-
{ id: 'voucherTag', bounds: { left: 250, top: 210, right: 330, bottom: 280 }, parentId: 'HomeOfferBanner', clipsChildren: false },
|
|
322
|
-
],
|
|
323
|
-
};
|
|
324
|
-
const facts = deriveStructuralFacts(dump);
|
|
325
|
-
const clip = facts.find((f) => f.kind === 'clipped_by_parent' && f.nodeId === 'voucherTag');
|
|
326
|
-
expect(clip).toMatchObject({
|
|
327
|
-
kind: 'clipped_by_parent',
|
|
328
|
-
nodeId: 'voucherTag',
|
|
329
|
-
clipperId: 'HomeOfferBanner',
|
|
330
|
-
edges: ['bottom'],
|
|
331
|
-
clippedPx: { bottom: 18 },
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
describe('deriveStructuralFacts — duplicate testTag collision guard', () => {
|
|
336
|
-
// Repeated rows tagging their pill with one shared testTag: parent lookups
|
|
337
|
-
// resolve through a last-wins id map, so overflow/clip derived through the
|
|
338
|
-
// duplicated tag compares arbitrary instances (a child of the first row
|
|
339
|
-
// measured against the last row's box reads as a huge spurious overflow).
|
|
340
|
-
// The deriver must refuse to guess — name the collision as a fact and
|
|
341
|
-
// withhold the pairing-derived facts for that tag.
|
|
342
|
-
test('three nodes sharing one testTag yield a duplicate_tag fact and no overflow/clip facts through that tag', () => {
|
|
343
|
-
const dump = {
|
|
344
|
-
canvas: CANVAS,
|
|
345
|
-
nodes: [
|
|
346
|
-
{ id: 'list', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
347
|
-
{ id: 'copy_button', bounds: { left: 20, top: 20, right: 180, bottom: 60 }, parentId: 'list', clipsChildren: false },
|
|
348
|
-
{ id: 'copy_button', bounds: { left: 20, top: 80, right: 180, bottom: 120 }, parentId: 'list', clipsChildren: false },
|
|
349
|
-
{ id: 'copy_button', bounds: { left: 20, top: 140, right: 180, bottom: 180 }, parentId: 'list', clipsChildren: false },
|
|
350
|
-
// Sits inside the FIRST pill; paired against the last-wins instance
|
|
351
|
-
// (top 140) it would read as a spurious 110px top overflow.
|
|
352
|
-
{ id: 'copy_label', bounds: { left: 30, top: 30, right: 120, bottom: 50 }, parentId: 'copy_button', clipsChildren: false },
|
|
353
|
-
],
|
|
354
|
-
};
|
|
355
|
-
expect(deriveStructuralFacts(dump)).toEqual([
|
|
356
|
-
{ kind: 'duplicate_tag', tag: 'copy_button', instanceCount: 3 },
|
|
357
|
-
]);
|
|
358
|
-
});
|
|
359
|
-
test('a duplicated node overflowing its unique parent is withheld too — attribution is ambiguous', () => {
|
|
360
|
-
const dump = {
|
|
361
|
-
canvas: CANVAS,
|
|
362
|
-
nodes: [
|
|
363
|
-
{ id: 'row', bounds: { left: 20, top: 20, right: 180, bottom: 100 }, parentId: null, clipsChildren: false },
|
|
364
|
-
// The first instance sticks out 15px past the row; the second is contained.
|
|
365
|
-
{ id: 'chip', bounds: { left: 150, top: 30, right: 195, bottom: 80 }, parentId: 'row', clipsChildren: false },
|
|
366
|
-
{ id: 'chip', bounds: { left: 30, top: 30, right: 60, bottom: 60 }, parentId: 'row', clipsChildren: false },
|
|
367
|
-
],
|
|
368
|
-
};
|
|
369
|
-
expect(deriveStructuralFacts(dump)).toEqual([
|
|
370
|
-
{ kind: 'duplicate_tag', tag: 'chip', instanceCount: 2 },
|
|
371
|
-
]);
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
// ---- Signal B: key-geometry diff + inline structural-block assembly ----
|
|
375
|
-
describe('diffKeyGeometry — dimensional gaps', () => {
|
|
376
|
-
test('reports a size delta on the right element beyond tolerance', () => {
|
|
377
|
-
const generated = { card: { width: 328, height: 81 }, chip: { width: 64, height: 24 } };
|
|
378
|
-
const reference = { card: { width: 328, height: 72 }, chip: { width: 64, height: 24 } };
|
|
379
|
-
expect(diffKeyGeometry(generated, reference)).toEqual([
|
|
380
|
-
{ elementId: 'card', deltas: [{ dimension: 'height', generated: 81, reference: 72, delta: 9 }] },
|
|
381
|
-
]);
|
|
382
|
-
});
|
|
383
|
-
test('degrades cleanly to no deltas when there is no reference geometry', () => {
|
|
384
|
-
const generated = { card: { width: 100, height: 50 } };
|
|
385
|
-
expect(diffKeyGeometry(generated, null)).toEqual([]);
|
|
386
|
-
expect(diffKeyGeometry(generated, {})).toEqual([]);
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
// The golden hub promo banner (xhdpi, density 2): banner fills the 720px canvas,
|
|
390
|
-
// voucher 96x96dp vs Figma 81x81, banner 96dp tall vs Figma 72, copy<->voucher
|
|
391
|
-
// overlap. Identical fixture + expectations to Invarn's fidelity-structural test,
|
|
392
|
-
// so preview (Invarn) and run (cibuild) assemble byte-identical blocks.
|
|
393
|
-
const HUB_PROMO_DUMP = {
|
|
394
|
-
canvas: { left: 0, top: 0, right: 720, bottom: 192 },
|
|
395
|
-
nodes: [
|
|
396
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 720, bottom: 192 }, unclippedBounds: { left: 0, top: 0, right: 720, bottom: 192 }, parentId: null, clipsChildren: false },
|
|
397
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 720, bottom: 192 }, unclippedBounds: { left: 0, top: 0, right: 720, bottom: 192 }, parentId: 'node-1', clipsChildren: false },
|
|
398
|
-
{ id: 'voucher', bounds: { left: 496, top: 0, right: 688, bottom: 192 }, unclippedBounds: { left: 496, top: 0, right: 688, bottom: 192 }, parentId: 'banner', clipsChildren: false },
|
|
399
|
-
{ id: 'copy', bounds: { left: 64, top: 24, right: 656, bottom: 117 }, unclippedBounds: { left: 64, top: 24, right: 656, bottom: 117 }, parentId: 'banner', clipsChildren: false },
|
|
400
|
-
],
|
|
401
|
-
};
|
|
402
|
-
const HUB_PROMO_REFERENCE = { voucher: { w: 81, h: 81 }, banner: { w: 343, h: 72 } };
|
|
403
|
-
describe('buildStructuralBlock — facts only (no reference geometry)', () => {
|
|
404
|
-
test('surfaces facts + summary; geometry describes what rasterized (rendered-only boxes)', () => {
|
|
405
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, null, 2);
|
|
406
|
-
expect(block.facts).toEqual([
|
|
407
|
-
{ kind: 'overlap', nodeIds: ['copy', 'voucher'], overlapPx: { width: 160, height: 93 }, ratio: 0.4036458333333333 },
|
|
408
|
-
]);
|
|
409
|
-
// Summary is byte-identical to the pre-description era: max deltas stay
|
|
410
|
-
// null (no comparison ran), clean is fact-driven only.
|
|
411
|
-
expect(block.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: null, maxClipDeltaDp: null });
|
|
412
|
-
// Description vs comparison: rendered boxes persist for every authored-tag
|
|
413
|
-
// node even without a reference. No design/deltas/sizeFlag — those exist
|
|
414
|
-
// only where a reference declares the element. The synthetic root
|
|
415
|
-
// (node-1) is not fact-named, so it does not persist.
|
|
416
|
-
expect(block.geometry).toEqual({
|
|
417
|
-
unit: 'dp',
|
|
418
|
-
density: 2,
|
|
419
|
-
elements: [
|
|
420
|
-
{ id: 'banner', rendered: { w: 360, h: 96, x: 0, y: 0 } },
|
|
421
|
-
{ id: 'voucher', rendered: { w: 96, h: 96, x: 248, y: 0 } },
|
|
422
|
-
// copy's honest height is 93px ÷ 2 = 46.5 — the box persists at
|
|
423
|
-
// 2-decimal precision; 47 was the whole-dp era's quantization.
|
|
424
|
-
{ id: 'copy', rendered: { w: 296, h: 46.5, x: 32, y: 12 } },
|
|
425
|
-
],
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
test('a fact-named synthetic node persists its box; unnamed synthetics never do', () => {
|
|
429
|
-
// An untagged sibling (node-7) overlaps the tagged chip: the deriver names
|
|
430
|
-
// it in the overlap fact, so the collision scene needs its box. The
|
|
431
|
-
// untagged root (node-1) participates in no fact and stays out.
|
|
432
|
-
const dump = {
|
|
433
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
434
|
-
nodes: [
|
|
435
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
436
|
-
{ id: 'chip', bounds: { left: 0, top: 0, right: 100, bottom: 40 }, parentId: 'node-1', clipsChildren: false },
|
|
437
|
-
{ id: 'node-7', bounds: { left: 40, top: 0, right: 140, bottom: 40 }, parentId: 'node-1', clipsChildren: false },
|
|
438
|
-
],
|
|
439
|
-
};
|
|
440
|
-
const block = buildStructuralBlock(dump, null, 2);
|
|
441
|
-
expect(block.facts).toEqual([
|
|
442
|
-
expect.objectContaining({ kind: 'overlap', nodeIds: ['chip', 'node-7'] }),
|
|
443
|
-
]);
|
|
444
|
-
expect(block.geometry?.elements).toEqual([
|
|
445
|
-
{ id: 'chip', rendered: { w: 50, h: 20, x: 0, y: 0 } },
|
|
446
|
-
{ id: 'node-7', rendered: { w: 50, h: 20, x: 20, y: 0 } },
|
|
447
|
-
]);
|
|
448
|
-
});
|
|
449
|
-
test('a duplicated testTag persists no box (last-wins geometry is ambiguous)', () => {
|
|
450
|
-
const dump = {
|
|
451
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
452
|
-
nodes: [
|
|
453
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
454
|
-
{ id: 'chip', bounds: { left: 0, top: 0, right: 40, bottom: 20 }, parentId: 'node-1', clipsChildren: false },
|
|
455
|
-
{ id: 'chip', bounds: { left: 60, top: 0, right: 100, bottom: 20 }, parentId: 'node-1', clipsChildren: false },
|
|
456
|
-
{ id: 'label', bounds: { left: 0, top: 40, right: 40, bottom: 60 }, parentId: 'node-1', clipsChildren: false },
|
|
457
|
-
],
|
|
458
|
-
};
|
|
459
|
-
const block = buildStructuralBlock(dump, null, 2);
|
|
460
|
-
// The duplicate_tag fact carries the collision; a box for an arbitrary
|
|
461
|
-
// instance would be misleading, so only the unambiguous tag persists.
|
|
462
|
-
expect(block.geometry?.elements).toEqual([
|
|
463
|
-
{ id: 'label', rendered: { w: 20, h: 10, x: 0, y: 20 } },
|
|
464
|
-
]);
|
|
465
|
-
});
|
|
466
|
-
});
|
|
467
|
-
describe('buildStructuralBlock — with reference geometry (Signal B)', () => {
|
|
468
|
-
test('reports the golden size deltas and a not-clean summary', () => {
|
|
469
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
|
|
470
|
-
expect(block.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: 24, maxClipDeltaDp: 0 });
|
|
471
|
-
expect(block.geometry?.unit).toBe('dp');
|
|
472
|
-
expect(block.geometry?.density).toBe(2);
|
|
473
|
-
const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
|
|
474
|
-
expect(voucher).toEqual({
|
|
475
|
-
id: 'voucher',
|
|
476
|
-
// rendered now carries x/y (dp) for the clip reference; design stays
|
|
477
|
-
// w/h-only here (the reference declared no x/y).
|
|
478
|
-
rendered: { w: 96, h: 96, x: 248, y: 0 },
|
|
479
|
-
design: { w: 81, h: 81 },
|
|
480
|
-
deltas: [{ dimension: 'w', delta: 15 }, { dimension: 'h', delta: 15 }],
|
|
481
|
-
sizeFlag: true,
|
|
482
|
-
// The render ancestry (icon-ink-ratio 05): `voucher` sits inside `banner`,
|
|
483
|
-
// which sits at the root. This assertion is exhaustive on purpose — it is
|
|
484
|
-
// what noticed the new field, and it is what will notice the next one.
|
|
485
|
-
ancestors: ['banner', 'node-1'],
|
|
486
|
-
});
|
|
487
|
-
const banner = block.geometry?.elements.find((e) => e.id === 'banner');
|
|
488
|
-
expect(banner?.sizeFlag).toBe(true);
|
|
489
|
-
expect(banner?.deltas).toContainEqual({ dimension: 'h', delta: 24 });
|
|
490
|
-
});
|
|
491
|
-
test('a declared tag absent from the render persists its design box alone', () => {
|
|
492
|
-
// The absence itself is reported by the presence channel (element_missing);
|
|
493
|
-
// this is the BOX that lets that report's evidence point at the region the
|
|
494
|
-
// design reserved. Design-only: no rendered half, no comparison fields
|
|
495
|
-
// (there is nothing to compare), no ancestry (there is no node to chain
|
|
496
|
-
// from). Exhaustive on purpose, like the voucher assertion above.
|
|
497
|
-
const reference = { ...HUB_PROMO_REFERENCE, cta: { w: 112, h: 36, x: 16, y: 200 } };
|
|
498
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, reference, 2);
|
|
499
|
-
expect(block.geometry?.elements.find((e) => e.id === 'cta')).toEqual({
|
|
500
|
-
id: 'cta',
|
|
501
|
-
design: { w: 112, h: 36, x: 16, y: 200 },
|
|
502
|
-
});
|
|
503
|
-
// The summary must not move: the deltas and flags that drive it exist only
|
|
504
|
-
// on compared elements, and an element that rendered nothing compares to
|
|
505
|
-
// nothing. Byte-identical to the same dump judged without the absent tag.
|
|
506
|
-
expect(block.summary).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2).summary);
|
|
507
|
-
});
|
|
508
|
-
test('a w/h-only declared tag absent from the render stays w/h — x/y are never invented', () => {
|
|
509
|
-
const reference = { ...HUB_PROMO_REFERENCE, chip: { w: 40, h: 20 } };
|
|
510
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, reference, 2);
|
|
511
|
-
expect(block.geometry?.elements.find((e) => e.id === 'chip')).toEqual({
|
|
512
|
-
id: 'chip',
|
|
513
|
-
design: { w: 40, h: 20 },
|
|
514
|
-
});
|
|
515
|
-
});
|
|
516
|
-
test('a duplicated declared tag emits nothing — it rendered (twice); last-wins is ambiguous', () => {
|
|
517
|
-
const dump = {
|
|
518
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
519
|
-
nodes: [
|
|
520
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
521
|
-
{ id: 'chip', bounds: { left: 0, top: 0, right: 40, bottom: 20 }, parentId: 'node-1', clipsChildren: false },
|
|
522
|
-
{ id: 'chip', bounds: { left: 60, top: 0, right: 100, bottom: 20 }, parentId: 'node-1', clipsChildren: false },
|
|
523
|
-
],
|
|
524
|
-
};
|
|
525
|
-
const block = buildStructuralBlock(dump, { chip: { w: 20, h: 10, x: 0, y: 0 } }, 2);
|
|
526
|
-
// Not a pair, and not a design-only entry either: the tag DID render, so
|
|
527
|
-
// "absent" would be a lie; the duplicate_tag fact carries the collision.
|
|
528
|
-
expect(block.geometry?.elements.find((e) => e.id === 'chip')).toBeUndefined();
|
|
529
|
-
});
|
|
530
|
-
test('authored elements outside the reference ride along as rendered-only boxes', () => {
|
|
531
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
|
|
532
|
-
// The reference declares voucher+banner; `copy` is authored but undeclared,
|
|
533
|
-
// so it persists as a description (rendered only, no comparison fields),
|
|
534
|
-
// appended after the compared elements.
|
|
535
|
-
expect(block.geometry?.elements.map((e) => e.id)).toEqual(['voucher', 'banner', 'copy']);
|
|
536
|
-
const copy = block.geometry?.elements.find((e) => e.id === 'copy');
|
|
537
|
-
// 93px ÷ 2 = 46.5 — 2-decimal persistence; 47 was the quantization.
|
|
538
|
-
expect(copy).toEqual({ id: 'copy', rendered: { w: 296, h: 46.5, x: 32, y: 12 } });
|
|
539
|
-
// The summary still reflects the comparison only.
|
|
540
|
-
expect(block.summary).toEqual({ clean: false, factCount: 1, maxSizeDeltaDp: 24, maxClipDeltaDp: 0 });
|
|
541
|
-
});
|
|
542
|
-
});
|
|
543
|
-
// ---- Signal B v2: per-edge overhang (clip) diff ----
|
|
544
|
-
//
|
|
545
|
-
// The euro-coin miss: a voucher tag meant to overflow-and-clip on the bottom-right
|
|
546
|
-
// reproduced at the wrong size, so its bottom clip came out wrong even though its
|
|
547
|
-
// width matched. Signal B v1 (raw w/h) is blind here — the load-bearing invariant
|
|
548
|
-
// for a clipping element is its per-edge OVERHANG (= the clip), not its raw size.
|
|
549
|
-
// These two fixtures are the field validation numbers (px @ density 2): the
|
|
550
|
-
// logged-in state MUST flag (bottom Δ−9), the guest state MUST NOT (bottom Δ+2,
|
|
551
|
-
// within tolerance). Same fixtures + expectations as Invarn's preview copy.
|
|
552
|
-
// Logged-in: rendered voucher unclipped 289..370 × 16..86.5dp, frame card 343×70.5dp.
|
|
553
|
-
// right overhang 27=27 (match); bottom overhang 16 vs design 25 → Δ−9 (FLAG).
|
|
554
|
-
const OVERHANG_LOGGED_IN_DUMP = {
|
|
555
|
-
canvas: { left: 0, top: 0, right: 760, bottom: 200 },
|
|
556
|
-
nodes: [
|
|
557
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 760, bottom: 200 }, unclippedBounds: { left: 0, top: 0, right: 760, bottom: 200 }, parentId: null, clipsChildren: false },
|
|
558
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 141 }, unclippedBounds: { left: 0, top: 0, right: 686, bottom: 141 }, parentId: 'node-1', clipsChildren: false },
|
|
559
|
-
{ id: 'voucher', bounds: { left: 578, top: 32, right: 740, bottom: 173 }, unclippedBounds: { left: 578, top: 32, right: 740, bottom: 173 }, parentId: 'banner', clipsChildren: false },
|
|
560
|
-
],
|
|
561
|
-
};
|
|
562
|
-
const OVERHANG_LOGGED_IN_REF = { banner: { x: 0, y: 0, w: 343, h: 72 }, voucher: { x: 289, y: 16, w: 81, h: 81 } };
|
|
563
|
-
// Guest: rendered voucher unclipped 272..381 × 19..127.5dp, frame card 343×108.5dp.
|
|
564
|
-
// right overhang 38=38 (match); bottom overhang 19 vs design 17 → Δ+2 (within tol, PASS).
|
|
565
|
-
const OVERHANG_GUEST_DUMP = {
|
|
566
|
-
canvas: { left: 0, top: 0, right: 800, bottom: 280 },
|
|
567
|
-
nodes: [
|
|
568
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 800, bottom: 280 }, unclippedBounds: { left: 0, top: 0, right: 800, bottom: 280 }, parentId: null, clipsChildren: false },
|
|
569
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 217 }, unclippedBounds: { left: 0, top: 0, right: 686, bottom: 217 }, parentId: 'node-1', clipsChildren: false },
|
|
570
|
-
{ id: 'voucher', bounds: { left: 544, top: 38, right: 762, bottom: 255 }, unclippedBounds: { left: 544, top: 38, right: 762, bottom: 255 }, parentId: 'banner', clipsChildren: false },
|
|
571
|
-
],
|
|
572
|
-
};
|
|
573
|
-
const OVERHANG_GUEST_REF = { banner: { x: 0, y: 0, w: 343, h: 111 }, voucher: { x: 272, y: 19, w: 109, h: 109 } };
|
|
574
|
-
// Voucher is the correct 81×81 size but sits 5dp too high (y 11 vs design 16),
|
|
575
|
-
// so the bottom clip is 5dp short (overhang 20 vs 25) for a POSITION reason.
|
|
576
|
-
const OVERHANG_POSITION_DUMP = {
|
|
577
|
-
canvas: { left: 0, top: 0, right: 800, bottom: 200 },
|
|
578
|
-
nodes: [
|
|
579
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 800, bottom: 200 }, unclippedBounds: { left: 0, top: 0, right: 800, bottom: 200 }, parentId: null, clipsChildren: false },
|
|
580
|
-
{ id: 'card', bounds: { left: 0, top: 0, right: 686, bottom: 144 }, unclippedBounds: { left: 0, top: 0, right: 686, bottom: 144 }, parentId: 'node-1', clipsChildren: false },
|
|
581
|
-
{ id: 'voucher', bounds: { left: 578, top: 22, right: 740, bottom: 184 }, unclippedBounds: { left: 578, top: 22, right: 740, bottom: 184 }, parentId: 'card', clipsChildren: false },
|
|
582
|
-
],
|
|
583
|
-
};
|
|
584
|
-
const OVERHANG_POSITION_REF = { card: { x: 0, y: 0, w: 343, h: 72 }, voucher: { x: 289, y: 16, w: 81, h: 81 } };
|
|
585
|
-
// MISSING clip: the voucher is squashed + sits tangent to the card edges, so the
|
|
586
|
-
// render does NOT overflow at all (no clip), while the design overflows-and-clips
|
|
587
|
-
// (right 27 / bottom 25). Signal B v1 was SILENT here (no rendered overflow → no
|
|
588
|
-
// overhang). The symmetric (design-side) candidate rule must flag it.
|
|
589
|
-
const OVERHANG_MISSING_DUMP = {
|
|
590
|
-
canvas: { left: 0, top: 0, right: 800, bottom: 200 },
|
|
591
|
-
nodes: [
|
|
592
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 800, bottom: 200 }, unclippedBounds: { left: 0, top: 0, right: 800, bottom: 200 }, parentId: null, clipsChildren: false },
|
|
593
|
-
{ id: 'card', bounds: { left: 0, top: 0, right: 686, bottom: 142 }, unclippedBounds: { left: 0, top: 0, right: 686, bottom: 142 }, parentId: 'node-1', clipsChildren: false },
|
|
594
|
-
{ id: 'voucher', bounds: { left: 524, top: 0, right: 686, bottom: 142 }, unclippedBounds: { left: 524, top: 0, right: 686, bottom: 142 }, parentId: 'card', clipsChildren: false },
|
|
595
|
-
],
|
|
596
|
-
};
|
|
597
|
-
const OVERHANG_MISSING_REF = { card: { x: 0, y: 0, w: 343, h: 72 }, voucher: { x: 289, y: 16, w: 81, h: 81 } };
|
|
598
|
-
describe('buildStructuralBlock — per-edge overhang (Signal B v2)', () => {
|
|
599
|
-
test('logged-in voucher: flags the bottom clip (Δ−9), not the matching right edge', () => {
|
|
600
|
-
const block = buildStructuralBlock(OVERHANG_LOGGED_IN_DUMP, OVERHANG_LOGGED_IN_REF, 2);
|
|
601
|
-
const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
|
|
602
|
-
expect(voucher?.overhang).toEqual({
|
|
603
|
-
unit: 'dp',
|
|
604
|
-
rendered: { right: 27, bottom: 16 },
|
|
605
|
-
design: { right: 27, bottom: 25 },
|
|
606
|
-
deltas: [{ edge: 'bottom', delta: -9 }],
|
|
607
|
-
clipFlag: true,
|
|
608
|
-
// the clip is explained by the voucher's own height (71 vs 81dp) tracking
|
|
609
|
-
// the frame (the fillMaxHeight tell) — a sizing defect, not an intended clip.
|
|
610
|
-
sizeDriven: true,
|
|
611
|
-
frameRelative: true,
|
|
612
|
-
positionDriven: false,
|
|
613
|
-
});
|
|
614
|
-
expect(block.summary.clean).toBe(false);
|
|
615
|
-
expect(block.summary.maxClipDeltaDp).toBe(9);
|
|
616
|
-
});
|
|
617
|
-
test('guest voucher: matching right edge, bottom within tolerance → no clipFlag', () => {
|
|
618
|
-
const block = buildStructuralBlock(OVERHANG_GUEST_DUMP, OVERHANG_GUEST_REF, 2);
|
|
619
|
-
const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
|
|
620
|
-
expect(voucher?.overhang).toEqual({
|
|
621
|
-
unit: 'dp',
|
|
622
|
-
rendered: { right: 38, bottom: 19 },
|
|
623
|
-
design: { right: 38, bottom: 17 },
|
|
624
|
-
deltas: [],
|
|
625
|
-
clipFlag: false,
|
|
626
|
-
sizeDriven: false,
|
|
627
|
-
frameRelative: false,
|
|
628
|
-
positionDriven: false,
|
|
629
|
-
});
|
|
630
|
-
});
|
|
631
|
-
test('a fully-contained element gets no overhang block', () => {
|
|
632
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
|
|
633
|
-
const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
|
|
634
|
-
expect(voucher?.overhang).toBeUndefined();
|
|
635
|
-
expect(block.summary.maxClipDeltaDp).toBe(0);
|
|
636
|
-
});
|
|
637
|
-
// Correct SIZE (81×81) but positioned 5dp too high → the bottom clip comes out
|
|
638
|
-
// short (20 vs 25) for a POSITION reason, not a size one. This is the residual
|
|
639
|
-
// that got waved off as "sub-pixel rounding": positionDriven must flag it.
|
|
640
|
-
test('a correctly-sized element clipped wrong by its position is positionDriven, not sizeDriven', () => {
|
|
641
|
-
const block = buildStructuralBlock(OVERHANG_POSITION_DUMP, OVERHANG_POSITION_REF, 2);
|
|
642
|
-
const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
|
|
643
|
-
expect(voucher?.overhang).toEqual({
|
|
644
|
-
unit: 'dp',
|
|
645
|
-
rendered: { right: 27, bottom: 20 },
|
|
646
|
-
design: { right: 27, bottom: 25 },
|
|
647
|
-
deltas: [{ edge: 'bottom', delta: -5 }],
|
|
648
|
-
clipFlag: true,
|
|
649
|
-
sizeDriven: false,
|
|
650
|
-
frameRelative: false,
|
|
651
|
-
positionDriven: true,
|
|
652
|
-
});
|
|
653
|
-
const review = buildReviewScaffold(block);
|
|
654
|
-
const clipRow = review.rows.find((r) => r.element === 'voucher' && r.fact.startsWith('clip'));
|
|
655
|
-
expect(clipRow?.fact).toBe('clip bottom 20dp vs design 25dp (-5) — driven by the element\'s position relative to the frame; reposition to the design offset');
|
|
656
|
-
});
|
|
657
|
-
test('a MISSING clip (design overflows, render contained/tangent) flags via the design-side candidate', () => {
|
|
658
|
-
const block = buildStructuralBlock(OVERHANG_MISSING_DUMP, OVERHANG_MISSING_REF, 2);
|
|
659
|
-
const voucher = block.geometry?.elements.find((e) => e.id === 'voucher');
|
|
660
|
-
expect(voucher?.overhang).toEqual({
|
|
661
|
-
unit: 'dp',
|
|
662
|
-
rendered: { right: 0, bottom: 0 },
|
|
663
|
-
design: { right: 27, bottom: 25 },
|
|
664
|
-
deltas: [
|
|
665
|
-
{ edge: 'right', delta: -27 },
|
|
666
|
-
{ edge: 'bottom', delta: -25 },
|
|
667
|
-
],
|
|
668
|
-
clipFlag: true,
|
|
669
|
-
sizeDriven: true,
|
|
670
|
-
frameRelative: true,
|
|
671
|
-
positionDriven: true,
|
|
672
|
-
});
|
|
673
|
-
expect(block.summary.clean).toBe(false);
|
|
674
|
-
expect(block.summary.maxClipDeltaDp).toBe(27);
|
|
675
|
-
const review = buildReviewScaffold(block);
|
|
676
|
-
const clipRow = review.rows.find((r) => r.element === 'voucher' && r.fact.startsWith('clip'));
|
|
677
|
-
expect(clipRow?.fact).toContain('right 0dp vs design 27dp (-27)');
|
|
678
|
-
expect(clipRow?.fact).toContain('reposition to the design offset');
|
|
679
|
-
});
|
|
680
|
-
});
|
|
681
|
-
describe('buildReviewScaffold — run-only verdict obligation', () => {
|
|
682
|
-
test('forces a verdict per flagged element + fact, with the golden mustExplain', () => {
|
|
683
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
|
|
684
|
-
const review = buildReviewScaffold(block);
|
|
685
|
-
expect(review.verdict).toBeNull();
|
|
686
|
-
expect(review.mustExplain).toEqual(['voucher', 'banner', 'copy↔voucher']);
|
|
687
|
-
expect(review.rows.every((r) => r.verdict === null)).toBe(true);
|
|
688
|
-
expect(review.rows.find((r) => r.element === 'voucher')).toEqual({
|
|
689
|
-
element: 'voucher',
|
|
690
|
-
rendered: '96×96dp',
|
|
691
|
-
design: '81×81dp',
|
|
692
|
-
fact: 'size +15×+15',
|
|
693
|
-
verdict: null,
|
|
694
|
-
});
|
|
695
|
-
expect(review.rows.find((r) => r.element === 'copy↔voucher')?.fact).toBe('overlap 160×93px (ratio 0.40)');
|
|
696
|
-
});
|
|
697
|
-
test('a clipFlag element forces a verdict row describing the overhang mismatch', () => {
|
|
698
|
-
const block = buildStructuralBlock(OVERHANG_LOGGED_IN_DUMP, OVERHANG_LOGGED_IN_REF, 2);
|
|
699
|
-
const review = buildReviewScaffold(block);
|
|
700
|
-
expect(review.mustExplain).toContain('voucher');
|
|
701
|
-
const clipRow = review.rows.find((r) => r.element === 'voucher' && r.fact.startsWith('clip'));
|
|
702
|
-
expect(clipRow).toEqual({
|
|
703
|
-
element: 'voucher',
|
|
704
|
-
rendered: 'bottom 16dp',
|
|
705
|
-
design: 'bottom 25dp',
|
|
706
|
-
fact: "clip bottom 16dp vs design 25dp (-9) — driven by the element's own size (h 71 vs 81dp) tracking the frame (parent-relative); reproduce at the fixed design size",
|
|
707
|
-
verdict: null,
|
|
708
|
-
});
|
|
709
|
-
});
|
|
710
|
-
});
|
|
711
|
-
// ---- Signal C: element-presence facts (appearance container) ----
|
|
712
|
-
describe('derivePresenceFacts — set difference over element ids', () => {
|
|
713
|
-
test('equal element sets produce no facts', () => {
|
|
714
|
-
expect(derivePresenceFacts(['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual([]);
|
|
715
|
-
});
|
|
716
|
-
test('an id only in the reference yields element_missing', () => {
|
|
717
|
-
// rendered lacks `voucher`, which the reference declares.
|
|
718
|
-
expect(derivePresenceFacts(['banner'], ['banner', 'voucher'])).toEqual([
|
|
719
|
-
{ kind: 'element_missing', elementId: 'voucher' },
|
|
720
|
-
]);
|
|
721
|
-
});
|
|
722
|
-
test('an id only in the render yields element_extra', () => {
|
|
723
|
-
// rendered has an extra `debugOverlay` the reference does not declare.
|
|
724
|
-
expect(derivePresenceFacts(['banner', 'debugOverlay'], ['banner'])).toEqual([
|
|
725
|
-
{ kind: 'element_extra', elementId: 'debugOverlay' },
|
|
726
|
-
]);
|
|
727
|
-
});
|
|
728
|
-
test('reports both a missing and an extra id, missing before extra', () => {
|
|
729
|
-
expect(derivePresenceFacts(['banner', 'extra'], ['banner', 'gone'])).toEqual([
|
|
730
|
-
{ kind: 'element_missing', elementId: 'gone' },
|
|
731
|
-
{ kind: 'element_extra', elementId: 'extra' },
|
|
732
|
-
]);
|
|
733
|
-
});
|
|
734
|
-
test('result is order-independent — same sets in any input order give the same facts', () => {
|
|
735
|
-
const a = derivePresenceFacts(['a', 'b', 'c'], ['b', 'd', 'a']);
|
|
736
|
-
const b = derivePresenceFacts(['c', 'b', 'a'], ['d', 'a', 'b']);
|
|
737
|
-
expect(a).toEqual(b);
|
|
738
|
-
// d missing, c extra — independent of how the ids were ordered or duplicated.
|
|
739
|
-
expect(derivePresenceFacts(['c', 'c', 'b', 'a'], ['a', 'b', 'd', 'd'])).toEqual([
|
|
740
|
-
{ kind: 'element_missing', elementId: 'd' },
|
|
741
|
-
{ kind: 'element_extra', elementId: 'c' },
|
|
742
|
-
]);
|
|
743
|
-
});
|
|
744
|
-
});
|
|
745
|
-
describe('ciede2000 — CIEDE2000 ΔE against the Sharma supplementary test data', () => {
|
|
746
|
-
// Canonical test vectors from Sharma, Wu & Dalal, "The CIEDE2000 Color-
|
|
747
|
-
// Difference Formula: Implementation Notes, Supplementary Test Data, and
|
|
748
|
-
// Mathematical Observations" (2005). Each row: Lab1, Lab2, expected ΔE00.
|
|
749
|
-
// These exercise the hue-discontinuity and high-chroma edge cases an
|
|
750
|
-
// incorrect implementation gets wrong.
|
|
751
|
-
const SHARMA = [
|
|
752
|
-
[[50, 2.6772, -79.7751], [50, 0, -82.7485], 2.0425],
|
|
753
|
-
[[50, 3.1571, -77.2803], [50, 0, -82.7485], 2.8615],
|
|
754
|
-
[[50, 2.8361, -74.02], [50, 0, -82.7485], 3.4412],
|
|
755
|
-
[[50, -1.3802, -84.2814], [50, 0, -82.7485], 1.0],
|
|
756
|
-
[[50, -1.1848, -84.8006], [50, 0, -82.7485], 1.0],
|
|
757
|
-
[[50, 0, 0], [50, -1, 2], 2.3669],
|
|
758
|
-
[[50, 2.49, -0.001], [50, -2.49, 0.0009], 7.1792],
|
|
759
|
-
[[50, 2.49, -0.001], [50, -2.49, 0.0011], 7.2195],
|
|
760
|
-
[[50, -0.001, 2.49], [50, 0.0009, -2.49], 4.8045],
|
|
761
|
-
[[50, 2.5, 0], [50, 0, -2.5], 4.3065],
|
|
762
|
-
[[50, 2.5, 0], [73, 25, -18], 27.1492],
|
|
763
|
-
[[50, 2.5, 0], [50, 3.1736, 0.5854], 1.0],
|
|
764
|
-
[[60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644],
|
|
765
|
-
[[63.0109, -31.0961, -5.8663], [62.8187, -29.7946, -4.0864], 1.263],
|
|
766
|
-
[[22.7233, 20.0904, -46.694], [23.0331, 14.973, -42.5619], 2.0373],
|
|
767
|
-
[[90.8027, -2.0831, 1.441], [91.1528, -1.6435, 0.0447], 1.4441],
|
|
768
|
-
[[2.0776, 0.0795, -1.135], [0.9033, -0.0636, -0.5514], 0.9082],
|
|
769
|
-
];
|
|
770
|
-
for (const [lab1, lab2, expected] of SHARMA) {
|
|
771
|
-
test(`ΔE(${lab1.join(',')} ; ${lab2.join(',')}) ≈ ${expected}`, () => {
|
|
772
|
-
expect(ciede2000(lab1, lab2)).toBeCloseTo(expected, 4);
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
test('identical colors have ΔE 0 and the metric is symmetric', () => {
|
|
776
|
-
expect(ciede2000([42, 7, -13], [42, 7, -13])).toBe(0);
|
|
777
|
-
expect(ciede2000([50, 2.5, 0], [73, 25, -18])).toBeCloseTo(ciede2000([73, 25, -18], [50, 2.5, 0]), 10);
|
|
778
|
-
});
|
|
779
|
-
});
|
|
780
|
-
describe('hexToLab — sRGB hex → CIELAB (D65)', () => {
|
|
781
|
-
test('white maps to L≈100, a≈0, b≈0', () => {
|
|
782
|
-
const [L, a, b] = hexToLab('#ffffff');
|
|
783
|
-
expect(L).toBeCloseTo(100, 3);
|
|
784
|
-
expect(a).toBeCloseTo(0, 3);
|
|
785
|
-
expect(b).toBeCloseTo(0, 3);
|
|
786
|
-
});
|
|
787
|
-
test('black maps to L≈0', () => {
|
|
788
|
-
const [L] = hexToLab('#000000');
|
|
789
|
-
expect(L).toBeCloseTo(0, 3);
|
|
790
|
-
});
|
|
791
|
-
test('accepts hex with or without a leading # and is case-insensitive', () => {
|
|
792
|
-
expect(hexToLab('FFFFFF')).toEqual(hexToLab('#ffffff'));
|
|
793
|
-
});
|
|
794
|
-
});
|
|
795
|
-
describe('deriveColorFacts — rendered vs reference color per element/channel', () => {
|
|
796
|
-
test('matching colors produce no fact', () => {
|
|
797
|
-
expect(deriveColorFacts({ card: { fill: '#ff0000', text: '#000000' } }, { card: { fill: '#ff0000', text: '#000000' } })).toEqual([]);
|
|
798
|
-
// case-insensitive match
|
|
799
|
-
expect(deriveColorFacts({ card: { fill: '#FF0000' } }, { card: { fill: '#ff0000' } })).toEqual([]);
|
|
800
|
-
});
|
|
801
|
-
// Opacity parity with the Invarn CLI twin (issue 14b). The design's Apply CTA
|
|
802
|
-
// was painted at 30% (disabled) and the render at 100% (enabled); both hexes
|
|
803
|
-
// were #3ca000, so ΔE was 0 and nothing ever flagged.
|
|
804
|
-
test('a design translucency the render paints opaque yields one color_alpha fact', () => {
|
|
805
|
-
const facts = deriveColorFacts({ 'button-cta': { fill: '#3ca000' } }, { 'button-cta': { fill: '#3ca000', fillAlpha: 0.3 } });
|
|
806
|
-
expect(facts).toHaveLength(1);
|
|
807
|
-
expect(facts[0]).toMatchObject({
|
|
808
|
-
kind: 'color_alpha', elementId: 'button-cta', channel: 'fill', rendered: 1, reference: 0.3,
|
|
809
|
-
});
|
|
810
|
-
});
|
|
811
|
-
test('a render matching the design translucency produces no fact', () => {
|
|
812
|
-
expect(deriveColorFacts({ 'button-cta': { fill: '#3ca000', fillAlpha: 0.3 } }, { 'button-cta': { fill: '#3ca000', fillAlpha: 0.3 } })).toEqual([]);
|
|
813
|
-
});
|
|
814
|
-
test('an opaque design says nothing about alpha, so a translucent render is not graded', () => {
|
|
815
|
-
expect(deriveColorFacts({ 'button-cta': { fill: '#3ca000', fillAlpha: 0.3 } }, { 'button-cta': { fill: '#3ca000' } })).toEqual([]);
|
|
816
|
-
});
|
|
817
|
-
test('a rounding-step alpha difference is ignored', () => {
|
|
818
|
-
expect(deriveColorFacts({ 'button-cta': { fill: '#3ca000', fillAlpha: 0.2999 } }, { 'button-cta': { fill: '#3ca000', fillAlpha: 0.3 } })).toEqual([]);
|
|
819
|
-
});
|
|
820
|
-
test('a fill divergence yields one fill-channel fact with the correct ΔE', () => {
|
|
821
|
-
const facts = deriveColorFacts({ card: { fill: '#ff0000' } }, { card: { fill: '#00ff00' } });
|
|
822
|
-
expect(facts).toHaveLength(1);
|
|
823
|
-
expect(facts[0]).toMatchObject({ kind: 'color', elementId: 'card', channel: 'fill', rendered: '#ff0000', reference: '#00ff00' });
|
|
824
|
-
expect(facts[0].deltaE).toBeCloseTo(ciede2000(hexToLab('#ff0000'), hexToLab('#00ff00')), 10);
|
|
825
|
-
});
|
|
826
|
-
test('fill and text are distinct channels — a wrong text color does not collapse with a correct fill', () => {
|
|
827
|
-
const facts = deriveColorFacts({ card: { fill: '#ffffff', text: '#111111' } }, { card: { fill: '#ffffff', text: '#000000' } });
|
|
828
|
-
expect(facts).toHaveLength(1);
|
|
829
|
-
expect(facts[0]).toMatchObject({ kind: 'color', elementId: 'card', channel: 'text', rendered: '#111111', reference: '#000000' });
|
|
830
|
-
});
|
|
831
|
-
test('both channels diverging yield two facts, fill before text', () => {
|
|
832
|
-
const facts = deriveColorFacts({ card: { fill: '#ff0000', text: '#111111' } }, { card: { fill: '#00ff00', text: '#000000' } });
|
|
833
|
-
expect(facts.map((f) => f.channel)).toEqual(['fill', 'text']);
|
|
834
|
-
});
|
|
835
|
-
test('an element/channel with no color signal produces no fact', () => {
|
|
836
|
-
// reference declares a fill the render never exposes → no signal → no fact
|
|
837
|
-
expect(deriveColorFacts({ card: {} }, { card: { fill: '#ff0000' } })).toEqual([]);
|
|
838
|
-
// render has a color the reference never declared → no reference signal → no fact
|
|
839
|
-
expect(deriveColorFacts({ card: { text: '#abcdef' } }, { card: { fill: '#ff0000' } })).toEqual([]);
|
|
840
|
-
// element absent from the reference entirely → no fact
|
|
841
|
-
expect(deriveColorFacts({ extra: { fill: '#ff0000' } }, {})).toEqual([]);
|
|
842
|
-
});
|
|
843
|
-
test('result is order-independent and deterministic (sorted by element id)', () => {
|
|
844
|
-
const a = deriveColorFacts({ b: { fill: '#ff0000' }, a: { fill: '#000000' } }, { a: { fill: '#ffffff' }, b: { fill: '#ff0000' } });
|
|
845
|
-
const b = deriveColorFacts({ a: { fill: '#000000' }, b: { fill: '#ff0000' } }, { b: { fill: '#ff0000' }, a: { fill: '#ffffff' } });
|
|
846
|
-
expect(a).toEqual(b);
|
|
847
|
-
expect(a.map((f) => f.elementId)).toEqual(['a']);
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
describe('deriveCopyFacts — rendered vs reference text per element', () => {
|
|
851
|
-
test('identical text produces no fact', () => {
|
|
852
|
-
expect(deriveCopyFacts({ title: '5 EUR off' }, { title: '5 EUR off' })).toEqual([]);
|
|
853
|
-
});
|
|
854
|
-
test('differing text yields text_mismatch with both strings', () => {
|
|
855
|
-
expect(deriveCopyFacts({ title: '5 EUR' }, { title: '5 EUR off' })).toEqual([{ kind: 'text_mismatch', elementId: 'title', rendered: '5 EUR', reference: '5 EUR off' }]);
|
|
856
|
-
});
|
|
857
|
-
test('reference text with empty/absent render yields text_missing', () => {
|
|
858
|
-
// absent render entry
|
|
859
|
-
expect(deriveCopyFacts({}, { cta: 'Shop now' })).toEqual([
|
|
860
|
-
{ kind: 'text_missing', elementId: 'cta', reference: 'Shop now' },
|
|
861
|
-
]);
|
|
862
|
-
// empty-string render entry counts as no rendered text
|
|
863
|
-
expect(deriveCopyFacts({ cta: '' }, { cta: 'Shop now' })).toEqual([
|
|
864
|
-
{ kind: 'text_missing', elementId: 'cta', reference: 'Shop now' },
|
|
865
|
-
]);
|
|
866
|
-
});
|
|
867
|
-
test('an element with no reference text produces no fact (absence of signal is not a divergence)', () => {
|
|
868
|
-
// render has text the reference never declared → no fact (no reference signal)
|
|
869
|
-
expect(deriveCopyFacts({ debugLabel: 'x' }, {})).toEqual([]);
|
|
870
|
-
// reference declares an empty string → no signal → no fact regardless of render
|
|
871
|
-
expect(deriveCopyFacts({ note: 'hi' }, { note: '' })).toEqual([]);
|
|
872
|
-
});
|
|
873
|
-
test('result is order-independent and deterministic (sorted by element id)', () => {
|
|
874
|
-
const a = deriveCopyFacts({ b: 'r', a: 'wrong' }, { a: 'right', b: 'r', c: 'gone' });
|
|
875
|
-
const b = deriveCopyFacts({ a: 'wrong', b: 'r' }, { c: 'gone', b: 'r', a: 'right' });
|
|
876
|
-
expect(a).toEqual(b);
|
|
877
|
-
expect(a).toEqual([
|
|
878
|
-
{ kind: 'text_mismatch', elementId: 'a', rendered: 'wrong', reference: 'right' },
|
|
879
|
-
{ kind: 'text_missing', elementId: 'c', reference: 'gone' },
|
|
880
|
-
]);
|
|
881
|
-
});
|
|
882
|
-
});
|
|
883
|
-
describe('deriveSpanFacts — declared highlight extents vs rendered background spans', () => {
|
|
884
|
-
test('a rendered span matching the declared extent produces no fact', () => {
|
|
885
|
-
expect(deriveSpanFacts({ discountText: [{ start: 4, end: 13, text: '5 EUR off', background: '#ffe45e' }] }, { discountText: { text: '5 EUR off' } })).toEqual([]);
|
|
886
|
-
});
|
|
887
|
-
test('a rendered span chipping the wrong extent yields span_mismatch with both strings', () => {
|
|
888
|
-
// The motivating case: the design chips the full phrase, the wrapper
|
|
889
|
-
// highlighted only the amount.
|
|
890
|
-
expect(deriveSpanFacts({ discountText: [{ start: 4, end: 9, text: '5 EUR', background: '#ffe45e' }] }, { discountText: { text: '5 EUR off' } })).toEqual([
|
|
891
|
-
{ kind: 'span_mismatch', elementId: 'discountText', rendered: '5 EUR', reference: '5 EUR off' },
|
|
892
|
-
]);
|
|
893
|
-
});
|
|
894
|
-
test('a node that emitted no spans produces no fact (old packages degrade gracefully)', () => {
|
|
895
|
-
// An older committed package's dump has no textSpans field at all — the
|
|
896
|
-
// declared expectation must stay silent, not fire a false missing-span.
|
|
897
|
-
expect(deriveSpanFacts({}, { discountText: { text: '5 EUR off' } })).toEqual([]);
|
|
898
|
-
});
|
|
899
|
-
test('any one of several rendered spans matching the declared extent passes', () => {
|
|
900
|
-
expect(deriveSpanFacts({
|
|
901
|
-
discountText: [
|
|
902
|
-
{ start: 0, end: 3, text: 'Get', background: '#eeeeee' },
|
|
903
|
-
{ start: 4, end: 13, text: '5 EUR off', background: '#ffe45e' },
|
|
904
|
-
],
|
|
905
|
-
}, { discountText: { text: '5 EUR off' } })).toEqual([]);
|
|
906
|
-
});
|
|
907
|
-
test('several wrong spans report all their extents in one fact', () => {
|
|
908
|
-
expect(deriveSpanFacts({
|
|
909
|
-
discountText: [
|
|
910
|
-
{ start: 0, end: 3, text: 'Get', background: '#eeeeee' },
|
|
911
|
-
{ start: 4, end: 9, text: '5 EUR', background: '#ffe45e' },
|
|
912
|
-
],
|
|
913
|
-
}, { discountText: { text: '5 EUR off' } })).toEqual([
|
|
914
|
-
{ kind: 'span_mismatch', elementId: 'discountText', rendered: 'Get, 5 EUR', reference: '5 EUR off' },
|
|
915
|
-
]);
|
|
916
|
-
});
|
|
917
|
-
test('no declared entry / empty declared text produces no fact (absence of signal is not a divergence)', () => {
|
|
918
|
-
expect(deriveSpanFacts({ discountText: [{ start: 0, end: 3, text: 'Get' }] }, {})).toEqual([]);
|
|
919
|
-
expect(deriveSpanFacts({ discountText: [{ start: 0, end: 3, text: 'Get' }] }, { discountText: { text: '' } })).toEqual([]);
|
|
920
|
-
});
|
|
921
|
-
test('result is order-independent and deterministic (sorted by element id)', () => {
|
|
922
|
-
const rendered = {
|
|
923
|
-
b: [{ start: 0, end: 1, text: 'x' }],
|
|
924
|
-
a: [{ start: 0, end: 1, text: 'y' }],
|
|
925
|
-
};
|
|
926
|
-
const a = deriveSpanFacts(rendered, { a: { text: 'right' }, b: { text: 'x' } });
|
|
927
|
-
const b = deriveSpanFacts(rendered, { b: { text: 'x' }, a: { text: 'right' } });
|
|
928
|
-
expect(a).toEqual(b);
|
|
929
|
-
expect(a).toEqual([
|
|
930
|
-
{ kind: 'span_mismatch', elementId: 'a', rendered: 'y', reference: 'right' },
|
|
931
|
-
]);
|
|
932
|
-
});
|
|
933
|
-
// ── Foreground-color spans (partial-color emphasis), capability-gated. ──
|
|
934
|
-
test('a color span with the right extent but the wrong color yields span_color_mismatch', () => {
|
|
935
|
-
expect(deriveSpanFacts({ pillText: [{ start: 19, end: 30, text: 'special offer', color: '#ffffff' }] }, { pillText: { text: 'special offer', color: '#ffdc23' } }, true)).toEqual([
|
|
936
|
-
{
|
|
937
|
-
kind: 'span_color_mismatch',
|
|
938
|
-
elementId: 'pillText',
|
|
939
|
-
extent: 'special offer',
|
|
940
|
-
rendered: '#ffffff',
|
|
941
|
-
reference: '#ffdc23',
|
|
942
|
-
},
|
|
943
|
-
]);
|
|
944
|
-
});
|
|
945
|
-
test('a color span matching extent AND color (case-insensitive) produces no fact', () => {
|
|
946
|
-
expect(deriveSpanFacts({ pillText: [{ start: 19, end: 30, text: 'special offer', color: '#FFDC23' }] }, { pillText: { text: 'special offer', color: '#ffdc23' } }, true)).toEqual([]);
|
|
947
|
-
});
|
|
948
|
-
test('a capable dump rendering NO span at all fails a color declaration (dropped emphasis)', () => {
|
|
949
|
-
expect(deriveSpanFacts({}, { pillText: { text: 'special offer', color: '#ffdc23' } }, true)).toEqual([
|
|
950
|
-
{ kind: 'span_mismatch', elementId: 'pillText', rendered: '', reference: 'special offer' },
|
|
951
|
-
]);
|
|
952
|
-
});
|
|
953
|
-
test('a color declaration against a color-blind dump stays silent (capability gate)', () => {
|
|
954
|
-
// Old emitter: no spanChannels marker → colorCapable false. Its silence
|
|
955
|
-
// is "cannot see", never "no color span rendered".
|
|
956
|
-
expect(deriveSpanFacts({}, { pillText: { text: 'special offer', color: '#ffdc23' } }, false)).toEqual([]);
|
|
957
|
-
expect(deriveSpanFacts({}, { pillText: { text: 'special offer', color: '#ffdc23' } })).toEqual([]);
|
|
958
|
-
});
|
|
959
|
-
test('a color-only rendered span never satisfies a BACKGROUND declaration (channel-scoped)', () => {
|
|
960
|
-
expect(deriveSpanFacts({ pillText: [{ start: 0, end: 5, text: 'offer', color: '#ffdc23' }] }, { pillText: { text: 'offer', background: '#ffe45e' } }, true)).toEqual([
|
|
961
|
-
{ kind: 'span_mismatch', elementId: 'pillText', rendered: '', reference: 'offer' },
|
|
962
|
-
]);
|
|
963
|
-
});
|
|
964
|
-
test('a declaration carrying BOTH channels needs both satisfied', () => {
|
|
965
|
-
const rendered = {
|
|
966
|
-
pillText: [{ start: 0, end: 5, text: 'offer', background: '#ffe45e', color: '#ffdc23' }],
|
|
967
|
-
};
|
|
968
|
-
expect(deriveSpanFacts(rendered, { pillText: { text: 'offer', background: '#ffe45e', color: '#ffdc23' } }, true)).toEqual([]);
|
|
969
|
-
expect(deriveSpanFacts(rendered, { pillText: { text: 'offer', background: '#ffe45e', color: '#1b1b1b' } }, true)).toEqual([
|
|
970
|
-
{
|
|
971
|
-
kind: 'span_color_mismatch',
|
|
972
|
-
elementId: 'pillText',
|
|
973
|
-
extent: 'offer',
|
|
974
|
-
rendered: '#ffdc23',
|
|
975
|
-
reference: '#1b1b1b',
|
|
976
|
-
},
|
|
977
|
-
]);
|
|
978
|
-
});
|
|
979
|
-
});
|
|
980
|
-
describe('deriveLineFacts — declared line split vs rendered laid-out lines', () => {
|
|
981
|
-
test('rendered lines matching the declared split produce no fact', () => {
|
|
982
|
-
expect(deriveLineFacts({
|
|
983
|
-
promoCopy: [
|
|
984
|
-
{ start: 0, end: 28, text: 'Get 5 EUR off your first order' },
|
|
985
|
-
{ start: 29, end: 50, text: 'placed by tomorrow.' },
|
|
986
|
-
],
|
|
987
|
-
}, { promoCopy: ['Get 5 EUR off your first order', 'placed by tomorrow.'] })).toEqual([]);
|
|
988
|
-
});
|
|
989
|
-
test('a break on the wrong word yields line_break_mismatch with both splits', () => {
|
|
990
|
-
expect(deriveLineFacts({
|
|
991
|
-
promoCopy: [
|
|
992
|
-
{ start: 0, end: 31, text: 'Get 5 EUR off your first order placed' },
|
|
993
|
-
{ start: 32, end: 45, text: 'by tomorrow.' },
|
|
994
|
-
],
|
|
995
|
-
}, { promoCopy: ['Get 5 EUR off your first order', 'placed by tomorrow.'] })).toEqual([
|
|
996
|
-
{
|
|
997
|
-
kind: 'line_break_mismatch',
|
|
998
|
-
elementId: 'promoCopy',
|
|
999
|
-
rendered: 'Get 5 EUR off your first order placed\nby tomorrow.',
|
|
1000
|
-
reference: 'Get 5 EUR off your first order\nplaced by tomorrow.',
|
|
1001
|
-
},
|
|
1002
|
-
]);
|
|
1003
|
-
});
|
|
1004
|
-
test('a dropped break (single rendered line vs a declared two-line split) is a fact', () => {
|
|
1005
|
-
expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 50, text: 'Get 5 EUR off your first order placed by tomorrow.' }] }, { promoCopy: ['Get 5 EUR off your first order', 'placed by tomorrow.'] })).toEqual([
|
|
1006
|
-
{
|
|
1007
|
-
kind: 'line_break_mismatch',
|
|
1008
|
-
elementId: 'promoCopy',
|
|
1009
|
-
rendered: 'Get 5 EUR off your first order placed by tomorrow.',
|
|
1010
|
-
reference: 'Get 5 EUR off your first order\nplaced by tomorrow.',
|
|
1011
|
-
},
|
|
1012
|
-
]);
|
|
1013
|
-
});
|
|
1014
|
-
test('a node that emitted no lines produces no fact (old packages degrade gracefully)', () => {
|
|
1015
|
-
expect(deriveLineFacts({}, { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] })).toEqual([]);
|
|
1016
|
-
});
|
|
1017
|
-
test('no declared entry / an empty declared split produces no fact (absence of signal is not a divergence)', () => {
|
|
1018
|
-
expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, {})).toEqual([]);
|
|
1019
|
-
expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, { promoCopy: [] })).toEqual([]);
|
|
1020
|
-
expect(deriveLineFacts({ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, { promoCopy: ['', ''] })).toEqual([]);
|
|
1021
|
-
});
|
|
1022
|
-
test('result is order-independent and deterministic (sorted by element id)', () => {
|
|
1023
|
-
const rendered = {
|
|
1024
|
-
b: [{ start: 0, end: 1, text: 'x' }],
|
|
1025
|
-
a: [{ start: 0, end: 1, text: 'y' }],
|
|
1026
|
-
};
|
|
1027
|
-
const one = deriveLineFacts(rendered, { a: ['right'], b: ['x'] });
|
|
1028
|
-
const two = deriveLineFacts(rendered, { b: ['x'], a: ['right'] });
|
|
1029
|
-
expect(one).toEqual(two);
|
|
1030
|
-
expect(one).toEqual([
|
|
1031
|
-
{ kind: 'line_break_mismatch', elementId: 'a', rendered: 'y', reference: 'right' },
|
|
1032
|
-
]);
|
|
1033
|
-
});
|
|
1034
|
-
});
|
|
1035
|
-
describe('deriveTypeMetricFacts — laid-out size/tracking/leading vs design', () => {
|
|
1036
|
-
// The blind spot: with family and weight matching, the typeface comparison
|
|
1037
|
-
// finds nothing, so a wrong font size can only surface as the text box
|
|
1038
|
-
// measuring small — filed as structure, with a hint naming a fix site that
|
|
1039
|
-
// does not exist. The live case survived two autofix passes and was
|
|
1040
|
-
// recommended for dismissal as unfixable.
|
|
1041
|
-
test('the motivating case: 18sp design against a 16sp wrapper yields a font-size fact', () => {
|
|
1042
|
-
const facts = deriveTypeMetricFacts({ 'button-16': { fontSize: 16, lineHeight: 24 } }, { 'button-16': { family: 'Poppins', weight: 600, fontSize: 18, lineHeight: 24 } });
|
|
1043
|
-
expect(facts).toEqual([
|
|
1044
|
-
{
|
|
1045
|
-
kind: 'type_metric',
|
|
1046
|
-
elementId: 'button-16',
|
|
1047
|
-
channel: 'text',
|
|
1048
|
-
metric: 'fontSize',
|
|
1049
|
-
rendered: 16,
|
|
1050
|
-
reference: 18,
|
|
1051
|
-
},
|
|
1052
|
-
]);
|
|
1053
|
-
});
|
|
1054
|
-
test('two metrics diverging on one element yield TWO facts, size first', () => {
|
|
1055
|
-
// Folding them into one fact would collapse downstream into a single
|
|
1056
|
-
// finding (identity is screen+element+category) and silently drop the
|
|
1057
|
-
// other — and the motivating element diverged in BOTH at once.
|
|
1058
|
-
const facts = deriveTypeMetricFacts({ 'button-16': { fontSize: 16, letterSpacing: 0 } }, { 'button-16': { family: 'Poppins', fontSize: 18, letterSpacing: -0.6 } });
|
|
1059
|
-
expect(facts.map((f) => f.metric)).toEqual(['fontSize', 'letterSpacing']);
|
|
1060
|
-
});
|
|
1061
|
-
test('a metric declared on only ONE side is no signal, never a divergence', () => {
|
|
1062
|
-
// A design that declares no tracking is not asserting 0, and a style that
|
|
1063
|
-
// leaves leading Unspecified has not chosen a value to be wrong about.
|
|
1064
|
-
expect(deriveTypeMetricFacts({ label: { fontSize: 14, letterSpacing: 0.5 } }, { label: { family: 'Poppins', fontSize: 14 } })).toEqual([]);
|
|
1065
|
-
expect(deriveTypeMetricFacts({ label: { fontSize: 14 } }, { label: { family: 'Poppins', lineHeight: 20 } })).toEqual([]);
|
|
1066
|
-
});
|
|
1067
|
-
test('a sub-unit difference does not flap', () => {
|
|
1068
|
-
// 15.999 against 16 is the same type; a fact for it would recur on every
|
|
1069
|
-
// render forever. No design system steps type by a tenth of a point.
|
|
1070
|
-
expect(deriveTypeMetricFacts({ label: { fontSize: 15.999, lineHeight: 20.2 } }, { label: { family: 'Poppins', fontSize: 16, lineHeight: 20 } })).toEqual([]);
|
|
1071
|
-
});
|
|
1072
|
-
test('a real one-point difference is reported', () => {
|
|
1073
|
-
const facts = deriveTypeMetricFacts({ label: { fontSize: 15 } }, { label: { family: 'Poppins', fontSize: 16 } });
|
|
1074
|
-
expect(facts).toHaveLength(1);
|
|
1075
|
-
});
|
|
1076
|
-
test('an element the render measured no metrics for yields nothing', () => {
|
|
1077
|
-
expect(deriveTypeMetricFacts({}, { label: { family: 'Poppins', fontSize: 16 } })).toEqual([]);
|
|
1078
|
-
});
|
|
1079
|
-
test('a non-finite measurement is no measurement, not a zero', () => {
|
|
1080
|
-
expect(deriveTypeMetricFacts({ label: { fontSize: Number.NaN } }, { label: { family: 'Poppins', fontSize: 16 } })).toEqual([]);
|
|
1081
|
-
});
|
|
1082
|
-
test('deterministic: output sorted by element id', () => {
|
|
1083
|
-
const facts = deriveTypeMetricFacts({ b: { fontSize: 10 }, a: { fontSize: 10 } }, { b: { family: 'P', fontSize: 16 }, a: { family: 'P', fontSize: 16 } });
|
|
1084
|
-
expect(facts.map((f) => f.elementId)).toEqual(['a', 'b']);
|
|
1085
|
-
});
|
|
1086
|
-
test('the embedded render-side port agrees with the module', () => {
|
|
1087
|
-
// The runner executes the JS twin, not this module. A twin that drifted
|
|
1088
|
-
// would emit different facts from the ones these tests guarantee.
|
|
1089
|
-
const { deriveTypeMetricFacts: embedded } = getGeometryStageInternals();
|
|
1090
|
-
const rendered = { 'button-16': { fontSize: 16, letterSpacing: 0, lineHeight: 24 } };
|
|
1091
|
-
const reference = {
|
|
1092
|
-
'button-16': { family: 'Poppins', weight: 600, fontSize: 18, letterSpacing: -0.6, lineHeight: 24 },
|
|
1093
|
-
};
|
|
1094
|
-
expect(embedded(rendered, reference)).toEqual(deriveTypeMetricFacts(rendered, reference));
|
|
1095
|
-
expect(embedded(rendered, reference)).toHaveLength(2);
|
|
1096
|
-
});
|
|
1097
|
-
});
|
|
1098
|
-
describe('deriveTypefaceFacts — resolved vs design typeface per element', () => {
|
|
1099
|
-
test('a family mismatch yields one typeface fact carrying both fonts', () => {
|
|
1100
|
-
const facts = deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
|
|
1101
|
-
expect(facts).toEqual([
|
|
1102
|
-
{
|
|
1103
|
-
kind: 'typeface',
|
|
1104
|
-
elementId: 'code',
|
|
1105
|
-
channel: 'text',
|
|
1106
|
-
rendered: { family: 'Roboto', weight: 400 },
|
|
1107
|
-
reference: { family: 'Poppins', weight: 600 },
|
|
1108
|
-
},
|
|
1109
|
-
]);
|
|
1110
|
-
});
|
|
1111
|
-
test('a weight mismatch on the same family yields a typeface fact', () => {
|
|
1112
|
-
const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } });
|
|
1113
|
-
expect(facts).toHaveLength(1);
|
|
1114
|
-
expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code', channel: 'text' });
|
|
1115
|
-
});
|
|
1116
|
-
test('an exact family+weight match produces no fact', () => {
|
|
1117
|
-
expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
|
|
1118
|
-
});
|
|
1119
|
-
test('a normalization-equivalent pair produces no fact (suffix-encoded weight vs numeric, case, separators)', () => {
|
|
1120
|
-
// design names "Poppins-SemiBold"; render resolved "Poppins" weight 600 — same font.
|
|
1121
|
-
expect(deriveTypefaceFacts({ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } })).toEqual([]);
|
|
1122
|
-
// resource-name encoding "fira_sans_medium" vs design "Fira Sans" weight 500.
|
|
1123
|
-
expect(deriveTypefaceFacts({ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } })).toEqual([]);
|
|
1124
|
-
// pure case difference on the family.
|
|
1125
|
-
expect(deriveTypefaceFacts({ code: { family: 'POPPINS', weight: 600 } }, { code: { family: 'poppins', weight: 600 } })).toEqual([]);
|
|
1126
|
-
});
|
|
1127
|
-
test('a suffix-encoded family that disagrees on weight still flags', () => {
|
|
1128
|
-
// design "Poppins-SemiBold" (→600) vs resolved "Poppins" Regular (400) — a real
|
|
1129
|
-
// weight fallback the suffix normalization must NOT mask.
|
|
1130
|
-
const facts = deriveTypefaceFacts({ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } });
|
|
1131
|
-
expect(facts).toHaveLength(1);
|
|
1132
|
-
expect(facts[0]).toMatchObject({ kind: 'typeface', elementId: 'code' });
|
|
1133
|
-
});
|
|
1134
|
-
test('a missing rendered or reference font produces no fact (graceful, no false positive)', () => {
|
|
1135
|
-
// reference declares a font the render never resolved → no signal → no fact
|
|
1136
|
-
expect(deriveTypefaceFacts({ code: undefined }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
|
|
1137
|
-
expect(deriveTypefaceFacts({ code: { family: '' } }, { code: { family: 'Poppins', weight: 600 } })).toEqual([]);
|
|
1138
|
-
// render resolved a font the reference never declared → no reference signal → no fact
|
|
1139
|
-
expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, {})).toEqual([]);
|
|
1140
|
-
expect(deriveTypefaceFacts({ code: { family: 'Roboto', weight: 400 } }, { code: { family: '' } })).toEqual([]);
|
|
1141
|
-
});
|
|
1142
|
-
test('result is order-independent and deterministic (sorted by element id)', () => {
|
|
1143
|
-
const a = deriveTypefaceFacts({ b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } }, { a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } });
|
|
1144
|
-
const b = deriveTypefaceFacts({ a: { family: 'Arial', weight: 400 }, b: { family: 'Roboto', weight: 400 } }, { b: { family: 'Roboto', weight: 400 }, a: { family: 'Poppins', weight: 600 } });
|
|
1145
|
-
expect(a).toEqual(b);
|
|
1146
|
-
expect(a.map((f) => f.elementId)).toEqual(['a']);
|
|
1147
|
-
});
|
|
1148
|
-
});
|
|
1149
|
-
describe('buildStructuralBlock — appearance container (Signal C, additive)', () => {
|
|
1150
|
-
test('no appearanceReference → no appearance container; facts + geometry unchanged', () => {
|
|
1151
|
-
const withC = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2);
|
|
1152
|
-
const without = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, null);
|
|
1153
|
-
expect(withC.appearance).toBeUndefined();
|
|
1154
|
-
// Signal A facts and Signal B geometry are byte-identical whether or not the
|
|
1155
|
-
// appearance arg is threaded.
|
|
1156
|
-
expect(without).toEqual(withC);
|
|
1157
|
-
});
|
|
1158
|
-
test('with a figma appearanceReference → container rides along, Signal A/B untouched (issue 04: no presence noise)', () => {
|
|
1159
|
-
// dump node ids: node-1, banner, voucher, copy; reference declares banner+voucher.
|
|
1160
|
-
// element_extra is gated to the previous_render baseline (see the issue-04 test
|
|
1161
|
-
// below), so the figma path no longer flags copy/node-1 as element_extra; both
|
|
1162
|
-
// declared ids rendered, so no element_missing either (issue 03).
|
|
1163
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, {
|
|
1164
|
-
baseline: 'figma',
|
|
1165
|
-
elementIds: ['banner', 'voucher'],
|
|
1166
|
-
});
|
|
1167
|
-
expect(block.appearance).toEqual({
|
|
1168
|
-
baseline: 'figma',
|
|
1169
|
-
facts: [],
|
|
1170
|
-
rendered: { elementIds: ['banner', 'copy', 'node-1', 'voucher'] },
|
|
1171
|
-
});
|
|
1172
|
-
// Additive: the existing Signal A facts and Signal B geometry are untouched.
|
|
1173
|
-
expect(block.facts).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2).facts);
|
|
1174
|
-
expect(block.geometry).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2).geometry);
|
|
1175
|
-
});
|
|
1176
|
-
test('a figma appearanceReference emits NO element_extra — reference_geometry is a partial annotation, not the element inventory (issue 04)', () => {
|
|
1177
|
-
// The figma baseline's ids come from reference_geometry (only the load-bearing
|
|
1178
|
-
// tagged elements). Deriving element_extra against that partial set falsely
|
|
1179
|
-
// flags every untagged rendered element (the e2e noise: copy, node-1).
|
|
1180
|
-
// element_extra requires the COMPLETE inventory, which figma lacks here.
|
|
1181
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, {
|
|
1182
|
-
baseline: 'figma',
|
|
1183
|
-
elementIds: ['banner', 'voucher'],
|
|
1184
|
-
});
|
|
1185
|
-
expect(block.appearance?.baseline).toBe('figma');
|
|
1186
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1187
|
-
// The container still rode along (Signal C ran) with the render's snapshot.
|
|
1188
|
-
expect(block.appearance?.rendered?.elementIds).toEqual(['banner', 'copy', 'node-1', 'voucher']);
|
|
1189
|
-
});
|
|
1190
|
-
test('a figma appearanceReference emits element_missing for a declared id with no rendered node (coverage-gaps issue 03)', () => {
|
|
1191
|
-
// Declared-side presence is noise-free: every reference id is a deliberate,
|
|
1192
|
-
// named, load-bearing element — a declared id matching NO rendered node is a
|
|
1193
|
-
// real absence, not annotation noise. The render-side direction stays gated
|
|
1194
|
-
// (copy/node-1 rendered-but-undeclared → still no element_extra).
|
|
1195
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, {
|
|
1196
|
-
baseline: 'figma',
|
|
1197
|
-
elementIds: ['banner', 'voucher', 'removedButton'],
|
|
1198
|
-
});
|
|
1199
|
-
expect(block.appearance?.facts).toEqual([
|
|
1200
|
-
{ kind: 'element_missing', elementId: 'removedButton' },
|
|
1201
|
-
]);
|
|
1202
|
-
});
|
|
1203
|
-
// ---- Painted ink (icon-ink-ratio 01) ----
|
|
1204
|
-
// `Icon(N.dp)` paints `N x (path bounds / viewport)`, not N, while a design
|
|
1205
|
-
// reference's key for an icon is its vector PATH box. The emitter measures the
|
|
1206
|
-
// ink; this block is the only thing between it and the judge, so a dropped
|
|
1207
|
-
// field here silently restores the container-vs-ink comparison that produced a
|
|
1208
|
-
// false positive, a false negative and an inverted sign, all live at once.
|
|
1209
|
-
//
|
|
1210
|
-
// TWO copies build this element — the module below and the embedded render
|
|
1211
|
-
// script that actually runs on the runner — so both are asserted, and the
|
|
1212
|
-
// parity test guards them against drifting apart later.
|
|
1213
|
-
const INK_DUMP = {
|
|
1214
|
-
canvas: CANVAS,
|
|
1215
|
-
nodes: [
|
|
1216
|
-
{
|
|
1217
|
-
id: 'intersect',
|
|
1218
|
-
bounds: { left: 32, top: 32, right: 64, bottom: 64 },
|
|
1219
|
-
unclippedBounds: { left: 32, top: 32, right: 64, bottom: 64 },
|
|
1220
|
-
parentId: null,
|
|
1221
|
-
clipsChildren: false,
|
|
1222
|
-
paintedInk: { kind: 'vector', viewport: { w: 24, h: 24 }, ink: { w: 15, h: 15 } },
|
|
1223
|
-
},
|
|
1224
|
-
{
|
|
1225
|
-
id: 'label',
|
|
1226
|
-
bounds: { left: 0, top: 80, right: 200, bottom: 120 },
|
|
1227
|
-
unclippedBounds: { left: 0, top: 80, right: 200, bottom: 120 },
|
|
1228
|
-
parentId: null,
|
|
1229
|
-
clipsChildren: false,
|
|
1230
|
-
},
|
|
1231
|
-
],
|
|
1232
|
-
};
|
|
1233
|
-
const INK_REFERENCE = { intersect: { w: 14, h: 14 }, label: { w: 100, h: 20 } };
|
|
1234
|
-
test('carries a measured ink onto the element, and omits it where nothing paints', () => {
|
|
1235
|
-
const block = buildStructuralBlock(INK_DUMP, INK_REFERENCE, 2);
|
|
1236
|
-
const intersect = block.geometry?.elements.find((e) => e.id === 'intersect');
|
|
1237
|
-
expect(intersect?.paintedInk).toEqual({
|
|
1238
|
-
kind: 'vector',
|
|
1239
|
-
viewport: { w: 24, h: 24 },
|
|
1240
|
-
ink: { w: 15, h: 15 },
|
|
1241
|
-
});
|
|
1242
|
-
// A text label paints no painter: its layout box IS the measurement, and a
|
|
1243
|
-
// caveat here would land on every non-icon element in the product.
|
|
1244
|
-
const label = block.geometry?.elements.find((e) => e.id === 'label');
|
|
1245
|
-
expect(label && 'paintedInk' in label).toBe(false);
|
|
1246
|
-
});
|
|
1247
|
-
test('embedded stage matches the module for painted ink (parity, icon-ink-ratio 01)', () => {
|
|
1248
|
-
const embedded = getGeometryStageInternals();
|
|
1249
|
-
expect(embedded.buildStructuralBlock(INK_DUMP, INK_REFERENCE, 2)).toEqual(buildStructuralBlock(INK_DUMP, INK_REFERENCE, 2));
|
|
1250
|
-
});
|
|
1251
|
-
// ---- Render ancestry (icon-ink-ratio 05) ----
|
|
1252
|
-
// The dump carries `parentId` per node and the deriver consumes it for sibling
|
|
1253
|
-
// grouping, then drops it. Without it the judge cannot see the render's tree at
|
|
1254
|
-
// all, so an element the wrapper nested in the wrong container has no finding of
|
|
1255
|
-
// its own — it surfaces only as a side effect of whatever collision it happens
|
|
1256
|
-
// to cause.
|
|
1257
|
-
//
|
|
1258
|
-
// The CHAIN, not the parent link. Compose's semantics tree omits untagged layout
|
|
1259
|
-
// nodes, so a declared key routinely sits several levels below the parent it
|
|
1260
|
-
// should be compared against; measured against three gated screens, an immediate
|
|
1261
|
-
// parent alone produces 21 false rows where the chain produces 2 (see the issue).
|
|
1262
|
-
//
|
|
1263
|
-
// Same three copies as `paintedInk`: this module, the embedded render script that
|
|
1264
|
-
// actually runs on the runner, and Invarn's vendored twin.
|
|
1265
|
-
const NESTED_DUMP = {
|
|
1266
|
-
canvas: CANVAS,
|
|
1267
|
-
nodes: [
|
|
1268
|
-
{
|
|
1269
|
-
id: 'root',
|
|
1270
|
-
bounds: { left: 0, top: 0, right: 200, bottom: 200 },
|
|
1271
|
-
unclippedBounds: { left: 0, top: 0, right: 200, bottom: 200 },
|
|
1272
|
-
parentId: null,
|
|
1273
|
-
clipsChildren: false,
|
|
1274
|
-
},
|
|
1275
|
-
// An UNTAGGED intermediate: the case the chain exists for.
|
|
1276
|
-
{
|
|
1277
|
-
id: 'node-7',
|
|
1278
|
-
bounds: { left: 0, top: 0, right: 200, bottom: 200 },
|
|
1279
|
-
unclippedBounds: { left: 0, top: 0, right: 200, bottom: 200 },
|
|
1280
|
-
parentId: 'root',
|
|
1281
|
-
clipsChildren: false,
|
|
1282
|
-
},
|
|
1283
|
-
{
|
|
1284
|
-
id: 'badge',
|
|
1285
|
-
bounds: { left: 32, top: 32, right: 48, bottom: 48 },
|
|
1286
|
-
unclippedBounds: { left: 32, top: 32, right: 48, bottom: 48 },
|
|
1287
|
-
parentId: 'node-7',
|
|
1288
|
-
clipsChildren: false,
|
|
1289
|
-
},
|
|
1290
|
-
],
|
|
1291
|
-
};
|
|
1292
|
-
const NESTED_REFERENCE = { root: { w: 100, h: 100 }, badge: { w: 8, h: 8 } };
|
|
1293
|
-
test('carries the render ancestor chain onto the element, immediate parent first (icon-ink-ratio 05)', () => {
|
|
1294
|
-
const block = buildStructuralBlock(NESTED_DUMP, NESTED_REFERENCE, 2);
|
|
1295
|
-
const badge = block.geometry?.elements.find((e) => e.id === 'badge');
|
|
1296
|
-
// Through the untagged `node-7` and on to the root — the untagged link is the
|
|
1297
|
-
// whole reason a single `parentId` is not enough.
|
|
1298
|
-
expect(badge?.ancestors).toEqual(['node-7', 'root']);
|
|
1299
|
-
// A root node's chain is empty, not absent: "measured, no ancestors" is a
|
|
1300
|
-
// statement, and it is what tells the judge this render carries ancestry at
|
|
1301
|
-
// all (every render made before this shipped carries none).
|
|
1302
|
-
const root = block.geometry?.elements.find((e) => e.id === 'root');
|
|
1303
|
-
expect(root?.ancestors).toEqual([]);
|
|
1304
|
-
});
|
|
1305
|
-
test('embedded stage matches the module for render ancestry (parity, icon-ink-ratio 05)', () => {
|
|
1306
|
-
const embedded = getGeometryStageInternals();
|
|
1307
|
-
expect(embedded.buildStructuralBlock(NESTED_DUMP, NESTED_REFERENCE, 2)).toEqual(buildStructuralBlock(NESTED_DUMP, NESTED_REFERENCE, 2));
|
|
1308
|
-
});
|
|
1309
|
-
test('embedded stage matches the module for a declared tag absent from the render (parity)', () => {
|
|
1310
|
-
const embedded = getGeometryStageInternals();
|
|
1311
|
-
const reference = { ...HUB_PROMO_REFERENCE, cta: { w: 112, h: 36, x: 16, y: 200 } };
|
|
1312
|
-
expect(embedded.buildStructuralBlock(HUB_PROMO_DUMP, reference, 2)).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, reference, 2));
|
|
1313
|
-
});
|
|
1314
|
-
test('embedded stage matches the module for the figma missing-element case (parity, issue 03)', () => {
|
|
1315
|
-
const embedded = getGeometryStageInternals();
|
|
1316
|
-
const reference = {
|
|
1317
|
-
baseline: 'figma',
|
|
1318
|
-
elementIds: ['banner', 'voucher', 'removedButton'],
|
|
1319
|
-
};
|
|
1320
|
-
expect(embedded.buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, reference)).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, reference));
|
|
1321
|
-
});
|
|
1322
|
-
test('a previous_render appearanceReference still emits presence facts keyed on the previous render id set (issue 04)', () => {
|
|
1323
|
-
// The previous_render baseline carries the COMPLETE prior-render inventory, so
|
|
1324
|
-
// missing/extra are meaningful: `legacyBadge` was in the prior render and is
|
|
1325
|
-
// gone (missing); `copy`/`node-1` are in the prior render too (not extra).
|
|
1326
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, null, 2, undefined, {
|
|
1327
|
-
baseline: 'previous_render',
|
|
1328
|
-
elementIds: ['banner', 'voucher', 'copy', 'node-1', 'legacyBadge'],
|
|
1329
|
-
});
|
|
1330
|
-
expect(block.appearance?.facts).toEqual([
|
|
1331
|
-
{ kind: 'element_missing', elementId: 'legacyBadge' },
|
|
1332
|
-
]);
|
|
1333
|
-
});
|
|
1334
|
-
test('an appearanceReference matching the render → ran-and-clean (baseline, zero facts)', () => {
|
|
1335
|
-
const block = buildStructuralBlock(HUB_PROMO_DUMP, null, 2, undefined, {
|
|
1336
|
-
baseline: 'figma',
|
|
1337
|
-
elementIds: ['node-1', 'banner', 'voucher', 'copy'],
|
|
1338
|
-
});
|
|
1339
|
-
expect(block.appearance).toEqual({
|
|
1340
|
-
baseline: 'figma',
|
|
1341
|
-
facts: [],
|
|
1342
|
-
rendered: { elementIds: ['banner', 'copy', 'node-1', 'voucher'] },
|
|
1343
|
-
});
|
|
1344
|
-
});
|
|
1345
|
-
// A dump whose per-node `text` (Signal C render-side enrichment) drives the
|
|
1346
|
-
// copy-fact comparison: `title` renders the wrong string, `cta` renders no
|
|
1347
|
-
// text, and `debugOverlay` is an extra element absent from the reference.
|
|
1348
|
-
const COPY_DUMP = {
|
|
1349
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1350
|
-
nodes: [
|
|
1351
|
-
{ id: 'title', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: '5 EUR' },
|
|
1352
|
-
{ id: 'cta', bounds: { left: 0, top: 30, right: 100, bottom: 50 }, parentId: null, clipsChildren: false },
|
|
1353
|
-
{ id: 'debugOverlay', bounds: { left: 0, top: 60, right: 100, bottom: 80 }, parentId: null, clipsChildren: false },
|
|
1354
|
-
],
|
|
1355
|
-
};
|
|
1356
|
-
test('with reference text → copy facts derive on the figma path (no presence noise, issue 04)', () => {
|
|
1357
|
-
// Copy facts still fire on the figma baseline (reference text present); only
|
|
1358
|
-
// presence is gated off, so `debugOverlay` (rendered, not in the reference) is
|
|
1359
|
-
// no longer flagged element_extra — just the genuine copy drift remains.
|
|
1360
|
-
const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
|
|
1361
|
-
baseline: 'figma',
|
|
1362
|
-
elementIds: ['title', 'cta'],
|
|
1363
|
-
text: { title: '5 EUR off', cta: 'Shop now' },
|
|
1364
|
-
});
|
|
1365
|
-
expect(block.appearance).toEqual({
|
|
1366
|
-
baseline: 'figma',
|
|
1367
|
-
facts: [
|
|
1368
|
-
// copy only (sorted by id: cta missing, title mismatched)
|
|
1369
|
-
{ kind: 'text_missing', elementId: 'cta', reference: 'Shop now' },
|
|
1370
|
-
{ kind: 'text_mismatch', elementId: 'title', rendered: '5 EUR', reference: '5 EUR off' },
|
|
1371
|
-
],
|
|
1372
|
-
rendered: { elementIds: ['cta', 'debugOverlay', 'title'], text: { title: '5 EUR' } },
|
|
1373
|
-
});
|
|
1374
|
-
});
|
|
1375
|
-
test('an appearanceReference without text → presence only (issue-01 behavior unchanged)', () => {
|
|
1376
|
-
const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
|
|
1377
|
-
baseline: 'figma',
|
|
1378
|
-
elementIds: ['title', 'cta', 'debugOverlay'],
|
|
1379
|
-
});
|
|
1380
|
-
expect(block.appearance).toEqual({
|
|
1381
|
-
baseline: 'figma',
|
|
1382
|
-
facts: [],
|
|
1383
|
-
rendered: { elementIds: ['cta', 'debugOverlay', 'title'], text: { title: '5 EUR' } },
|
|
1384
|
-
});
|
|
1385
|
-
});
|
|
1386
|
-
// A dump whose per-node fill/text colors (Signal C render-side enrichment)
|
|
1387
|
-
// drive the color-fact comparison: `card` renders the wrong fill, correct text.
|
|
1388
|
-
const COLOR_DUMP = {
|
|
1389
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1390
|
-
nodes: [
|
|
1391
|
-
{ id: 'card', bounds: { left: 0, top: 0, right: 100, bottom: 40 }, parentId: null, clipsChildren: false, fillColor: '#ff0000', textColor: '#000000' },
|
|
1392
|
-
],
|
|
1393
|
-
};
|
|
1394
|
-
test('with reference color → color facts are appended after presence/copy facts', () => {
|
|
1395
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2, undefined, {
|
|
1396
|
-
baseline: 'figma',
|
|
1397
|
-
elementIds: ['card'],
|
|
1398
|
-
color: { card: { fill: '#00ff00', text: '#000000' } },
|
|
1399
|
-
});
|
|
1400
|
-
expect(block.appearance?.baseline).toBe('figma');
|
|
1401
|
-
expect(block.appearance?.facts).toEqual([
|
|
1402
|
-
{
|
|
1403
|
-
kind: 'color',
|
|
1404
|
-
elementId: 'card',
|
|
1405
|
-
channel: 'fill',
|
|
1406
|
-
rendered: '#ff0000',
|
|
1407
|
-
reference: '#00ff00',
|
|
1408
|
-
deltaE: ciede2000(hexToLab('#ff0000'), hexToLab('#00ff00')),
|
|
1409
|
-
},
|
|
1410
|
-
]);
|
|
1411
|
-
});
|
|
1412
|
-
test('with reference text color → a text-channel color fact (issue 02)', () => {
|
|
1413
|
-
// COLOR_DUMP renders `card` text #000000; the reference declares #ffffff for
|
|
1414
|
-
// the text channel and the matching fill — so exactly one fact, channel:'text'.
|
|
1415
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2, undefined, {
|
|
1416
|
-
baseline: 'figma',
|
|
1417
|
-
elementIds: ['card'],
|
|
1418
|
-
color: { card: { fill: '#ff0000', text: '#ffffff' } },
|
|
1419
|
-
});
|
|
1420
|
-
expect(block.appearance?.facts).toEqual([
|
|
1421
|
-
{
|
|
1422
|
-
kind: 'color',
|
|
1423
|
-
elementId: 'card',
|
|
1424
|
-
channel: 'text',
|
|
1425
|
-
rendered: '#000000',
|
|
1426
|
-
reference: '#ffffff',
|
|
1427
|
-
deltaE: ciede2000(hexToLab('#000000'), hexToLab('#ffffff')),
|
|
1428
|
-
},
|
|
1429
|
-
]);
|
|
1430
|
-
});
|
|
1431
|
-
test('an identical text color → no text fact (issue 02)', () => {
|
|
1432
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2, undefined, {
|
|
1433
|
-
baseline: 'figma',
|
|
1434
|
-
elementIds: ['card'],
|
|
1435
|
-
color: { card: { fill: '#ff0000', text: '#000000' } },
|
|
1436
|
-
});
|
|
1437
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1438
|
-
});
|
|
1439
|
-
test('an appearanceReference without color → no color facts (presence only)', () => {
|
|
1440
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2, undefined, {
|
|
1441
|
-
baseline: 'figma',
|
|
1442
|
-
elementIds: ['card'],
|
|
1443
|
-
});
|
|
1444
|
-
expect(block.appearance).toEqual({
|
|
1445
|
-
baseline: 'figma',
|
|
1446
|
-
facts: [],
|
|
1447
|
-
rendered: { elementIds: ['card'], color: { card: { fill: '#ff0000', text: '#000000' } } },
|
|
1448
|
-
});
|
|
1449
|
-
});
|
|
1450
|
-
// ---- Signal C no-Figma baseline (issue 04) ----
|
|
1451
|
-
test('the appearance container persists a rendered snapshot (the baseline source for the next render)', () => {
|
|
1452
|
-
// The snapshot captures THIS render's own element ids + text + colors so a
|
|
1453
|
-
// later no-Figma render can diff against it. Text/color are present only for
|
|
1454
|
-
// the nodes that expose them.
|
|
1455
|
-
const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
|
|
1456
|
-
baseline: 'figma',
|
|
1457
|
-
elementIds: ['title', 'cta'],
|
|
1458
|
-
});
|
|
1459
|
-
expect(block.appearance?.rendered).toEqual({
|
|
1460
|
-
elementIds: ['cta', 'debugOverlay', 'title'],
|
|
1461
|
-
text: { title: '5 EUR' },
|
|
1462
|
-
});
|
|
1463
|
-
});
|
|
1464
|
-
test('a previous_render baseline marks the facts as regressions (drift), derivers unchanged', () => {
|
|
1465
|
-
// The same derivers run; only the reference source + baseline marking differ.
|
|
1466
|
-
// Reference here is the *previous render*'s ids/text/color.
|
|
1467
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2, undefined, {
|
|
1468
|
-
baseline: 'previous_render',
|
|
1469
|
-
elementIds: ['card'],
|
|
1470
|
-
color: { card: { fill: '#00ff00' } },
|
|
1471
|
-
});
|
|
1472
|
-
expect(block.appearance?.baseline).toBe('previous_render');
|
|
1473
|
-
expect(block.appearance?.facts).toEqual([
|
|
1474
|
-
{
|
|
1475
|
-
kind: 'color',
|
|
1476
|
-
elementId: 'card',
|
|
1477
|
-
channel: 'fill',
|
|
1478
|
-
rendered: '#ff0000',
|
|
1479
|
-
reference: '#00ff00',
|
|
1480
|
-
deltaE: ciede2000(hexToLab('#ff0000'), hexToLab('#00ff00')),
|
|
1481
|
-
},
|
|
1482
|
-
]);
|
|
1483
|
-
});
|
|
1484
|
-
test('a previous_render baseline diffs presence against the previous element-id set', () => {
|
|
1485
|
-
// COLOR_DUMP renders only `card`; the previous render had `card` + `legacyBadge`.
|
|
1486
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2, undefined, {
|
|
1487
|
-
baseline: 'previous_render',
|
|
1488
|
-
elementIds: ['card', 'legacyBadge'],
|
|
1489
|
-
});
|
|
1490
|
-
expect(block.appearance).toMatchObject({
|
|
1491
|
-
baseline: 'previous_render',
|
|
1492
|
-
facts: [{ kind: 'element_missing', elementId: 'legacyBadge' }],
|
|
1493
|
-
});
|
|
1494
|
-
});
|
|
1495
|
-
test('no appearanceReference (neither Figma nor previous render) → no container, no rendered snapshot', () => {
|
|
1496
|
-
const block = buildStructuralBlock(COLOR_DUMP, null, 2);
|
|
1497
|
-
expect(block.appearance).toBeUndefined();
|
|
1498
|
-
});
|
|
1499
|
-
// ---- Signal C typeface facts (resolved vs design font, issue 03) ----
|
|
1500
|
-
// A dump whose text node carries the resolved-typeface enrichment (issue 03):
|
|
1501
|
-
// the font that ACTUALLY painted, post-fallback — here a Roboto fallback where
|
|
1502
|
-
// the design wants Poppins SemiBold. The dump also declares the render SDK the
|
|
1503
|
-
// proof ran at (TextRunShaper, the capture seam, is API 31+).
|
|
1504
|
-
const TYPEFACE_DUMP = {
|
|
1505
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1506
|
-
renderSdk: 34,
|
|
1507
|
-
nodes: [
|
|
1508
|
-
{
|
|
1509
|
-
id: 'code',
|
|
1510
|
-
bounds: { left: 0, top: 0, right: 100, bottom: 20 },
|
|
1511
|
-
parentId: null,
|
|
1512
|
-
clipsChildren: false,
|
|
1513
|
-
text: 'INV-50',
|
|
1514
|
-
resolvedTypeface: { family: 'Roboto', weight: 400 },
|
|
1515
|
-
},
|
|
1516
|
-
],
|
|
1517
|
-
};
|
|
1518
|
-
test('with a reference typeface → a typeface fact fires on divergence and the resolved font is persisted (issue 03)', () => {
|
|
1519
|
-
const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
|
|
1520
|
-
baseline: 'figma',
|
|
1521
|
-
elementIds: ['code'],
|
|
1522
|
-
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1523
|
-
});
|
|
1524
|
-
expect(block.appearance?.facts).toEqual([
|
|
1525
|
-
{
|
|
1526
|
-
kind: 'typeface',
|
|
1527
|
-
elementId: 'code',
|
|
1528
|
-
channel: 'text',
|
|
1529
|
-
rendered: { family: 'Roboto', weight: 400 },
|
|
1530
|
-
reference: { family: 'Poppins', weight: 600 },
|
|
1531
|
-
},
|
|
1532
|
-
]);
|
|
1533
|
-
expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
|
|
1534
|
-
});
|
|
1535
|
-
test('a matching resolved typeface → no fact; the snapshot still persists the resolved font (issue 03)', () => {
|
|
1536
|
-
const block = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
|
|
1537
|
-
baseline: 'figma',
|
|
1538
|
-
elementIds: ['code'],
|
|
1539
|
-
typeface: { code: { family: 'Roboto', weight: 400 } },
|
|
1540
|
-
});
|
|
1541
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1542
|
-
expect(block.appearance?.rendered?.typeface).toEqual({ code: { family: 'Roboto', weight: 400 } });
|
|
1543
|
-
});
|
|
1544
|
-
test('a node without the enrichment yields no typeface signal and no snapshot entry (old packages degrade gracefully)', () => {
|
|
1545
|
-
const block = buildStructuralBlock(COPY_DUMP, null, 2, undefined, {
|
|
1546
|
-
baseline: 'figma',
|
|
1547
|
-
elementIds: ['title'],
|
|
1548
|
-
typeface: { title: { family: 'Poppins', weight: 600 } },
|
|
1549
|
-
});
|
|
1550
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1551
|
-
expect(block.appearance?.rendered?.typeface).toBeUndefined();
|
|
1552
|
-
});
|
|
1553
|
-
test('a render SDK below 31 omits typeface facts AND carries the advisory — never a silent pass (issue 03)', () => {
|
|
1554
|
-
const oldSdkDump = {
|
|
1555
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1556
|
-
renderSdk: 28,
|
|
1557
|
-
nodes: [
|
|
1558
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
|
|
1559
|
-
],
|
|
1560
|
-
};
|
|
1561
|
-
const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
|
|
1562
|
-
baseline: 'figma',
|
|
1563
|
-
elementIds: ['code'],
|
|
1564
|
-
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1565
|
-
});
|
|
1566
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1567
|
-
expect(block.appearance?.advisories).toEqual([
|
|
1568
|
-
'typeface check requires render SDK >= 31; this proof rendered at SDK 28 — typeface facts were not derived (not checked, not passing)',
|
|
1569
|
-
]);
|
|
1570
|
-
});
|
|
1571
|
-
test('a render SDK below 31 without a typeface reference carries no advisory (nothing was checkable)', () => {
|
|
1572
|
-
const oldSdkDump = {
|
|
1573
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1574
|
-
renderSdk: 28,
|
|
1575
|
-
nodes: [
|
|
1576
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
|
|
1577
|
-
],
|
|
1578
|
-
};
|
|
1579
|
-
const block = buildStructuralBlock(oldSdkDump, null, 2, undefined, {
|
|
1580
|
-
baseline: 'figma',
|
|
1581
|
-
elementIds: ['code'],
|
|
1582
|
-
});
|
|
1583
|
-
expect(block.appearance?.advisories).toBeUndefined();
|
|
1584
|
-
});
|
|
1585
|
-
test('a render SDK >= 31 carries no advisory; facts derive normally', () => {
|
|
1586
|
-
const modern = buildStructuralBlock(TYPEFACE_DUMP, null, 2, undefined, {
|
|
1587
|
-
baseline: 'figma',
|
|
1588
|
-
elementIds: ['code'],
|
|
1589
|
-
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1590
|
-
});
|
|
1591
|
-
expect(modern.appearance?.advisories).toBeUndefined();
|
|
1592
|
-
expect(modern.appearance?.facts).toHaveLength(1);
|
|
1593
|
-
});
|
|
1594
|
-
test('an ABSENT render SDK carries the advisory too — a Roboto fallback on an old package must not read as checked (coverage-gaps issue 04)', () => {
|
|
1595
|
-
// The emitter stamps renderSdk in the same generation that captures
|
|
1596
|
-
// resolvedTypeface, so an absent renderSdk means the dump CANNOT carry
|
|
1597
|
-
// resolved typefaces: the check did not run, and "not run" is an
|
|
1598
|
-
// advisory, never a silent pass.
|
|
1599
|
-
const undeclared = {
|
|
1600
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
1601
|
-
nodes: [
|
|
1602
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
|
|
1603
|
-
],
|
|
1604
|
-
};
|
|
1605
|
-
const block = buildStructuralBlock(undeclared, null, 2, undefined, {
|
|
1606
|
-
baseline: 'figma',
|
|
1607
|
-
elementIds: ['code'],
|
|
1608
|
-
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1609
|
-
});
|
|
1610
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1611
|
-
expect(block.appearance?.advisories).toEqual([
|
|
1612
|
-
'typeface check requires render SDK >= 31; this proof declared no render SDK (an older package) — typeface facts were not derived (not checked, not passing)',
|
|
1613
|
-
]);
|
|
1614
|
-
});
|
|
1615
|
-
});
|
|
1616
|
-
// ---- Signal C: previous-render → AppearanceReference mapper (issue 01) ----
|
|
1617
|
-
//
|
|
1618
|
-
// The no-Figma drift path: a previous catalog render persisted its own
|
|
1619
|
-
// appearance snapshot (`appearance.rendered`: element ids + per-element colors).
|
|
1620
|
-
// This pure mapper turns that snapshot into the `previous_render`
|
|
1621
|
-
// AppearanceReference the next render diffs against — the colors/ids to compare,
|
|
1622
|
-
// marked as drift (regression vs the last render), not a design divergence. It is
|
|
1623
|
-
// the testable core the thin catalog-fetch adapter wraps; no I/O here.
|
|
1624
|
-
describe('appearanceReferenceFromPreviousRender — previous render snapshot → reference', () => {
|
|
1625
|
-
test('a previous render with per-element fill colors → a previous_render reference carrying color + ids', () => {
|
|
1626
|
-
const previous = {
|
|
1627
|
-
elementIds: ['banner', 'voucher'],
|
|
1628
|
-
color: { banner: { fill: '#fff0ee' } },
|
|
1629
|
-
};
|
|
1630
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1631
|
-
baseline: 'previous_render',
|
|
1632
|
-
elementIds: ['banner', 'voucher'],
|
|
1633
|
-
color: { banner: { fill: '#fff0ee' } },
|
|
1634
|
-
});
|
|
1635
|
-
});
|
|
1636
|
-
test('a previous render with per-element text colors → the text channel rides through into the reference (issue 02)', () => {
|
|
1637
|
-
// The mapper carries the whole previous color map, so the foreground (text)
|
|
1638
|
-
// channel reaches the reference exactly as the fill channel does — channel
|
|
1639
|
-
// genericity, not a fill-only carry.
|
|
1640
|
-
const previous = {
|
|
1641
|
-
elementIds: ['banner', 'copy'],
|
|
1642
|
-
color: { banner: { fill: '#fff0ee' }, copy: { text: '#1b1b1b' } },
|
|
1643
|
-
};
|
|
1644
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1645
|
-
baseline: 'previous_render',
|
|
1646
|
-
elementIds: ['banner', 'copy'],
|
|
1647
|
-
color: { banner: { fill: '#fff0ee' }, copy: { text: '#1b1b1b' } },
|
|
1648
|
-
});
|
|
1649
|
-
});
|
|
1650
|
-
test("a previous render with per-element text → the mapper carries text into the reference (issue 03)", () => {
|
|
1651
|
-
// The previous render's accessible text content (copy) becomes the reference
|
|
1652
|
-
// the next render's text is diffed against — drift, not design divergence.
|
|
1653
|
-
const previous = {
|
|
1654
|
-
elementIds: ['banner', 'copy'],
|
|
1655
|
-
text: { copy: 'Get 5 € off your first order placed by 1 lug 2026.' },
|
|
1656
|
-
};
|
|
1657
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1658
|
-
baseline: 'previous_render',
|
|
1659
|
-
elementIds: ['banner', 'copy'],
|
|
1660
|
-
text: { copy: 'Get 5 € off your first order placed by 1 lug 2026.' },
|
|
1661
|
-
});
|
|
1662
|
-
});
|
|
1663
|
-
test('a previous render with per-element resolved typefaces → the mapper carries typeface into the reference (typeface issue 05)', () => {
|
|
1664
|
-
// The previous render's RESOLVED font becomes the drift reference: a
|
|
1665
|
-
// previously-correct element that silently falls back on a later build then
|
|
1666
|
-
// diverges from its own prior snapshot and is flagged.
|
|
1667
|
-
const previous = {
|
|
1668
|
-
elementIds: ['banner', 'code'],
|
|
1669
|
-
typeface: { code: { family: 'poppins_semibold', weight: 600 } },
|
|
1670
|
-
};
|
|
1671
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1672
|
-
baseline: 'previous_render',
|
|
1673
|
-
elementIds: ['banner', 'code'],
|
|
1674
|
-
typeface: { code: { family: 'poppins_semibold', weight: 600 } },
|
|
1675
|
-
});
|
|
1676
|
-
// An empty typeface map is no signal on that axis — not carried.
|
|
1677
|
-
expect(appearanceReferenceFromPreviousRender({ elementIds: ['banner'], typeface: {} })).toEqual({ baseline: 'previous_render', elementIds: ['banner'] });
|
|
1678
|
-
});
|
|
1679
|
-
test('fed into buildStructuralBlock, a typeface that drifts from the previous render yields a previous_render typeface fact (typeface issue 05)', () => {
|
|
1680
|
-
const dump = {
|
|
1681
|
-
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
1682
|
-
renderSdk: 34,
|
|
1683
|
-
nodes: [
|
|
1684
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
|
|
1685
|
-
],
|
|
1686
|
-
};
|
|
1687
|
-
const previous = {
|
|
1688
|
-
elementIds: ['code'],
|
|
1689
|
-
typeface: { code: { family: 'poppins_semibold' } },
|
|
1690
|
-
};
|
|
1691
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
|
|
1692
|
-
expect(block.appearance?.baseline).toBe('previous_render');
|
|
1693
|
-
const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
|
|
1694
|
-
expect(typefaceFacts).toEqual([
|
|
1695
|
-
{
|
|
1696
|
-
kind: 'typeface',
|
|
1697
|
-
elementId: 'code',
|
|
1698
|
-
channel: 'text',
|
|
1699
|
-
rendered: { family: 'Roboto-Regular', weight: 400 },
|
|
1700
|
-
reference: { family: 'poppins_semibold' },
|
|
1701
|
-
},
|
|
1702
|
-
]);
|
|
1703
|
-
});
|
|
1704
|
-
test('a previous render with both color and text → the reference carries both maps (issue 03)', () => {
|
|
1705
|
-
const previous = {
|
|
1706
|
-
elementIds: ['banner', 'copy'],
|
|
1707
|
-
color: { banner: { fill: '#fff0ee' }, copy: { text: '#1b1b1b' } },
|
|
1708
|
-
text: { copy: 'Get 5 € off' },
|
|
1709
|
-
};
|
|
1710
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1711
|
-
baseline: 'previous_render',
|
|
1712
|
-
elementIds: ['banner', 'copy'],
|
|
1713
|
-
color: { banner: { fill: '#fff0ee' }, copy: { text: '#1b1b1b' } },
|
|
1714
|
-
text: { copy: 'Get 5 € off' },
|
|
1715
|
-
});
|
|
1716
|
-
});
|
|
1717
|
-
test('an empty text map is treated as no text signal (presence-only on the text axis) (issue 03)', () => {
|
|
1718
|
-
const previous = { elementIds: ['copy'], text: {} };
|
|
1719
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1720
|
-
baseline: 'previous_render',
|
|
1721
|
-
elementIds: ['copy'],
|
|
1722
|
-
});
|
|
1723
|
-
});
|
|
1724
|
-
test('a previous render with no extractable color → a presence-only reference (ids, no color)', () => {
|
|
1725
|
-
const previous = { elementIds: ['banner', 'voucher'] };
|
|
1726
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1727
|
-
baseline: 'previous_render',
|
|
1728
|
-
elementIds: ['banner', 'voucher'],
|
|
1729
|
-
});
|
|
1730
|
-
});
|
|
1731
|
-
test('an empty color map is treated as no color signal (presence-only)', () => {
|
|
1732
|
-
const previous = { elementIds: ['banner'], color: {} };
|
|
1733
|
-
expect(appearanceReferenceFromPreviousRender(previous)).toEqual({
|
|
1734
|
-
baseline: 'previous_render',
|
|
1735
|
-
elementIds: ['banner'],
|
|
1736
|
-
});
|
|
1737
|
-
});
|
|
1738
|
-
test('a genuinely absent previous render → null (no appearance container downstream)', () => {
|
|
1739
|
-
expect(appearanceReferenceFromPreviousRender(null)).toBeNull();
|
|
1740
|
-
expect(appearanceReferenceFromPreviousRender(undefined)).toBeNull();
|
|
1741
|
-
expect(appearanceReferenceFromPreviousRender({})).toBeNull();
|
|
1742
|
-
});
|
|
1743
|
-
test('fed into buildStructuralBlock, a drifted fill produces a previous_render color fact (the tracer)', () => {
|
|
1744
|
-
// The render now paints the banner the wrong fill (#1e88e5); the previous
|
|
1745
|
-
// render painted #fff0ee. The mapper turns that previous snapshot into the
|
|
1746
|
-
// drift reference, and the assembled block carries one fill color fact with a
|
|
1747
|
-
// large positive ΔE — exactly the blue-banner divergence, proven through the
|
|
1748
|
-
// public interface (the e2e proves the same on a real render).
|
|
1749
|
-
const driftedDump = {
|
|
1750
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
1751
|
-
nodes: [
|
|
1752
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 282 }, parentId: null, clipsChildren: false, fillColor: '#1e88e5' },
|
|
1753
|
-
],
|
|
1754
|
-
};
|
|
1755
|
-
const previous = { elementIds: ['banner'], color: { banner: { fill: '#fff0ee' } } };
|
|
1756
|
-
const reference = appearanceReferenceFromPreviousRender(previous);
|
|
1757
|
-
const block = buildStructuralBlock(driftedDump, null, 2, undefined, reference);
|
|
1758
|
-
expect(block.appearance?.baseline).toBe('previous_render');
|
|
1759
|
-
const colorFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'color');
|
|
1760
|
-
expect(colorFacts).toEqual([
|
|
1761
|
-
{
|
|
1762
|
-
kind: 'color',
|
|
1763
|
-
elementId: 'banner',
|
|
1764
|
-
channel: 'fill',
|
|
1765
|
-
rendered: '#1e88e5',
|
|
1766
|
-
reference: '#fff0ee',
|
|
1767
|
-
deltaE: ciede2000(hexToLab('#1e88e5'), hexToLab('#fff0ee')),
|
|
1768
|
-
},
|
|
1769
|
-
]);
|
|
1770
|
-
expect(colorFacts[0].kind === 'color' && colorFacts[0].deltaE).toBeGreaterThan(10);
|
|
1771
|
-
});
|
|
1772
|
-
test('fed into buildStructuralBlock, a drifted text color produces a previous_render text-channel fact (issue 02)', () => {
|
|
1773
|
-
// The render paints `copy` the wrong foreground color (#1e88e5); the previous
|
|
1774
|
-
// render painted #1b1b1b. The mapper carries the text channel through, and the
|
|
1775
|
-
// assembled block carries one text color fact with a large positive ΔE —
|
|
1776
|
-
// distinct from fill, proven through the public interface.
|
|
1777
|
-
const driftedDump = {
|
|
1778
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
1779
|
-
nodes: [
|
|
1780
|
-
{ id: 'copy', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, textColor: '#1e88e5' },
|
|
1781
|
-
],
|
|
1782
|
-
};
|
|
1783
|
-
const previous = { elementIds: ['copy'], color: { copy: { text: '#1b1b1b' } } };
|
|
1784
|
-
const reference = appearanceReferenceFromPreviousRender(previous);
|
|
1785
|
-
const block = buildStructuralBlock(driftedDump, null, 2, undefined, reference);
|
|
1786
|
-
expect(block.appearance?.baseline).toBe('previous_render');
|
|
1787
|
-
const colorFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'color');
|
|
1788
|
-
expect(colorFacts).toEqual([
|
|
1789
|
-
{
|
|
1790
|
-
kind: 'color',
|
|
1791
|
-
elementId: 'copy',
|
|
1792
|
-
channel: 'text',
|
|
1793
|
-
rendered: '#1e88e5',
|
|
1794
|
-
reference: '#1b1b1b',
|
|
1795
|
-
deltaE: ciede2000(hexToLab('#1e88e5'), hexToLab('#1b1b1b')),
|
|
1796
|
-
},
|
|
1797
|
-
]);
|
|
1798
|
-
expect(colorFacts[0].kind === 'color' && colorFacts[0].deltaE).toBeGreaterThan(10);
|
|
1799
|
-
});
|
|
1800
|
-
test('a correct fill with a wrong text color yields a text-channel fact only — the two never collapse (issue 02)', () => {
|
|
1801
|
-
// One element carries both channels: fill matches the previous render, text
|
|
1802
|
-
// drifts. Exactly one fact, channel:'text' — the correct fill produces none.
|
|
1803
|
-
const dump = {
|
|
1804
|
-
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
1805
|
-
nodes: [
|
|
1806
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 343, bottom: 72 }, parentId: null, clipsChildren: false, fillColor: '#fff0ee', textColor: '#1e88e5' },
|
|
1807
|
-
],
|
|
1808
|
-
};
|
|
1809
|
-
const previous = {
|
|
1810
|
-
elementIds: ['banner'],
|
|
1811
|
-
color: { banner: { fill: '#fff0ee', text: '#1b1b1b' } },
|
|
1812
|
-
};
|
|
1813
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
|
|
1814
|
-
const colorFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'color');
|
|
1815
|
-
expect(colorFacts).toEqual([
|
|
1816
|
-
{
|
|
1817
|
-
kind: 'color',
|
|
1818
|
-
elementId: 'banner',
|
|
1819
|
-
channel: 'text',
|
|
1820
|
-
rendered: '#1e88e5',
|
|
1821
|
-
reference: '#1b1b1b',
|
|
1822
|
-
deltaE: ciede2000(hexToLab('#1e88e5'), hexToLab('#1b1b1b')),
|
|
1823
|
-
},
|
|
1824
|
-
]);
|
|
1825
|
-
});
|
|
1826
|
-
test('fed into buildStructuralBlock, drifted copy produces a previous_render text_mismatch fact (issue 03)', () => {
|
|
1827
|
-
// The render paints `copy` a different string than the previous render; the
|
|
1828
|
-
// mapper carries the previous text through, and the assembled block carries
|
|
1829
|
-
// one text_mismatch with both strings — drift, proven through the public API.
|
|
1830
|
-
const dump = {
|
|
1831
|
-
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
1832
|
-
nodes: [
|
|
1833
|
-
{ id: 'copy', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, text: 'Get 10 € off your first order placed by 1 lug 2026.' },
|
|
1834
|
-
],
|
|
1835
|
-
};
|
|
1836
|
-
const previous = {
|
|
1837
|
-
elementIds: ['copy'],
|
|
1838
|
-
text: { copy: 'Get 5 € off your first order placed by 1 lug 2026.' },
|
|
1839
|
-
};
|
|
1840
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
|
|
1841
|
-
expect(block.appearance?.baseline).toBe('previous_render');
|
|
1842
|
-
expect(block.appearance?.facts).toEqual([
|
|
1843
|
-
{
|
|
1844
|
-
kind: 'text_mismatch',
|
|
1845
|
-
elementId: 'copy',
|
|
1846
|
-
rendered: 'Get 10 € off your first order placed by 1 lug 2026.',
|
|
1847
|
-
reference: 'Get 5 € off your first order placed by 1 lug 2026.',
|
|
1848
|
-
},
|
|
1849
|
-
]);
|
|
1850
|
-
});
|
|
1851
|
-
test('a reference with text the render no longer carries → a previous_render text_missing fact (issue 03)', () => {
|
|
1852
|
-
const dump = {
|
|
1853
|
-
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
1854
|
-
nodes: [
|
|
1855
|
-
{ id: 'copy', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false },
|
|
1856
|
-
],
|
|
1857
|
-
};
|
|
1858
|
-
const previous = {
|
|
1859
|
-
elementIds: ['copy'],
|
|
1860
|
-
text: { copy: 'Get 5 € off' },
|
|
1861
|
-
};
|
|
1862
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
|
|
1863
|
-
expect(block.appearance?.facts).toEqual([
|
|
1864
|
-
{ kind: 'text_missing', elementId: 'copy', reference: 'Get 5 € off' },
|
|
1865
|
-
]);
|
|
1866
|
-
});
|
|
1867
|
-
test('identical copy vs the previous render → no text fact (issue 03)', () => {
|
|
1868
|
-
const dump = {
|
|
1869
|
-
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
1870
|
-
nodes: [
|
|
1871
|
-
{ id: 'copy', bounds: { left: 0, top: 0, right: 343, bottom: 40 }, parentId: null, clipsChildren: false, text: 'Get 5 € off' },
|
|
1872
|
-
],
|
|
1873
|
-
};
|
|
1874
|
-
const previous = { elementIds: ['copy'], text: { copy: 'Get 5 € off' } };
|
|
1875
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, appearanceReferenceFromPreviousRender(previous));
|
|
1876
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
1877
|
-
});
|
|
1878
|
-
});
|
|
1879
|
-
// ---- Signal C: build the appearance reference from run params (issue 05) ----
|
|
1880
|
-
//
|
|
1881
|
-
// The runner builds the appearanceReference from the per-screen reference_geometry
|
|
1882
|
-
// / reference_text / reference_color params. Issue 05 decouples the container from
|
|
1883
|
-
// reference_geometry: it is assembled when ANY of the three is present, with
|
|
1884
|
-
// elementIds = the UNION of their keys, so a run with reference_color but no
|
|
1885
|
-
// reference_geometry still emits color facts.
|
|
1886
|
-
describe('buildAppearanceReferenceFromParams — union of geometry/text/color ids', () => {
|
|
1887
|
-
test('color only (no geometry) → a reference carrying color, ids from the color keys', () => {
|
|
1888
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1889
|
-
refColor: { banner: { fill: '#fff0ee' } },
|
|
1890
|
-
baseline: 'previous_render',
|
|
1891
|
-
});
|
|
1892
|
-
expect(ref).toEqual({
|
|
1893
|
-
baseline: 'previous_render',
|
|
1894
|
-
elementIds: ['banner'],
|
|
1895
|
-
color: { banner: { fill: '#fff0ee' } },
|
|
1896
|
-
});
|
|
1897
|
-
});
|
|
1898
|
-
test('geometry only → ids from the geometry keys, no text/color attached', () => {
|
|
1899
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1900
|
-
refGeom: { banner: { w: 343, h: 96 }, voucher: { w: 81, h: 81 } },
|
|
1901
|
-
baseline: 'figma',
|
|
1902
|
-
});
|
|
1903
|
-
expect(ref).toEqual({ baseline: 'figma', elementIds: ['banner', 'voucher'] });
|
|
1904
|
-
});
|
|
1905
|
-
test('geometry + color (color ids ⊆ geometry ids) → ids unchanged (additive, no regression)', () => {
|
|
1906
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1907
|
-
refGeom: { banner: { w: 343, h: 96 }, voucher: { w: 81, h: 81 } },
|
|
1908
|
-
refColor: { banner: { fill: '#fff0ee' } },
|
|
1909
|
-
baseline: 'figma',
|
|
1910
|
-
});
|
|
1911
|
-
expect(ref).toEqual({
|
|
1912
|
-
baseline: 'figma',
|
|
1913
|
-
elementIds: ['banner', 'voucher'],
|
|
1914
|
-
color: { banner: { fill: '#fff0ee' } },
|
|
1915
|
-
});
|
|
1916
|
-
});
|
|
1917
|
-
test('text + color, no geometry → union ids, both attached', () => {
|
|
1918
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1919
|
-
refText: { copy: 'Get 5 € off' },
|
|
1920
|
-
refColor: { banner: { fill: '#fff0ee' } },
|
|
1921
|
-
baseline: 'previous_render',
|
|
1922
|
-
});
|
|
1923
|
-
expect(ref).toEqual({
|
|
1924
|
-
baseline: 'previous_render',
|
|
1925
|
-
elementIds: ['copy', 'banner'],
|
|
1926
|
-
text: { copy: 'Get 5 € off' },
|
|
1927
|
-
color: { banner: { fill: '#fff0ee' } },
|
|
1928
|
-
});
|
|
1929
|
-
});
|
|
1930
|
-
test('spans only (no geometry/text/color) → a reference carrying spans, ids from the span keys (issue 16)', () => {
|
|
1931
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1932
|
-
refSpans: { discountText: { text: '5 EUR off', background: '#ffe45e' } },
|
|
1933
|
-
baseline: 'figma',
|
|
1934
|
-
});
|
|
1935
|
-
expect(ref).toEqual({
|
|
1936
|
-
baseline: 'figma',
|
|
1937
|
-
elementIds: ['discountText'],
|
|
1938
|
-
spans: { discountText: { text: '5 EUR off', background: '#ffe45e' } },
|
|
1939
|
-
});
|
|
1940
|
-
});
|
|
1941
|
-
test('lines only (no geometry/text/color/spans) → a reference carrying lines, ids from the line keys (issue 18)', () => {
|
|
1942
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1943
|
-
refLines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
1944
|
-
baseline: 'figma',
|
|
1945
|
-
});
|
|
1946
|
-
expect(ref).toEqual({
|
|
1947
|
-
baseline: 'figma',
|
|
1948
|
-
elementIds: ['promoCopy'],
|
|
1949
|
-
lines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
1950
|
-
});
|
|
1951
|
-
});
|
|
1952
|
-
test('typeface only (no geometry/text/color) → a reference carrying typeface, ids from the typeface keys (typeface issue 04)', () => {
|
|
1953
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1954
|
-
refTypeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1955
|
-
baseline: 'figma',
|
|
1956
|
-
});
|
|
1957
|
-
expect(ref).toEqual({
|
|
1958
|
-
baseline: 'figma',
|
|
1959
|
-
elementIds: ['code'],
|
|
1960
|
-
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
1961
|
-
});
|
|
1962
|
-
});
|
|
1963
|
-
test('none present → null (no appearance container)', () => {
|
|
1964
|
-
expect(buildAppearanceReferenceFromParams({ baseline: 'figma' })).toBeNull();
|
|
1965
|
-
expect(buildAppearanceReferenceFromParams({ baseline: 'figma', refGeom: {}, refColor: {}, refText: {}, refSpans: {}, refLines: {}, refTypeface: {} })).toBeNull();
|
|
1966
|
-
});
|
|
1967
|
-
test('fed into buildStructuralBlock, a typeface reference flags a diverging resolved font and stays silent on a matching or unresolvable one (typeface issue 04)', () => {
|
|
1968
|
-
// The design (Figma) declares Poppins 600 on `code` and Fira Sans 500 on
|
|
1969
|
-
// `label`. The render resolved a Roboto fallback on `code` (→ one fact) and
|
|
1970
|
-
// the correct font on `label` (→ none); `legacyText` has a declared font but
|
|
1971
|
-
// no resolved enrichment (old package) → no signal, no fact.
|
|
1972
|
-
const dump = {
|
|
1973
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
1974
|
-
renderSdk: 34,
|
|
1975
|
-
nodes: [
|
|
1976
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 300, bottom: 40 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto-Regular', weight: 400 } },
|
|
1977
|
-
{ id: 'label', bounds: { left: 0, top: 50, right: 300, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Your code', resolvedTypeface: { family: 'fira_sans_medium' } },
|
|
1978
|
-
{ id: 'legacyText', bounds: { left: 0, top: 100, right: 300, bottom: 140 }, parentId: null, clipsChildren: false, text: 'Old' },
|
|
1979
|
-
],
|
|
1980
|
-
};
|
|
1981
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
1982
|
-
refTypeface: {
|
|
1983
|
-
code: { family: 'Poppins', weight: 600 },
|
|
1984
|
-
label: { family: 'Fira Sans', weight: 500 },
|
|
1985
|
-
legacyText: { family: 'Poppins', weight: 400 },
|
|
1986
|
-
},
|
|
1987
|
-
baseline: 'figma',
|
|
1988
|
-
});
|
|
1989
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
1990
|
-
const typefaceFacts = (block.appearance?.facts ?? []).filter((f) => f.kind === 'typeface');
|
|
1991
|
-
expect(typefaceFacts).toEqual([
|
|
1992
|
-
{
|
|
1993
|
-
kind: 'typeface',
|
|
1994
|
-
elementId: 'code',
|
|
1995
|
-
channel: 'text',
|
|
1996
|
-
rendered: { family: 'Roboto-Regular', weight: 400 },
|
|
1997
|
-
reference: { family: 'Poppins', weight: 600 },
|
|
1998
|
-
},
|
|
1999
|
-
]);
|
|
2000
|
-
});
|
|
2001
|
-
test('fed into buildStructuralBlock, a spans-only reference emits exactly one span_mismatch for the wrong extent (issue 16)', () => {
|
|
2002
|
-
const dump = {
|
|
2003
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
2004
|
-
nodes: [
|
|
2005
|
-
{
|
|
2006
|
-
id: 'discountText',
|
|
2007
|
-
bounds: { left: 0, top: 0, right: 300, bottom: 40 },
|
|
2008
|
-
parentId: null,
|
|
2009
|
-
clipsChildren: false,
|
|
2010
|
-
text: 'Get 5 EUR off',
|
|
2011
|
-
textSpans: [{ start: 4, end: 9, text: '5 EUR', background: '#ffe45e' }],
|
|
2012
|
-
},
|
|
2013
|
-
// A node that never emitted spans (old package) — the declared
|
|
2014
|
-
// expectation for it must stay silent.
|
|
2015
|
-
{ id: 'legacyText', bounds: { left: 0, top: 50, right: 300, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Free delivery' },
|
|
2016
|
-
],
|
|
2017
|
-
};
|
|
2018
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
2019
|
-
refSpans: {
|
|
2020
|
-
discountText: { text: '5 EUR off', background: '#ffe45e' },
|
|
2021
|
-
legacyText: { text: 'Free' },
|
|
2022
|
-
},
|
|
2023
|
-
baseline: 'figma',
|
|
2024
|
-
});
|
|
2025
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
2026
|
-
expect(block.appearance?.facts).toEqual([
|
|
2027
|
-
{ kind: 'span_mismatch', elementId: 'discountText', rendered: '5 EUR', reference: '5 EUR off' },
|
|
2028
|
-
]);
|
|
2029
|
-
});
|
|
2030
|
-
test('a matching extent yields a container with zero facts (the fixed wrapper is clean)', () => {
|
|
2031
|
-
const dump = {
|
|
2032
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
2033
|
-
nodes: [
|
|
2034
|
-
{
|
|
2035
|
-
id: 'discountText',
|
|
2036
|
-
bounds: { left: 0, top: 0, right: 300, bottom: 40 },
|
|
2037
|
-
parentId: null,
|
|
2038
|
-
clipsChildren: false,
|
|
2039
|
-
text: 'Get 5 EUR off',
|
|
2040
|
-
textSpans: [{ start: 4, end: 13, text: '5 EUR off', background: '#ffe45e' }],
|
|
2041
|
-
},
|
|
2042
|
-
],
|
|
2043
|
-
};
|
|
2044
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
2045
|
-
refSpans: { discountText: { text: '5 EUR off' } },
|
|
2046
|
-
baseline: 'figma',
|
|
2047
|
-
});
|
|
2048
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
2049
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
2050
|
-
});
|
|
2051
|
-
test('fed into buildStructuralBlock, a lines-only reference emits exactly one line_break_mismatch for a dropped break (issue 18)', () => {
|
|
2052
|
-
const dump = {
|
|
2053
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
2054
|
-
nodes: [
|
|
2055
|
-
{
|
|
2056
|
-
id: 'promoCopy',
|
|
2057
|
-
bounds: { left: 0, top: 0, right: 600, bottom: 40 },
|
|
2058
|
-
parentId: null,
|
|
2059
|
-
clipsChildren: false,
|
|
2060
|
-
text: 'Get 5 EUR off placed by tomorrow.',
|
|
2061
|
-
textLines: [{ start: 0, end: 33, text: 'Get 5 EUR off placed by tomorrow.' }],
|
|
2062
|
-
},
|
|
2063
|
-
// Emitted no lines (old package) — the declared split stays silent.
|
|
2064
|
-
{ id: 'legacyText', bounds: { left: 0, top: 50, right: 600, bottom: 90 }, parentId: null, clipsChildren: false, text: 'Free delivery' },
|
|
2065
|
-
],
|
|
2066
|
-
};
|
|
2067
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
2068
|
-
refLines: {
|
|
2069
|
-
promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'],
|
|
2070
|
-
legacyText: ['Free', 'delivery'],
|
|
2071
|
-
},
|
|
2072
|
-
baseline: 'figma',
|
|
2073
|
-
});
|
|
2074
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
2075
|
-
expect(block.appearance?.facts).toEqual([
|
|
2076
|
-
{
|
|
2077
|
-
kind: 'line_break_mismatch',
|
|
2078
|
-
elementId: 'promoCopy',
|
|
2079
|
-
rendered: 'Get 5 EUR off placed by tomorrow.',
|
|
2080
|
-
reference: 'Get 5 EUR off\nplaced by tomorrow.',
|
|
2081
|
-
},
|
|
2082
|
-
]);
|
|
2083
|
-
});
|
|
2084
|
-
test('a matching line split yields a container with zero facts', () => {
|
|
2085
|
-
const dump = {
|
|
2086
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
2087
|
-
nodes: [
|
|
2088
|
-
{
|
|
2089
|
-
id: 'promoCopy',
|
|
2090
|
-
bounds: { left: 0, top: 0, right: 600, bottom: 40 },
|
|
2091
|
-
parentId: null,
|
|
2092
|
-
clipsChildren: false,
|
|
2093
|
-
text: 'Get 5 EUR off\nplaced by tomorrow.',
|
|
2094
|
-
textLines: [
|
|
2095
|
-
{ start: 0, end: 13, text: 'Get 5 EUR off' },
|
|
2096
|
-
{ start: 14, end: 33, text: 'placed by tomorrow.' },
|
|
2097
|
-
],
|
|
2098
|
-
},
|
|
2099
|
-
],
|
|
2100
|
-
};
|
|
2101
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
2102
|
-
refLines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
2103
|
-
baseline: 'figma',
|
|
2104
|
-
});
|
|
2105
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
2106
|
-
expect(block.appearance?.facts).toEqual([]);
|
|
2107
|
-
});
|
|
2108
|
-
test('fed into buildStructuralBlock, a color-only reference still emits the color fact', () => {
|
|
2109
|
-
const dump = {
|
|
2110
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 282 },
|
|
2111
|
-
nodes: [
|
|
2112
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 282 }, parentId: null, clipsChildren: false, fillColor: '#1e88e5' },
|
|
2113
|
-
],
|
|
2114
|
-
};
|
|
2115
|
-
const ref = buildAppearanceReferenceFromParams({
|
|
2116
|
-
refColor: { banner: { fill: '#fff0ee' } },
|
|
2117
|
-
baseline: 'previous_render',
|
|
2118
|
-
});
|
|
2119
|
-
const block = buildStructuralBlock(dump, null, 2, undefined, ref);
|
|
2120
|
-
expect(block.appearance?.facts).toEqual([
|
|
2121
|
-
{
|
|
2122
|
-
kind: 'color',
|
|
2123
|
-
elementId: 'banner',
|
|
2124
|
-
channel: 'fill',
|
|
2125
|
-
rendered: '#1e88e5',
|
|
2126
|
-
reference: '#fff0ee',
|
|
2127
|
-
deltaE: ciede2000(hexToLab('#1e88e5'), hexToLab('#fff0ee')),
|
|
2128
|
-
},
|
|
2129
|
-
]);
|
|
2130
|
-
});
|
|
2131
|
-
});
|
|
2132
|
-
describe('buildStructuralBlock — design-frame anchor (composition runs)', () => {
|
|
2133
|
-
// 686x384px at density 2 → a 343×192dp captured root.
|
|
2134
|
-
const FRAME_DUMP = {
|
|
2135
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 384 },
|
|
2136
|
-
nodes: [
|
|
2137
|
-
{ id: 'voucher', bounds: { left: 32, top: 48, right: 194, bottom: 210 }, parentId: null, clipsChildren: false },
|
|
2138
|
-
],
|
|
2139
|
-
};
|
|
2140
|
-
const GEOM = { voucher: { w: 81, h: 81, x: 16, y: 24 } };
|
|
2141
|
-
test('root ≈ declared frame → no mismatch fact, placement is not withheld', () => {
|
|
2142
|
-
const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 343, h: 192 });
|
|
2143
|
-
expect(block.facts.filter((f) => f.kind === 'frame_mismatch')).toEqual([]);
|
|
2144
|
-
expect(block.placement).toEqual({ withheld: false, facts: [] });
|
|
2145
|
-
});
|
|
2146
|
-
test('sub-tolerance drift (≤1dp) does not read as a mismatch', () => {
|
|
2147
|
-
const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 342.5, h: 192.5 });
|
|
2148
|
-
expect(block.facts.filter((f) => f.kind === 'frame_mismatch')).toEqual([]);
|
|
2149
|
-
expect(block.placement).toEqual({ withheld: false, facts: [] });
|
|
2150
|
-
});
|
|
2151
|
-
test('a mis-sized root → frame_mismatch fact with declared/captured dims, placement withheld', () => {
|
|
2152
|
-
const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 343, h: 100 });
|
|
2153
|
-
const mismatches = block.facts.filter((f) => f.kind === 'frame_mismatch');
|
|
2154
|
-
expect(mismatches).toEqual([
|
|
2155
|
-
{
|
|
2156
|
-
kind: 'frame_mismatch',
|
|
2157
|
-
declared: { w: 343, h: 100 },
|
|
2158
|
-
captured: { w: 343, h: 192 },
|
|
2159
|
-
deltas: [{ dimension: 'h', delta: 92 }],
|
|
2160
|
-
},
|
|
2161
|
-
]);
|
|
2162
|
-
expect(block.placement).toEqual({ withheld: true, reason: 'frame_mismatch' });
|
|
2163
|
-
// A mis-sized wrapper root is not a clean render.
|
|
2164
|
-
expect(block.summary.clean).toBe(false);
|
|
2165
|
-
});
|
|
2166
|
-
test('the mismatch review row reads as a mis-sized wrapper root, never a screen defect', () => {
|
|
2167
|
-
const block = buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, { w: 343, h: 100 });
|
|
2168
|
-
const scaffold = buildReviewScaffold(block);
|
|
2169
|
-
const row = scaffold.rows.find((r) => r.fact.includes('wrapper root'));
|
|
2170
|
-
expect(row).toBeDefined();
|
|
2171
|
-
expect(row.fact).toContain('343×192');
|
|
2172
|
-
expect(row.fact).toContain('343×100');
|
|
2173
|
-
expect(row.fact.toLowerCase()).toContain('size the wrapper root');
|
|
2174
|
-
});
|
|
2175
|
-
test('no declared frame → no placement container, block unchanged from today', () => {
|
|
2176
|
-
const withoutFrame = buildStructuralBlock(FRAME_DUMP, GEOM, 2);
|
|
2177
|
-
expect(withoutFrame.placement).toBeUndefined();
|
|
2178
|
-
expect(buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, null)).toEqual(withoutFrame);
|
|
2179
|
-
});
|
|
2180
|
-
test('embedded stage matches the module for match and mismatch alike (parity)', () => {
|
|
2181
|
-
const embedded = getGeometryStageInternals();
|
|
2182
|
-
for (const frame of [{ w: 343, h: 192 }, { w: 343, h: 100 }, null]) {
|
|
2183
|
-
expect(embedded.buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, frame)).toEqual(buildStructuralBlock(FRAME_DUMP, GEOM, 2, undefined, undefined, frame));
|
|
2184
|
-
}
|
|
2185
|
-
});
|
|
2186
|
-
});
|
|
2187
|
-
describe('buildStructuralBlock — placement facts (composition runs)', () => {
|
|
2188
|
-
// 686x384px at density 2 → a 343×192dp captured root; frame matches.
|
|
2189
|
-
const FRAME = { w: 343, h: 192 };
|
|
2190
|
-
// voucher: design places it at (16, 24); rendered root-space (unclipped)
|
|
2191
|
-
// bounds put it at (16, 24) → in place, or shifted for the drift cases.
|
|
2192
|
-
function dumpWithVoucherAt(leftPx, topPx) {
|
|
2193
|
-
return {
|
|
2194
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 384 },
|
|
2195
|
-
nodes: [
|
|
2196
|
-
{
|
|
2197
|
-
id: 'voucher',
|
|
2198
|
-
bounds: { left: leftPx, top: topPx, right: leftPx + 162, bottom: topPx + 162 },
|
|
2199
|
-
unclippedBounds: { left: leftPx, top: topPx, right: leftPx + 162, bottom: topPx + 162 },
|
|
2200
|
-
parentId: null,
|
|
2201
|
-
clipsChildren: false,
|
|
2202
|
-
},
|
|
2203
|
-
],
|
|
2204
|
-
};
|
|
2205
|
-
}
|
|
2206
|
-
const GEOM = { voucher: { w: 81, h: 81, x: 16, y: 24 } };
|
|
2207
|
-
test('a member root shifted beyond tolerance → a placement fact with expected/actual/delta', () => {
|
|
2208
|
-
// Rendered at (20, 100)dp = (40, 200)px — design says (16, 24)dp.
|
|
2209
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, FRAME);
|
|
2210
|
-
expect(block.placement).toEqual({
|
|
2211
|
-
withheld: false,
|
|
2212
|
-
facts: [
|
|
2213
|
-
{
|
|
2214
|
-
elementId: 'voucher',
|
|
2215
|
-
expected: { x: 16, y: 24 },
|
|
2216
|
-
actual: { x: 20, y: 100 },
|
|
2217
|
-
deltas: [
|
|
2218
|
-
{ dimension: 'x', delta: 4 },
|
|
2219
|
-
{ dimension: 'y', delta: 76 },
|
|
2220
|
-
],
|
|
2221
|
-
},
|
|
2222
|
-
],
|
|
2223
|
-
});
|
|
2224
|
-
// Placement drift is load-bearing on a composition State — not clean.
|
|
2225
|
-
expect(block.summary.clean).toBe(false);
|
|
2226
|
-
});
|
|
2227
|
-
test('within tolerance (≤2dp) → measured but no fact, still clean', () => {
|
|
2228
|
-
// Rendered at (17, 25)dp = (34, 50)px — 1dp off design (16, 24).
|
|
2229
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(34, 50), GEOM, 2, undefined, undefined, FRAME);
|
|
2230
|
-
expect(block.placement).toEqual({ withheld: false, facts: [] });
|
|
2231
|
-
expect(block.summary.clean).toBe(true);
|
|
2232
|
-
});
|
|
2233
|
-
test('an element without design x/y contributes no placement fact', () => {
|
|
2234
|
-
const geomNoXY = { voucher: { w: 81, h: 81 } };
|
|
2235
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), geomNoXY, 2, undefined, undefined, FRAME);
|
|
2236
|
-
expect(block.placement).toEqual({ withheld: false, facts: [] });
|
|
2237
|
-
});
|
|
2238
|
-
test('frame mismatch → placement facts withheld with the reason, none computed', () => {
|
|
2239
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, { w: 343, h: 100 });
|
|
2240
|
-
expect(block.placement).toEqual({ withheld: true, reason: 'frame_mismatch' });
|
|
2241
|
-
expect(block.placement && 'facts' in block.placement).toBe(false);
|
|
2242
|
-
});
|
|
2243
|
-
test('no declared frame (component runs) → no placement facts, identical deltas stay advisory', () => {
|
|
2244
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2);
|
|
2245
|
-
expect(block.placement).toBeUndefined();
|
|
2246
|
-
// x/y deltas still reported (advisory) on the geometry element, unflagged.
|
|
2247
|
-
const el = block.geometry.elements.find((e) => e.id === 'voucher');
|
|
2248
|
-
expect(el.deltas.some((d) => d.dimension === 'x' || d.dimension === 'y')).toBe(true);
|
|
2249
|
-
expect(el.sizeFlag).toBe(false);
|
|
2250
|
-
expect(block.summary.clean).toBe(true);
|
|
2251
|
-
});
|
|
2252
|
-
test('embedded stage matches the module for drift, clean, and withheld (parity)', () => {
|
|
2253
|
-
const embedded = getGeometryStageInternals();
|
|
2254
|
-
const cases = [
|
|
2255
|
-
[dumpWithVoucherAt(40, 200), FRAME],
|
|
2256
|
-
[dumpWithVoucherAt(34, 50), FRAME],
|
|
2257
|
-
[dumpWithVoucherAt(40, 200), { w: 343, h: 100 }],
|
|
2258
|
-
[dumpWithVoucherAt(40, 200), null],
|
|
2259
|
-
];
|
|
2260
|
-
for (const [dump, frame] of cases) {
|
|
2261
|
-
expect(embedded.buildStructuralBlock(dump, GEOM, 2, undefined, undefined, frame)).toEqual(buildStructuralBlock(dump, GEOM, 2, undefined, undefined, frame));
|
|
2262
|
-
}
|
|
2263
|
-
});
|
|
2264
|
-
});
|
|
2265
|
-
describe('buildStructuralBlock — frame-anchored component runs (coverage-gaps issue 01)', () => {
|
|
2266
|
-
// Same fixtures as the composition placement suite: 686x384px at density 2 →
|
|
2267
|
-
// a 343×192dp captured root that MATCHES the design frame — but here the run
|
|
2268
|
-
// is a COMPONENT run (no composition referenceFrame); the frame arrives as
|
|
2269
|
-
// the ANCHOR PROBE instead. ADR-0004's canvas-width-artifact rationale for
|
|
2270
|
-
// advisory x/y provably does not apply to a design-frame-sized root, so an
|
|
2271
|
-
// anchored component run earns the same placement check a composition run
|
|
2272
|
-
// gets. A root that does not match keeps today's advisory behavior exactly.
|
|
2273
|
-
const FRAME = { w: 343, h: 192 };
|
|
2274
|
-
function dumpWithVoucherAt(leftPx, topPx) {
|
|
2275
|
-
return {
|
|
2276
|
-
canvas: { left: 0, top: 0, right: 686, bottom: 384 },
|
|
2277
|
-
nodes: [
|
|
2278
|
-
{
|
|
2279
|
-
id: 'voucher',
|
|
2280
|
-
bounds: { left: leftPx, top: topPx, right: leftPx + 162, bottom: topPx + 162 },
|
|
2281
|
-
unclippedBounds: { left: leftPx, top: topPx, right: leftPx + 162, bottom: topPx + 162 },
|
|
2282
|
-
parentId: null,
|
|
2283
|
-
clipsChildren: false,
|
|
2284
|
-
},
|
|
2285
|
-
],
|
|
2286
|
-
};
|
|
2287
|
-
}
|
|
2288
|
-
const GEOM = { voucher: { w: 81, h: 81, x: 16, y: 24 } };
|
|
2289
|
-
test('an anchored root with an element shifted beyond tolerance → placement facts flag (the −50dp button)', () => {
|
|
2290
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, null, FRAME);
|
|
2291
|
-
expect(block.placement).toEqual({
|
|
2292
|
-
withheld: false,
|
|
2293
|
-
facts: [
|
|
2294
|
-
{
|
|
2295
|
-
elementId: 'voucher',
|
|
2296
|
-
expected: { x: 16, y: 24 },
|
|
2297
|
-
actual: { x: 20, y: 100 },
|
|
2298
|
-
deltas: [
|
|
2299
|
-
{ dimension: 'x', delta: 4 },
|
|
2300
|
-
{ dimension: 'y', delta: 76 },
|
|
2301
|
-
],
|
|
2302
|
-
},
|
|
2303
|
-
],
|
|
2304
|
-
});
|
|
2305
|
-
expect(block.summary.clean).toBe(false);
|
|
2306
|
-
});
|
|
2307
|
-
test('an anchored root with everything in place → placement rides with zero facts, clean', () => {
|
|
2308
|
-
// Rendered at (16, 24)dp = (32, 48)px — exactly the design position.
|
|
2309
|
-
const block = buildStructuralBlock(dumpWithVoucherAt(32, 48), GEOM, 2, undefined, undefined, null, FRAME);
|
|
2310
|
-
expect(block.placement).toEqual({ withheld: false, facts: [] });
|
|
2311
|
-
expect(block.summary.clean).toBe(true);
|
|
2312
|
-
});
|
|
2313
|
-
test('a content-sized root (anchor does not match) keeps today\'s behavior byte-identically', () => {
|
|
2314
|
-
// 400x200px @2 → a 200x100dp root, nowhere near the 343×192 frame: no
|
|
2315
|
-
// frame_mismatch fact, no placement container, x/y stays advisory — the
|
|
2316
|
-
// probe must never start an invalidation storm for legacy wrappers.
|
|
2317
|
-
const dump = {
|
|
2318
|
-
canvas: { left: 0, top: 0, right: 400, bottom: 200 },
|
|
2319
|
-
nodes: [
|
|
2320
|
-
{
|
|
2321
|
-
id: 'voucher',
|
|
2322
|
-
bounds: { left: 40, top: 20, right: 202, bottom: 182 },
|
|
2323
|
-
unclippedBounds: { left: 40, top: 20, right: 202, bottom: 182 },
|
|
2324
|
-
parentId: null,
|
|
2325
|
-
clipsChildren: false,
|
|
2326
|
-
},
|
|
2327
|
-
],
|
|
2328
|
-
};
|
|
2329
|
-
const probed = buildStructuralBlock(dump, GEOM, 2, undefined, undefined, null, FRAME);
|
|
2330
|
-
const without = buildStructuralBlock(dump, GEOM, 2);
|
|
2331
|
-
expect(probed).toEqual(without);
|
|
2332
|
-
expect(probed.placement).toBeUndefined();
|
|
2333
|
-
expect(probed.facts.some((f) => f.kind === 'frame_mismatch')).toBe(false);
|
|
2334
|
-
});
|
|
2335
|
-
test('a composition referenceFrame takes precedence over the probe (never both asserted)', () => {
|
|
2336
|
-
const withBoth = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, FRAME, FRAME);
|
|
2337
|
-
const compositionOnly = buildStructuralBlock(dumpWithVoucherAt(40, 200), GEOM, 2, undefined, undefined, FRAME);
|
|
2338
|
-
expect(withBoth).toEqual(compositionOnly);
|
|
2339
|
-
});
|
|
2340
|
-
test('embedded stage matches the module for anchored, unanchored, and in-place (parity)', () => {
|
|
2341
|
-
const embedded = getGeometryStageInternals();
|
|
2342
|
-
const cases = [
|
|
2343
|
-
[dumpWithVoucherAt(40, 200)],
|
|
2344
|
-
[dumpWithVoucherAt(32, 48)],
|
|
2345
|
-
];
|
|
2346
|
-
for (const [dump] of cases) {
|
|
2347
|
-
expect(embedded.buildStructuralBlock(dump, GEOM, 2, undefined, undefined, null, FRAME)).toEqual(buildStructuralBlock(dump, GEOM, 2, undefined, undefined, null, FRAME));
|
|
2348
|
-
}
|
|
2349
|
-
});
|
|
2350
|
-
});
|
|
2351
|
-
describe('buildStructuralBlock — duplicate testTag withhold (geometry / overhang / placement)', () => {
|
|
2352
|
-
// A duplicated tag's entry in the generated-geometry map is last-wins, so its
|
|
2353
|
-
// size diff, any overhang computed against it as a frame, and its placement
|
|
2354
|
-
// drift would all compare an arbitrary instance. Each is withheld; the
|
|
2355
|
-
// deriver's duplicate_tag fact carries the collision.
|
|
2356
|
-
test('a duplicated tag gets no geometry element — no size/clip facts for it', () => {
|
|
2357
|
-
const dump = {
|
|
2358
|
-
canvas: { left: 0, top: 0, right: 360, bottom: 200 },
|
|
2359
|
-
nodes: [
|
|
2360
|
-
{ id: 'card', bounds: { left: 0, top: 0, right: 360, bottom: 200 }, parentId: null, clipsChildren: false },
|
|
2361
|
-
// 64×33 rendered vs 60×36 declared — would sizeFlag if compared.
|
|
2362
|
-
{ id: 'pill', bounds: { left: 10, top: 10, right: 74, bottom: 43 }, parentId: 'card', clipsChildren: false },
|
|
2363
|
-
{ id: 'pill', bounds: { left: 10, top: 60, right: 74, bottom: 93 }, parentId: 'card', clipsChildren: false },
|
|
2364
|
-
],
|
|
2365
|
-
};
|
|
2366
|
-
const block = buildStructuralBlock(dump, { card: { w: 360, h: 200 }, pill: { w: 60, h: 36 } }, 1);
|
|
2367
|
-
expect(block.facts).toContainEqual({ kind: 'duplicate_tag', tag: 'pill', instanceCount: 2 });
|
|
2368
|
-
expect(block.geometry.elements.map((e) => e.id)).toEqual(['card']);
|
|
2369
|
-
expect(block.summary.clean).toBe(false);
|
|
2370
|
-
});
|
|
2371
|
-
test('no overhang is computed against a duplicated frame — the pairing would be arbitrary', () => {
|
|
2372
|
-
const dump = {
|
|
2373
|
-
canvas: { left: 0, top: 0, right: 360, bottom: 400 },
|
|
2374
|
-
nodes: [
|
|
2375
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 343, bottom: 72 }, parentId: null, clipsChildren: false },
|
|
2376
|
-
{ id: 'banner', bounds: { left: 0, top: 100, right: 343, bottom: 172 }, parentId: null, clipsChildren: false },
|
|
2377
|
-
// Child of the duplicated banner: vs the last-wins instance it would
|
|
2378
|
-
// read a spurious 100dp top clip.
|
|
2379
|
-
{ id: 'coin', bounds: { left: 300, top: 40, right: 340, bottom: 90 }, parentId: 'banner', clipsChildren: false },
|
|
2380
|
-
],
|
|
2381
|
-
};
|
|
2382
|
-
const block = buildStructuralBlock(dump, { banner: { x: 0, y: 0, w: 343, h: 72 }, coin: { x: 300, y: 40, w: 40, h: 50 } }, 1);
|
|
2383
|
-
const coin = block.geometry.elements.find((e) => e.id === 'coin');
|
|
2384
|
-
expect(coin).toBeDefined();
|
|
2385
|
-
expect(coin.overhang).toBeUndefined();
|
|
2386
|
-
});
|
|
2387
|
-
test('placement facts skip a duplicated member tag (composition runs)', () => {
|
|
2388
|
-
const dump = {
|
|
2389
|
-
canvas: { left: 0, top: 0, right: 360, bottom: 640 },
|
|
2390
|
-
nodes: [
|
|
2391
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2392
|
-
{ id: 'member', bounds: { left: 0, top: 0, right: 360, bottom: 100 }, parentId: 'root', clipsChildren: false },
|
|
2393
|
-
{ id: 'member', bounds: { left: 0, top: 200, right: 360, bottom: 300 }, parentId: 'root', clipsChildren: false },
|
|
2394
|
-
// Genuinely misplaced: rendered y 500 vs design y 540.
|
|
2395
|
-
{ id: 'footer', bounds: { left: 0, top: 500, right: 360, bottom: 640 }, parentId: 'root', clipsChildren: false },
|
|
2396
|
-
],
|
|
2397
|
-
};
|
|
2398
|
-
const block = buildStructuralBlock(dump, { member: { x: 0, y: 0, w: 360, h: 100 }, footer: { x: 0, y: 540, w: 360, h: 140 } }, 1, undefined, undefined, { w: 360, h: 640 });
|
|
2399
|
-
expect(block.placement.withheld).toBe(false);
|
|
2400
|
-
expect(block.placement.facts.map((f) => f.elementId)).toEqual(['footer']);
|
|
2401
|
-
});
|
|
2402
|
-
test('the review scaffold rows the collision under the tag with the re-tag remediation', () => {
|
|
2403
|
-
const scaffold = buildReviewScaffold({
|
|
2404
|
-
facts: [{ kind: 'duplicate_tag', tag: 'copy_button', instanceCount: 3 }],
|
|
2405
|
-
});
|
|
2406
|
-
expect(scaffold.mustExplain).toEqual(['copy_button']);
|
|
2407
|
-
expect(scaffold.rows).toHaveLength(1);
|
|
2408
|
-
expect(scaffold.rows[0].element).toBe('copy_button');
|
|
2409
|
-
expect(scaffold.rows[0].fact).toContain('3 nodes');
|
|
2410
|
-
expect(scaffold.rows[0].fact).toContain('unique testTag');
|
|
2411
|
-
});
|
|
2412
|
-
});
|
|
2413
|
-
describe('buildStructuralBlock — text-aware size tolerance (line-box residue)', () => {
|
|
2414
|
-
// JVM (Robolectric) text metrics differ a few dp from Figma's nominal line
|
|
2415
|
-
// boxes, so a text-driven element legitimately renders ±~2dp per laid-out
|
|
2416
|
-
// line. The significance flag (sizeFlag) gets that band — min(8, 2 + 2·L)dp
|
|
2417
|
-
// over the element's subtree line count — while the MEASUREMENT (deltas)
|
|
2418
|
-
// stays exact and non-text elements keep the strict 1dp band (ADR-0004:
|
|
2419
|
-
// strict, not loosen). Fixtures pin the July log cases.
|
|
2420
|
-
const CANVAS_640 = { left: 0, top: 0, right: 360, bottom: 640 };
|
|
2421
|
-
test('the 1-line pill (64×33 rendered vs 60×36 design) records the deltas but does not size-flag', () => {
|
|
2422
|
-
const dump = {
|
|
2423
|
-
canvas: CANVAS_640,
|
|
2424
|
-
nodes: [
|
|
2425
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2426
|
-
{ id: 'pill', bounds: { left: 16, top: 16, right: 80, bottom: 49 }, parentId: 'root', clipsChildren: false },
|
|
2427
|
-
{
|
|
2428
|
-
id: 'pill_label',
|
|
2429
|
-
bounds: { left: 24, top: 24, right: 72, bottom: 41 },
|
|
2430
|
-
parentId: 'pill',
|
|
2431
|
-
clipsChildren: false,
|
|
2432
|
-
text: 'Copy code',
|
|
2433
|
-
textLines: [{ start: 0, end: 9, text: 'Copy code' }],
|
|
2434
|
-
},
|
|
2435
|
-
],
|
|
2436
|
-
};
|
|
2437
|
-
const block = buildStructuralBlock(dump, { pill: { w: 60, h: 36 } }, 1);
|
|
2438
|
-
const pill = block.geometry.elements.find((e) => e.id === 'pill');
|
|
2439
|
-
// Measurement integrity: the deltas are still recorded, exactly.
|
|
2440
|
-
expect(pill.deltas).toEqual([
|
|
2441
|
-
{ dimension: 'w', delta: 4 },
|
|
2442
|
-
{ dimension: 'h', delta: -3 },
|
|
2443
|
-
]);
|
|
2444
|
-
// Significance: within the 1-line text band (±4dp) — not a size divergence.
|
|
2445
|
-
expect(pill.sizeFlag).toBe(false);
|
|
2446
|
-
expect(block.summary.clean).toBe(true);
|
|
2447
|
-
});
|
|
2448
|
-
test('the 3-line card (65dp rendered vs 72 design) is within the line-scaled band', () => {
|
|
2449
|
-
const dump = {
|
|
2450
|
-
canvas: CANVAS_640,
|
|
2451
|
-
nodes: [
|
|
2452
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2453
|
-
{ id: 'card', bounds: { left: 16, top: 16, right: 359, bottom: 81 }, parentId: 'root', clipsChildren: false },
|
|
2454
|
-
{
|
|
2455
|
-
id: 'title',
|
|
2456
|
-
bounds: { left: 24, top: 24, right: 351, bottom: 45 },
|
|
2457
|
-
parentId: 'card',
|
|
2458
|
-
clipsChildren: false,
|
|
2459
|
-
text: 'Fresh picks',
|
|
2460
|
-
textLines: [{ start: 0, end: 11, text: 'Fresh picks' }],
|
|
2461
|
-
},
|
|
2462
|
-
{
|
|
2463
|
-
id: 'subtitle',
|
|
2464
|
-
bounds: { left: 24, top: 49, right: 351, bottom: 73 },
|
|
2465
|
-
parentId: 'card',
|
|
2466
|
-
clipsChildren: false,
|
|
2467
|
-
text: 'Two lines of helper copy wrap here',
|
|
2468
|
-
textLines: [
|
|
2469
|
-
{ start: 0, end: 17, text: 'Two lines of help' },
|
|
2470
|
-
{ start: 18, end: 34, text: 'er copy wrap here' },
|
|
2471
|
-
],
|
|
2472
|
-
},
|
|
2473
|
-
],
|
|
2474
|
-
};
|
|
2475
|
-
const block = buildStructuralBlock(dump, { card: { w: 343, h: 72 } }, 1);
|
|
2476
|
-
const card = block.geometry.elements.find((e) => e.id === 'card');
|
|
2477
|
-
expect(card.deltas).toEqual([{ dimension: 'h', delta: -7 }]);
|
|
2478
|
-
// 3 laid-out lines in the subtree → band min(8, 2 + 2·3) = 8dp ≥ 7.
|
|
2479
|
-
expect(card.sizeFlag).toBe(false);
|
|
2480
|
-
});
|
|
2481
|
-
test('a −2dp helper text node stays unflagged (its own line makes it text-driven)', () => {
|
|
2482
|
-
const dump = {
|
|
2483
|
-
canvas: CANVAS_640,
|
|
2484
|
-
nodes: [
|
|
2485
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2486
|
-
{
|
|
2487
|
-
id: 'helper',
|
|
2488
|
-
bounds: { left: 16, top: 100, right: 136, bottom: 118 },
|
|
2489
|
-
parentId: 'root',
|
|
2490
|
-
clipsChildren: false,
|
|
2491
|
-
text: 'Terms apply',
|
|
2492
|
-
textLines: [{ start: 0, end: 11, text: 'Terms apply' }],
|
|
2493
|
-
},
|
|
2494
|
-
],
|
|
2495
|
-
};
|
|
2496
|
-
const block = buildStructuralBlock(dump, { helper: { w: 120, h: 20 } }, 1);
|
|
2497
|
-
const helper = block.geometry.elements.find((e) => e.id === 'helper');
|
|
2498
|
-
expect(helper.deltas).toEqual([{ dimension: 'h', delta: -2 }]);
|
|
2499
|
-
expect(helper.sizeFlag).toBe(false);
|
|
2500
|
-
});
|
|
2501
|
-
test('the 328 vs 343dp button class still fails — 15dp is no line-box residue', () => {
|
|
2502
|
-
const dump = {
|
|
2503
|
-
canvas: CANVAS_640,
|
|
2504
|
-
nodes: [
|
|
2505
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2506
|
-
{ id: 'button', bounds: { left: 16, top: 500, right: 344, bottom: 548 }, parentId: 'root', clipsChildren: false },
|
|
2507
|
-
{
|
|
2508
|
-
id: 'button_label',
|
|
2509
|
-
bounds: { left: 100, top: 514, right: 260, bottom: 534 },
|
|
2510
|
-
parentId: 'button',
|
|
2511
|
-
clipsChildren: false,
|
|
2512
|
-
text: 'Continue',
|
|
2513
|
-
textLines: [{ start: 0, end: 8, text: 'Continue' }],
|
|
2514
|
-
},
|
|
2515
|
-
],
|
|
2516
|
-
};
|
|
2517
|
-
const block = buildStructuralBlock(dump, { button: { w: 343, h: 48 } }, 1);
|
|
2518
|
-
const button = block.geometry.elements.find((e) => e.id === 'button');
|
|
2519
|
-
expect(button.sizeFlag).toBe(true);
|
|
2520
|
-
expect(block.summary.clean).toBe(false);
|
|
2521
|
-
});
|
|
2522
|
-
test('the text band is capped at 8dp — a 10dp delta on a 5-line block still fails', () => {
|
|
2523
|
-
const dump = {
|
|
2524
|
-
canvas: CANVAS_640,
|
|
2525
|
-
nodes: [
|
|
2526
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2527
|
-
{
|
|
2528
|
-
id: 'paragraph',
|
|
2529
|
-
bounds: { left: 16, top: 16, right: 344, bottom: 126 },
|
|
2530
|
-
parentId: 'root',
|
|
2531
|
-
clipsChildren: false,
|
|
2532
|
-
text: 'five lines of copy wrap',
|
|
2533
|
-
textLines: [
|
|
2534
|
-
{ start: 0, end: 4, text: 'five' },
|
|
2535
|
-
{ start: 5, end: 10, text: 'lines' },
|
|
2536
|
-
{ start: 11, end: 13, text: 'of' },
|
|
2537
|
-
{ start: 14, end: 18, text: 'copy' },
|
|
2538
|
-
{ start: 19, end: 23, text: 'wrap' },
|
|
2539
|
-
],
|
|
2540
|
-
},
|
|
2541
|
-
],
|
|
2542
|
-
};
|
|
2543
|
-
const block = buildStructuralBlock(dump, { paragraph: { w: 328, h: 120 } }, 1);
|
|
2544
|
-
const paragraph = block.geometry.elements.find((e) => e.id === 'paragraph');
|
|
2545
|
-
// min(8, 2 + 2·5) = 8dp < 10dp — the cap keeps large stacks honest.
|
|
2546
|
-
expect(paragraph.sizeFlag).toBe(true);
|
|
2547
|
-
});
|
|
2548
|
-
test('a non-text element keeps the strict band — a 3dp delta still flags', () => {
|
|
2549
|
-
const dump = {
|
|
2550
|
-
canvas: CANVAS_640,
|
|
2551
|
-
nodes: [
|
|
2552
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2553
|
-
{ id: 'thumbnail', bounds: { left: 16, top: 16, right: 119, bottom: 116 }, parentId: 'root', clipsChildren: false },
|
|
2554
|
-
],
|
|
2555
|
-
};
|
|
2556
|
-
const block = buildStructuralBlock(dump, { thumbnail: { w: 100, h: 100 } }, 1);
|
|
2557
|
-
const thumbnail = block.geometry.elements.find((e) => e.id === 'thumbnail');
|
|
2558
|
-
expect(thumbnail.deltas).toEqual([{ dimension: 'w', delta: 3 }]);
|
|
2559
|
-
expect(thumbnail.sizeFlag).toBe(true);
|
|
2560
|
-
});
|
|
2561
|
-
test('an old package (text but no textLines) still counts one line per text node', () => {
|
|
2562
|
-
const dump = {
|
|
2563
|
-
canvas: CANVAS_640,
|
|
2564
|
-
nodes: [
|
|
2565
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2566
|
-
{ id: 'pill', bounds: { left: 16, top: 16, right: 80, bottom: 49 }, parentId: 'root', clipsChildren: false },
|
|
2567
|
-
{
|
|
2568
|
-
id: 'pill_label',
|
|
2569
|
-
bounds: { left: 24, top: 24, right: 72, bottom: 41 },
|
|
2570
|
-
parentId: 'pill',
|
|
2571
|
-
clipsChildren: false,
|
|
2572
|
-
text: 'Copy code',
|
|
2573
|
-
},
|
|
2574
|
-
],
|
|
2575
|
-
};
|
|
2576
|
-
const block = buildStructuralBlock(dump, { pill: { w: 60, h: 36 } }, 1);
|
|
2577
|
-
expect(block.geometry.elements.find((e) => e.id === 'pill').sizeFlag).toBe(false);
|
|
2578
|
-
});
|
|
2579
|
-
});
|
|
2580
|
-
describe('embedded geometry stage parity', () => {
|
|
2581
|
-
// The render ships a JS-source port of the differ + assembler. It must produce
|
|
2582
|
-
// byte-identical blocks to the canonical typed module, or run-side structural
|
|
2583
|
-
// would diverge from preview-side (Invarn) and from the tests.
|
|
2584
|
-
const embedded = getGeometryStageInternals();
|
|
2585
|
-
test('embedded diffKeyGeometry matches the module', () => {
|
|
2586
|
-
const generated = { card: { width: 328, height: 81 } };
|
|
2587
|
-
const reference = { card: { width: 328, height: 72 } };
|
|
2588
|
-
expect(embedded.diffKeyGeometry(generated, reference)).toEqual(diffKeyGeometry(generated, reference));
|
|
2589
|
-
});
|
|
2590
|
-
test('embedded derivePresenceFacts matches the module (Signal C)', () => {
|
|
2591
|
-
const cases = [
|
|
2592
|
-
[['a', 'b', 'c'], ['a', 'b', 'c']],
|
|
2593
|
-
[['banner'], ['banner', 'voucher']],
|
|
2594
|
-
[['banner', 'debugOverlay'], ['banner']],
|
|
2595
|
-
[['c', 'b', 'a'], ['d', 'a', 'b']],
|
|
2596
|
-
[[], ['x', 'y']],
|
|
2597
|
-
[['x', 'y'], []],
|
|
2598
|
-
];
|
|
2599
|
-
for (const [rendered, reference] of cases) {
|
|
2600
|
-
expect(embedded.derivePresenceFacts(rendered, reference)).toEqual(derivePresenceFacts(rendered, reference));
|
|
2601
|
-
}
|
|
2602
|
-
});
|
|
2603
|
-
test('embedded buildStructuralBlock matches the module with an appearanceReference', () => {
|
|
2604
|
-
const appearanceReference = { baseline: 'figma', elementIds: ['banner', 'voucher'] };
|
|
2605
|
-
expect(embedded.buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, HUB_PROMO_REFERENCE, 2, undefined, appearanceReference));
|
|
2606
|
-
});
|
|
2607
|
-
test('embedded buildStructuralBlock matches the module for rendered-only geometry (no reference)', () => {
|
|
2608
|
-
// The description path: authored tags + a fact-named synthetic node, no
|
|
2609
|
-
// reference geometry at all. Both copies must select the same ids and emit
|
|
2610
|
-
// identical rendered-only boxes.
|
|
2611
|
-
const dump = {
|
|
2612
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2613
|
-
nodes: [
|
|
2614
|
-
{ id: 'node-1', bounds: { left: 0, top: 0, right: 200, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
2615
|
-
{ id: 'chip', bounds: { left: 0, top: 0, right: 100, bottom: 40 }, parentId: 'node-1', clipsChildren: false },
|
|
2616
|
-
{ id: 'node-7', bounds: { left: 40, top: 0, right: 140, bottom: 40 }, parentId: 'node-1', clipsChildren: false },
|
|
2617
|
-
],
|
|
2618
|
-
};
|
|
2619
|
-
expect(embedded.buildStructuralBlock(dump, null, 2)).toEqual(buildStructuralBlock(dump, null, 2));
|
|
2620
|
-
expect(embedded.buildStructuralBlock(HUB_PROMO_DUMP, null, 2)).toEqual(buildStructuralBlock(HUB_PROMO_DUMP, null, 2));
|
|
2621
|
-
});
|
|
2622
|
-
test('embedded deriveSpanFacts matches the module (Signal C, issue 16)', () => {
|
|
2623
|
-
const cases = [
|
|
2624
|
-
[{ discountText: [{ start: 4, end: 13, text: '5 EUR off' }] }, { discountText: { text: '5 EUR off' } }],
|
|
2625
|
-
[{ discountText: [{ start: 4, end: 9, text: '5 EUR' }] }, { discountText: { text: '5 EUR off' } }],
|
|
2626
|
-
[{}, { discountText: { text: '5 EUR off' } }],
|
|
2627
|
-
[{ discountText: [{ start: 0, end: 3, text: 'Get' }, { start: 4, end: 9, text: '5 EUR' }] }, { discountText: { text: '5 EUR off' } }],
|
|
2628
|
-
[{ b: [{ start: 0, end: 1, text: 'x' }], a: [{ start: 0, end: 1, text: 'y' }] }, { a: { text: 'right' }, b: { text: 'x' } }],
|
|
2629
|
-
[{ discountText: [{ start: 0, end: 3, text: 'Get' }] }, { discountText: { text: '' } }],
|
|
2630
|
-
];
|
|
2631
|
-
for (const [rendered, reference] of cases) {
|
|
2632
|
-
expect(embedded.deriveSpanFacts(rendered, reference)).toEqual(deriveSpanFacts(rendered, reference));
|
|
2633
|
-
}
|
|
2634
|
-
});
|
|
2635
|
-
test('embedded buildStructuralBlock matches the module with an appearanceReference carrying spans (issue 16)', () => {
|
|
2636
|
-
const dumpWithSpans = {
|
|
2637
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2638
|
-
nodes: [
|
|
2639
|
-
{
|
|
2640
|
-
id: 'discountText',
|
|
2641
|
-
bounds: { left: 0, top: 0, right: 100, bottom: 20 },
|
|
2642
|
-
parentId: null,
|
|
2643
|
-
clipsChildren: false,
|
|
2644
|
-
text: 'Get 5 EUR off',
|
|
2645
|
-
textSpans: [{ start: 4, end: 9, text: '5 EUR', background: '#ffe45e' }],
|
|
2646
|
-
},
|
|
2647
|
-
],
|
|
2648
|
-
};
|
|
2649
|
-
const appearanceReference = {
|
|
2650
|
-
baseline: 'figma',
|
|
2651
|
-
elementIds: ['discountText'],
|
|
2652
|
-
spans: { discountText: { text: '5 EUR off', background: '#ffe45e' } },
|
|
2653
|
-
};
|
|
2654
|
-
expect(embedded.buildStructuralBlock(dumpWithSpans, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithSpans, null, 2, undefined, appearanceReference));
|
|
2655
|
-
});
|
|
2656
|
-
test('embedded buildAppearanceReferenceFromParams matches the module for a spans declaration (issue 16)', () => {
|
|
2657
|
-
const params = {
|
|
2658
|
-
refSpans: { discountText: { text: '5 EUR off', background: '#ffe45e' } },
|
|
2659
|
-
baseline: 'figma',
|
|
2660
|
-
};
|
|
2661
|
-
expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
|
|
2662
|
-
});
|
|
2663
|
-
test('embedded buildAppearanceReferenceFromParams matches the module for a typeface declaration (typeface issue 04)', () => {
|
|
2664
|
-
const cases = [
|
|
2665
|
-
{ refTypeface: { code: { family: 'Poppins', weight: 600 } }, baseline: 'figma' },
|
|
2666
|
-
{
|
|
2667
|
-
refGeom: { code: { w: 100, h: 20 } },
|
|
2668
|
-
refTypeface: { code: { family: 'Poppins', weight: 600 }, label: { family: 'Fira Sans' } },
|
|
2669
|
-
baseline: 'figma',
|
|
2670
|
-
},
|
|
2671
|
-
{ refTypeface: {}, baseline: 'figma' },
|
|
2672
|
-
];
|
|
2673
|
-
for (const params of cases) {
|
|
2674
|
-
expect(embedded.buildAppearanceReferenceFromParams(params)).toEqual(buildAppearanceReferenceFromParams(params));
|
|
2675
|
-
}
|
|
2676
|
-
});
|
|
2677
|
-
test('embedded deriveLineFacts matches the module (Signal C, issue 18)', () => {
|
|
2678
|
-
const cases = [
|
|
2679
|
-
[
|
|
2680
|
-
{ promoCopy: [{ start: 0, end: 13, text: 'Get 5 EUR off' }, { start: 14, end: 33, text: 'placed by tomorrow.' }] },
|
|
2681
|
-
{ promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
2682
|
-
],
|
|
2683
|
-
[
|
|
2684
|
-
{ promoCopy: [{ start: 0, end: 33, text: 'Get 5 EUR off placed by tomorrow.' }] },
|
|
2685
|
-
{ promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
2686
|
-
],
|
|
2687
|
-
[{}, { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] }],
|
|
2688
|
-
[{ promoCopy: [{ start: 0, end: 3, text: 'Get' }] }, { promoCopy: [] }],
|
|
2689
|
-
[
|
|
2690
|
-
{ b: [{ start: 0, end: 1, text: 'x' }], a: [{ start: 0, end: 1, text: 'y' }] },
|
|
2691
|
-
{ a: ['right'], b: ['x'] },
|
|
2692
|
-
],
|
|
2693
|
-
];
|
|
2694
|
-
for (const [rendered, reference] of cases) {
|
|
2695
|
-
expect(embedded.deriveLineFacts(rendered, reference)).toEqual(deriveLineFacts(rendered, reference));
|
|
2696
|
-
}
|
|
2697
|
-
});
|
|
2698
|
-
test('embedded buildStructuralBlock matches the module with an appearanceReference carrying lines (issue 18)', () => {
|
|
2699
|
-
const dumpWithLines = {
|
|
2700
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2701
|
-
nodes: [
|
|
2702
|
-
{
|
|
2703
|
-
id: 'promoCopy',
|
|
2704
|
-
bounds: { left: 0, top: 0, right: 100, bottom: 20 },
|
|
2705
|
-
parentId: null,
|
|
2706
|
-
clipsChildren: false,
|
|
2707
|
-
text: 'Get 5 EUR off placed by tomorrow.',
|
|
2708
|
-
textLines: [{ start: 0, end: 33, text: 'Get 5 EUR off placed by tomorrow.' }],
|
|
2709
|
-
},
|
|
2710
|
-
],
|
|
2711
|
-
};
|
|
2712
|
-
const appearanceReference = {
|
|
2713
|
-
baseline: 'figma',
|
|
2714
|
-
elementIds: ['promoCopy'],
|
|
2715
|
-
lines: { promoCopy: ['Get 5 EUR off', 'placed by tomorrow.'] },
|
|
2716
|
-
};
|
|
2717
|
-
expect(embedded.buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithLines, null, 2, undefined, appearanceReference));
|
|
2718
|
-
});
|
|
2719
|
-
test('embedded deriveTypefaceFacts matches the module (Signal C, issue 03)', () => {
|
|
2720
|
-
const cases = [
|
|
2721
|
-
[{ code: { family: 'Roboto', weight: 400 } }, { code: { family: 'Poppins', weight: 600 } }],
|
|
2722
|
-
[{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins', weight: 600 } }],
|
|
2723
|
-
[{ code: { family: 'Poppins', weight: 600 } }, { code: { family: 'Poppins-SemiBold' } }],
|
|
2724
|
-
[{ code: { family: 'fira_sans_medium' } }, { code: { family: 'Fira Sans', weight: 500 } }],
|
|
2725
|
-
[{ code: { family: 'Poppins', weight: 400 } }, { code: { family: 'Poppins-SemiBold' } }],
|
|
2726
|
-
[{}, { code: { family: 'Poppins', weight: 600 } }],
|
|
2727
|
-
[{ code: { family: 'Roboto', weight: 400 } }, {}],
|
|
2728
|
-
[
|
|
2729
|
-
{ b: { family: 'Roboto', weight: 400 }, a: { family: 'Arial', weight: 400 } },
|
|
2730
|
-
{ a: { family: 'Poppins', weight: 600 }, b: { family: 'Roboto', weight: 400 } },
|
|
2731
|
-
],
|
|
2732
|
-
];
|
|
2733
|
-
for (const [rendered, reference] of cases) {
|
|
2734
|
-
expect(embedded.deriveTypefaceFacts(rendered, reference)).toEqual(deriveTypefaceFacts(rendered, reference));
|
|
2735
|
-
}
|
|
2736
|
-
});
|
|
2737
|
-
test('embedded buildStructuralBlock matches the module with a typeface reference + the render-SDK gate (issue 03)', () => {
|
|
2738
|
-
const dumps = [
|
|
2739
|
-
{
|
|
2740
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2741
|
-
renderSdk: 34,
|
|
2742
|
-
nodes: [
|
|
2743
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50', resolvedTypeface: { family: 'Roboto', weight: 400 } },
|
|
2744
|
-
],
|
|
2745
|
-
},
|
|
2746
|
-
{
|
|
2747
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2748
|
-
renderSdk: 28,
|
|
2749
|
-
nodes: [
|
|
2750
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: 'INV-50' },
|
|
2751
|
-
],
|
|
2752
|
-
},
|
|
2753
|
-
{
|
|
2754
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2755
|
-
nodes: [
|
|
2756
|
-
{ id: 'code', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false },
|
|
2757
|
-
],
|
|
2758
|
-
},
|
|
2759
|
-
];
|
|
2760
|
-
const appearanceReference = {
|
|
2761
|
-
baseline: 'figma',
|
|
2762
|
-
elementIds: ['code'],
|
|
2763
|
-
typeface: { code: { family: 'Poppins', weight: 600 } },
|
|
2764
|
-
};
|
|
2765
|
-
for (const dump of dumps) {
|
|
2766
|
-
expect(embedded.buildStructuralBlock(dump, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dump, null, 2, undefined, appearanceReference));
|
|
2767
|
-
}
|
|
2768
|
-
});
|
|
2769
|
-
test('embedded deriveCopyFacts matches the module (Signal C)', () => {
|
|
2770
|
-
const cases = [
|
|
2771
|
-
[{ title: 'a' }, { title: 'a' }],
|
|
2772
|
-
[{ title: 'a' }, { title: 'b' }],
|
|
2773
|
-
[{}, { cta: 'Shop now' }],
|
|
2774
|
-
[{ cta: '' }, { cta: 'Shop now' }],
|
|
2775
|
-
[{ debugLabel: 'x' }, {}],
|
|
2776
|
-
[{ note: 'hi' }, { note: '' }],
|
|
2777
|
-
[{ b: 'r', a: 'wrong' }, { a: 'right', b: 'r', c: 'gone' }],
|
|
2778
|
-
];
|
|
2779
|
-
for (const [rendered, reference] of cases) {
|
|
2780
|
-
expect(embedded.deriveCopyFacts(rendered, reference)).toEqual(deriveCopyFacts(rendered, reference));
|
|
2781
|
-
}
|
|
2782
|
-
});
|
|
2783
|
-
test('embedded buildStructuralBlock matches the module with an appearanceReference carrying text', () => {
|
|
2784
|
-
const dumpWithText = {
|
|
2785
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2786
|
-
nodes: [
|
|
2787
|
-
{ id: 'title', bounds: { left: 0, top: 0, right: 100, bottom: 20 }, parentId: null, clipsChildren: false, text: '5 EUR' },
|
|
2788
|
-
{ id: 'cta', bounds: { left: 0, top: 30, right: 100, bottom: 50 }, parentId: null, clipsChildren: false },
|
|
2789
|
-
],
|
|
2790
|
-
};
|
|
2791
|
-
const appearanceReference = {
|
|
2792
|
-
baseline: 'figma',
|
|
2793
|
-
elementIds: ['title', 'cta'],
|
|
2794
|
-
text: { title: '5 EUR off', cta: 'Shop now' },
|
|
2795
|
-
};
|
|
2796
|
-
expect(embedded.buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference));
|
|
2797
|
-
});
|
|
2798
|
-
test('embedded ciede2000 + hexToLab match the module (Signal C)', () => {
|
|
2799
|
-
const labCases = [
|
|
2800
|
-
[[50, 2.6772, -79.7751], [50, 0, -82.7485]],
|
|
2801
|
-
[[50, 2.5, 0], [73, 25, -18]],
|
|
2802
|
-
[[60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387]],
|
|
2803
|
-
];
|
|
2804
|
-
for (const [a, b] of labCases) {
|
|
2805
|
-
expect(embedded.ciede2000(a, b)).toBeCloseTo(ciede2000(a, b), 12);
|
|
2806
|
-
}
|
|
2807
|
-
for (const hex of ['#ffffff', '#000000', '#ff0000', '#3a7bd5', 'abcdef']) {
|
|
2808
|
-
expect(embedded.hexToLab(hex)).toEqual(hexToLab(hex));
|
|
2809
|
-
}
|
|
2810
|
-
});
|
|
2811
|
-
test('embedded buildAppearanceReferenceFromParams matches the module (Signal C, issue 05)', () => {
|
|
2812
|
-
const cases = [
|
|
2813
|
-
{ refColor: { banner: { fill: '#fff0ee' } }, baseline: 'previous_render' },
|
|
2814
|
-
{ refGeom: { banner: { w: 343, h: 96 }, voucher: { w: 81, h: 81 } }, baseline: 'figma' },
|
|
2815
|
-
{ refGeom: { banner: { w: 343 } }, refColor: { banner: { fill: '#fff0ee' } }, baseline: 'figma' },
|
|
2816
|
-
{ refText: { copy: 'Get 5 € off' }, refColor: { banner: { fill: '#fff0ee' } }, baseline: 'previous_render' },
|
|
2817
|
-
{ refGeom: { banner: { w: 1 } }, refText: { copy: 'x' }, refColor: { logo: { fill: '#000000' } }, baseline: 'figma' },
|
|
2818
|
-
{ baseline: 'figma' },
|
|
2819
|
-
{ refGeom: {}, refText: {}, refColor: {}, baseline: 'figma' },
|
|
2820
|
-
];
|
|
2821
|
-
for (const c of cases) {
|
|
2822
|
-
expect(embedded.buildAppearanceReferenceFromParams(c)).toEqual(buildAppearanceReferenceFromParams(c));
|
|
2823
|
-
}
|
|
2824
|
-
});
|
|
2825
|
-
test('embedded deriveColorFacts matches the module (Signal C)', () => {
|
|
2826
|
-
const cases = [
|
|
2827
|
-
[{ card: { fill: '#ff0000' } }, { card: { fill: '#ff0000' } }],
|
|
2828
|
-
[{ card: { fill: '#ff0000' } }, { card: { fill: '#00ff00' } }],
|
|
2829
|
-
[{ card: { fill: '#ffffff', text: '#111111' } }, { card: { fill: '#ffffff', text: '#000000' } }],
|
|
2830
|
-
[{ card: {} }, { card: { fill: '#ff0000' } }],
|
|
2831
|
-
[{ extra: { fill: '#ff0000' } }, {}],
|
|
2832
|
-
];
|
|
2833
|
-
for (const [rendered, reference] of cases) {
|
|
2834
|
-
expect(embedded.deriveColorFacts(rendered, reference)).toEqual(deriveColorFacts(rendered, reference));
|
|
2835
|
-
}
|
|
2836
|
-
});
|
|
2837
|
-
test('embedded buildStructuralBlock matches the module with an appearanceReference carrying color', () => {
|
|
2838
|
-
const dumpWithColor = {
|
|
2839
|
-
canvas: { left: 0, top: 0, right: 200, bottom: 400 },
|
|
2840
|
-
nodes: [
|
|
2841
|
-
{ id: 'card', bounds: { left: 0, top: 0, right: 100, bottom: 40 }, parentId: null, clipsChildren: false, fillColor: '#ff0000', textColor: '#000000' },
|
|
2842
|
-
],
|
|
2843
|
-
};
|
|
2844
|
-
const appearanceReference = {
|
|
2845
|
-
baseline: 'figma',
|
|
2846
|
-
elementIds: ['card'],
|
|
2847
|
-
color: { card: { fill: '#00ff00', text: '#000000' } },
|
|
2848
|
-
};
|
|
2849
|
-
expect(embedded.buildStructuralBlock(dumpWithColor, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithColor, null, 2, undefined, appearanceReference));
|
|
2850
|
-
});
|
|
2851
|
-
test('embedded buildStructuralBlock matches the module for a text-only color drift (issue 02)', () => {
|
|
2852
|
-
// Correct fill + wrong text: the embedded port must emit the same single
|
|
2853
|
-
// text-channel fact (no collapse), matching the typed module exactly.
|
|
2854
|
-
const dumpWithText = {
|
|
2855
|
-
canvas: { left: 0, top: 0, right: 343, bottom: 72 },
|
|
2856
|
-
nodes: [
|
|
2857
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 343, bottom: 72 }, parentId: null, clipsChildren: false, fillColor: '#fff0ee', textColor: '#1e88e5' },
|
|
2858
|
-
],
|
|
2859
|
-
};
|
|
2860
|
-
const appearanceReference = {
|
|
2861
|
-
baseline: 'previous_render',
|
|
2862
|
-
elementIds: ['banner'],
|
|
2863
|
-
color: { banner: { fill: '#fff0ee', text: '#1b1b1b' } },
|
|
2864
|
-
};
|
|
2865
|
-
expect(embedded.buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference)).toEqual(buildStructuralBlock(dumpWithText, null, 2, undefined, appearanceReference));
|
|
2866
|
-
});
|
|
2867
|
-
// Duplicated banner tag + a child declared in the reference: exercises the
|
|
2868
|
-
// collision guard end-to-end (duplicate_tag fact, geometry skip, overhang
|
|
2869
|
-
// withheld against the duplicated frame, scaffold row).
|
|
2870
|
-
const DUPLICATE_TAG_DUMP = {
|
|
2871
|
-
canvas: { left: 0, top: 0, right: 720, bottom: 800 },
|
|
2872
|
-
nodes: [
|
|
2873
|
-
{ id: 'banner', bounds: { left: 0, top: 0, right: 686, bottom: 144 }, parentId: null, clipsChildren: false },
|
|
2874
|
-
{ id: 'banner', bounds: { left: 0, top: 200, right: 686, bottom: 344 }, parentId: null, clipsChildren: false },
|
|
2875
|
-
{ id: 'coin', bounds: { left: 600, top: 80, right: 680, bottom: 180 }, parentId: 'banner', clipsChildren: false },
|
|
2876
|
-
],
|
|
2877
|
-
};
|
|
2878
|
-
const DUPLICATE_TAG_REF = {
|
|
2879
|
-
banner: { x: 0, y: 0, w: 343, h: 72 },
|
|
2880
|
-
coin: { x: 300, y: 40, w: 40, h: 50 },
|
|
2881
|
-
};
|
|
2882
|
-
// A text-driven pill inside the band (density 2: px are 2× the dp numbers)
|
|
2883
|
-
// plus a non-text sibling outside it — exercises the text-aware size band.
|
|
2884
|
-
const TEXT_BAND_DUMP = {
|
|
2885
|
-
canvas: { left: 0, top: 0, right: 720, bottom: 400 },
|
|
2886
|
-
nodes: [
|
|
2887
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 720, bottom: 400 }, parentId: null, clipsChildren: false },
|
|
2888
|
-
{ id: 'pill', bounds: { left: 32, top: 32, right: 160, bottom: 98 }, parentId: 'root', clipsChildren: false },
|
|
2889
|
-
{
|
|
2890
|
-
id: 'pill_label',
|
|
2891
|
-
bounds: { left: 48, top: 48, right: 144, bottom: 82 },
|
|
2892
|
-
parentId: 'pill',
|
|
2893
|
-
clipsChildren: false,
|
|
2894
|
-
text: 'Copy code',
|
|
2895
|
-
textLines: [{ start: 0, end: 9, text: 'Copy code' }],
|
|
2896
|
-
},
|
|
2897
|
-
{ id: 'thumbnail', bounds: { left: 200, top: 32, right: 406, bottom: 232 }, parentId: 'root', clipsChildren: false },
|
|
2898
|
-
],
|
|
2899
|
-
};
|
|
2900
|
-
const TEXT_BAND_REF = { pill: { w: 60, h: 36 }, thumbnail: { w: 100, h: 100 } };
|
|
2901
|
-
const parityCases = [
|
|
2902
|
-
{ name: 'factsOnly', dump: HUB_PROMO_DUMP, ref: null },
|
|
2903
|
-
{ name: 'withGeometry', dump: HUB_PROMO_DUMP, ref: HUB_PROMO_REFERENCE },
|
|
2904
|
-
{ name: 'overhangLoggedIn', dump: OVERHANG_LOGGED_IN_DUMP, ref: OVERHANG_LOGGED_IN_REF },
|
|
2905
|
-
{ name: 'overhangGuest', dump: OVERHANG_GUEST_DUMP, ref: OVERHANG_GUEST_REF },
|
|
2906
|
-
{ name: 'overhangPosition', dump: OVERHANG_POSITION_DUMP, ref: OVERHANG_POSITION_REF },
|
|
2907
|
-
{ name: 'overhangMissing', dump: OVERHANG_MISSING_DUMP, ref: OVERHANG_MISSING_REF },
|
|
2908
|
-
{ name: 'duplicateTag', dump: DUPLICATE_TAG_DUMP, ref: DUPLICATE_TAG_REF },
|
|
2909
|
-
{ name: 'textSizeBand', dump: TEXT_BAND_DUMP, ref: TEXT_BAND_REF },
|
|
2910
|
-
];
|
|
2911
|
-
for (const { name, dump, ref } of parityCases) {
|
|
2912
|
-
test(`embedded buildStructuralBlock matches the module (${name})`, () => {
|
|
2913
|
-
expect(embedded.buildStructuralBlock(dump, ref, 2)).toEqual(buildStructuralBlock(dump, ref, 2));
|
|
2914
|
-
});
|
|
2915
|
-
test(`embedded buildReviewScaffold matches the module (${name})`, () => {
|
|
2916
|
-
const block = buildStructuralBlock(dump, ref, 2);
|
|
2917
|
-
expect(embedded.buildReviewScaffold(block)).toEqual(buildReviewScaffold(block));
|
|
2918
|
-
});
|
|
2919
|
-
}
|
|
2920
|
-
test('embedded buildStructuralBlock matches the module for a duplicated member tag under a declared frame', () => {
|
|
2921
|
-
const dump = {
|
|
2922
|
-
canvas: { left: 0, top: 0, right: 360, bottom: 640 },
|
|
2923
|
-
nodes: [
|
|
2924
|
-
{ id: 'root', bounds: { left: 0, top: 0, right: 360, bottom: 640 }, parentId: null, clipsChildren: false },
|
|
2925
|
-
{ id: 'member', bounds: { left: 0, top: 0, right: 360, bottom: 100 }, parentId: 'root', clipsChildren: false },
|
|
2926
|
-
{ id: 'member', bounds: { left: 0, top: 200, right: 360, bottom: 300 }, parentId: 'root', clipsChildren: false },
|
|
2927
|
-
{ id: 'footer', bounds: { left: 0, top: 500, right: 360, bottom: 640 }, parentId: 'root', clipsChildren: false },
|
|
2928
|
-
],
|
|
2929
|
-
};
|
|
2930
|
-
const ref = { member: { x: 0, y: 0, w: 360, h: 100 }, footer: { x: 0, y: 540, w: 360, h: 140 } };
|
|
2931
|
-
const frame = { w: 360, h: 640 };
|
|
2932
|
-
expect(embedded.buildStructuralBlock(dump, ref, 1, undefined, undefined, frame)).toEqual(buildStructuralBlock(dump, ref, 1, undefined, undefined, frame));
|
|
2933
|
-
});
|
|
2934
|
-
});
|
|
2935
|
-
// ─── Border facts (property-recognizer 01) ───────────────────────────────────
|
|
2936
|
-
// The runner half of the comparison that finally makes a missing ring visible.
|
|
2937
|
-
// These lock the TYPED copy; the ES5 runner twin is exercised through the
|
|
2938
|
-
// generated-script tests, and the two must not drift — patching one only is a
|
|
2939
|
-
// silent asymmetry between the local preview and the real gate.
|
|
2940
|
-
describe('deriveBorderFacts — the design stroke vs the rendered border', () => {
|
|
2941
|
-
const REF = { badge: { fill: '#3ca000', stroke: '#ffffff', strokeWeight: 4 } };
|
|
2942
|
-
test('THE motivating case: a declared ring the render never drew', () => {
|
|
2943
|
-
expect(deriveBorderFacts({ badge: { fill: '#3ca000' } }, REF, true)).toEqual([
|
|
2944
|
-
{ kind: 'border_missing', elementId: 'badge', reference: '#ffffff', referenceWidth: 4 },
|
|
2945
|
-
]);
|
|
2946
|
-
});
|
|
2947
|
-
test('a border-blind dump states NOTHING — silence is "cannot see", not "absent"', () => {
|
|
2948
|
-
// Without this gate every stroked design in the catalogue gains a finding on
|
|
2949
|
-
// the strength of a blindness. The caller raises a not-checked advisory.
|
|
2950
|
-
expect(deriveBorderFacts({ badge: { fill: '#3ca000' } }, REF, false)).toEqual([]);
|
|
2951
|
-
});
|
|
2952
|
-
test('a ring in the wrong colour is a border fact', () => {
|
|
2953
|
-
expect(deriveBorderFacts({ badge: { stroke: '#cccccc', strokeWeight: 4 } }, REF, true)).toEqual([{ kind: 'border', elementId: 'badge', rendered: '#cccccc', reference: '#ffffff' }]);
|
|
2954
|
-
});
|
|
2955
|
-
test('a ring of the right colour and the wrong thickness is its own fact', () => {
|
|
2956
|
-
expect(deriveBorderFacts({ badge: { stroke: '#ffffff', strokeWeight: 1 } }, REF, true)).toEqual([{ kind: 'border_width', elementId: 'badge', rendered: 1, reference: 4 }]);
|
|
2957
|
-
});
|
|
2958
|
-
test('hex case never matters, and a matching ring yields nothing', () => {
|
|
2959
|
-
expect(deriveBorderFacts({ badge: { stroke: '#FFFFFF', strokeWeight: 4 } }, REF, true)).toEqual([]);
|
|
2960
|
-
});
|
|
2961
|
-
test('sub-half-dp width differences are the same ring', () => {
|
|
2962
|
-
expect(deriveBorderFacts({ badge: { stroke: '#ffffff', strokeWeight: 4.4 } }, REF, true)).toEqual([]);
|
|
2963
|
-
});
|
|
2964
|
-
test('a design with no declared stroke says nothing about a rendered ring', () => {
|
|
2965
|
-
// The design is the only side that can raise the question — the same
|
|
2966
|
-
// asymmetry alpha uses. A wrapper adding an unasked-for ring is not a defect
|
|
2967
|
-
// this comparison can name.
|
|
2968
|
-
expect(deriveBorderFacts({ badge: { stroke: '#ffffff', strokeWeight: 4 } }, { badge: { fill: '#3ca000' } }, true)).toEqual([]);
|
|
2969
|
-
});
|
|
2970
|
-
test('an undeclared design WIDTH grades colour only, never thickness', () => {
|
|
2971
|
-
expect(deriveBorderFacts({ badge: { stroke: '#ffffff', strokeWeight: 9 } }, { badge: { stroke: '#ffffff' } }, true)).toEqual([]);
|
|
2972
|
-
// ...and a missing ring still states the colour, without inventing a width.
|
|
2973
|
-
expect(deriveBorderFacts({}, { badge: { stroke: '#ffffff' } }, true)).toEqual([
|
|
2974
|
-
{ kind: 'border_missing', elementId: 'badge', reference: '#ffffff' },
|
|
2975
|
-
]);
|
|
2976
|
-
});
|
|
2977
|
-
});
|
|
2978
|
-
describe('buildStructuralBlock — sub-dp rendered boxes', () => {
|
|
2979
|
-
// The rendered box persists at 2-decimal precision instead of whole dp: the
|
|
2980
|
-
// paint is sub-dp, and quantizing the box moved it up to half a dp off the
|
|
2981
|
-
// pixels it describes — visibly mis-wrapped once evidence is magnified. The
|
|
2982
|
-
// COMPARISON layer does not move: deltas were always computed from raw
|
|
2983
|
-
// values and persisted integer-rounded, and the flag bands read raw deltas.
|
|
2984
|
-
const FRACTIONAL_DUMP = {
|
|
2985
|
-
canvas: { left: 0, top: 0, right: 1125, bottom: 2436 },
|
|
2986
|
-
nodes: [
|
|
2987
|
-
{
|
|
2988
|
-
id: 'node-1',
|
|
2989
|
-
bounds: { left: 0, top: 0, right: 1125, bottom: 2436 },
|
|
2990
|
-
unclippedBounds: { left: 0, top: 0, right: 1125, bottom: 2436 },
|
|
2991
|
-
parentId: null,
|
|
2992
|
-
clipsChildren: false,
|
|
2993
|
-
},
|
|
2994
|
-
// ÷3 density: repeating decimals, so the 2-decimal clamp is load-bearing
|
|
2995
|
-
// (133.666… must persist as 133.67, never 133.66666666666666).
|
|
2996
|
-
{
|
|
2997
|
-
id: 'dot',
|
|
2998
|
-
bounds: { left: 401, top: 641, right: 426, bottom: 666 },
|
|
2999
|
-
unclippedBounds: { left: 401, top: 641, right: 426, bottom: 666 },
|
|
3000
|
-
parentId: 'node-1',
|
|
3001
|
-
clipsChildren: false,
|
|
3002
|
-
},
|
|
3003
|
-
{
|
|
3004
|
-
id: 'halo',
|
|
3005
|
-
bounds: { left: 300, top: 500, right: 400, bottom: 600 },
|
|
3006
|
-
unclippedBounds: { left: 300, top: 500, right: 400, bottom: 600 },
|
|
3007
|
-
parentId: 'node-1',
|
|
3008
|
-
clipsChildren: false,
|
|
3009
|
-
},
|
|
3010
|
-
],
|
|
3011
|
-
};
|
|
3012
|
-
const FRACTIONAL_REFERENCE = { dot: { w: 8, h: 8 } };
|
|
3013
|
-
test('persists the compared box at 2 decimals while deltas and flags stay whole-dp', () => {
|
|
3014
|
-
const block = buildStructuralBlock(FRACTIONAL_DUMP, FRACTIONAL_REFERENCE, 3);
|
|
3015
|
-
expect(block.geometry?.elements.find((e) => e.id === 'dot')).toEqual({
|
|
3016
|
-
id: 'dot',
|
|
3017
|
-
rendered: { w: 8.33, h: 8.33, x: 133.67, y: 213.67 },
|
|
3018
|
-
design: { w: 8, h: 8 },
|
|
3019
|
-
// The raw 0.33dp delta rounds to 0 — no delta emitted, no flag, exactly
|
|
3020
|
-
// as the whole-dp era persisted it.
|
|
3021
|
-
deltas: [],
|
|
3022
|
-
sizeFlag: false,
|
|
3023
|
-
ancestors: ['node-1'],
|
|
3024
|
-
});
|
|
3025
|
-
expect(block.summary.clean).toBe(true);
|
|
3026
|
-
});
|
|
3027
|
-
test('persists described-only (rendered-only) boxes at the same precision', () => {
|
|
3028
|
-
const block = buildStructuralBlock(FRACTIONAL_DUMP, FRACTIONAL_REFERENCE, 3);
|
|
3029
|
-
expect(block.geometry?.elements.find((e) => e.id === 'halo')).toEqual({
|
|
3030
|
-
id: 'halo',
|
|
3031
|
-
rendered: { w: 33.33, h: 33.33, x: 100, y: 166.67 },
|
|
3032
|
-
});
|
|
3033
|
-
});
|
|
3034
|
-
test('embedded stage matches the module for sub-dp rendered boxes (parity)', () => {
|
|
3035
|
-
const embedded = getGeometryStageInternals();
|
|
3036
|
-
expect(embedded.buildStructuralBlock(FRACTIONAL_DUMP, FRACTIONAL_REFERENCE, 3)).toEqual(buildStructuralBlock(FRACTIONAL_DUMP, FRACTIONAL_REFERENCE, 3));
|
|
3037
|
-
});
|
|
3038
|
-
});
|
|
3039
|
-
//# sourceMappingURL=structural-facts.test.js.map
|