mirror of
https://github.com/langgenius/dify.git
synced 2026-02-11 19:14:00 +00:00
Compare commits
9 Commits
test/billi
...
test/integ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af1c4ffbfb | ||
|
|
7058f90fd1 | ||
|
|
cebf141525 | ||
|
|
5b4c7b2a40 | ||
|
|
faefb98746 | ||
|
|
378a1d7d08 | ||
|
|
ce0192620d | ||
|
|
e9feeedc01 | ||
|
|
e32490f54e |
@@ -23,7 +23,7 @@ dependencies = [
|
||||
"gevent~=25.9.1",
|
||||
"gmpy2~=2.2.1",
|
||||
"google-api-core==2.18.0",
|
||||
"google-api-python-client==2.90.0",
|
||||
"google-api-python-client==2.189.0",
|
||||
"google-auth==2.29.0",
|
||||
"google-auth-httplib2==0.2.0",
|
||||
"google-cloud-aiplatform==1.49.0",
|
||||
|
||||
82
api/uv.lock
generated
82
api/uv.lock
generated
@@ -1237,49 +1237,47 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1594,7 +1592,7 @@ requires-dist = [
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.2.1" },
|
||||
{ name = "google-api-core", specifier = "==2.18.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.90.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.189.0" },
|
||||
{ name = "google-auth", specifier = "==2.29.0" },
|
||||
{ name = "google-auth-httplib2", specifier = "==0.2.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = "==1.49.0" },
|
||||
@@ -2306,7 +2304,7 @@ grpc = [
|
||||
|
||||
[[package]]
|
||||
name = "google-api-python-client"
|
||||
version = "2.90.0"
|
||||
version = "2.189.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2315,9 +2313,9 @@ dependencies = [
|
||||
{ name = "httplib2" },
|
||||
{ name = "uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/8b/d990f947c261304a5c1599d45717d02c27d46af5f23e1fee5dc19c8fa79d/google-api-python-client-2.90.0.tar.gz", hash = "sha256:cbcb3ba8be37c6806676a49df16ac412077e5e5dc7fa967941eff977b31fba03", size = 10891311, upload-time = "2023-06-20T16:29:25.008Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/f8/0783aeca3410ee053d4dd1fccafd85197847b8f84dd038e036634605d083/google_api_python_client-2.189.0.tar.gz", hash = "sha256:45f2d8559b5c895dde6ad3fb33de025f5cb2c197fa5862f18df7f5295a172741", size = 13979470, upload-time = "2026-02-03T19:24:55.432Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/03/209b5c36a621ae644dc7d4743746cd3b38b18e133f8779ecaf6b95cc01ce/google_api_python_client-2.90.0-py2.py3-none-any.whl", hash = "sha256:4a41ffb7797d4f28e44635fb1e7076240b741c6493e7c3233c0e4421cec7c913", size = 11379891, upload-time = "2023-06-20T16:29:19.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/44/3677ff27998214f2fa7957359da48da378a0ffff1bd0bdaba42e752bc13e/google_api_python_client-2.189.0-py3-none-any.whl", hash = "sha256:a258c09660a49c6159173f8bbece171278e917e104a11f0640b34751b79c8a1a", size = 14547633, upload-time = "2026-02-03T19:24:52.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,991 +0,0 @@
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import Billing from '@/app/components/billing/billing-page'
|
||||
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import HeaderBillingBtn from '@/app/components/billing/header-billing-btn'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
|
||||
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ──────────────────────────────────────────────────────────
|
||||
const mockRefetch = vi.fn().mockResolvedValue({ data: 'https://billing.example.com' })
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useBindPartnerStackInfo: () => ({ mutateAsync: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
const mockRouterPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow }: { isShow: boolean }) =>
|
||||
isShow ? <div data-testid="verify-state-modal" /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/utils/util', () => ({
|
||||
mailToSupport: () => 'mailto:support@test.com',
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Vitest hoists vi.mock() calls, so imports above will use mocked modules
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 1. Billing Page + Plan Component Integration
|
||||
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Billing Page + Plan Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Verify that the billing page renders PlanComp with all 7 usage items
|
||||
describe('Rendering complete plan information', () => {
|
||||
it('should display all 7 usage metrics for sandbox plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 3,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 10,
|
||||
vectorSpace: 20,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 1000,
|
||||
apiRateLimit: 2000,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
// Plan name
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
|
||||
// All 7 usage items should be visible
|
||||
expect(screen.getByText(/usagePage\.buildApps/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.teamMembers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.documentsUploadQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.annotationQuota/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/usagePage\.triggerEvents/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.apiRateLimit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display usage values as "usage / total" format', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 3, teamMembers: 1 },
|
||||
total: { buildApps: 5, teamMembers: 1 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Check that the buildApps usage fraction "3 / 5" is rendered
|
||||
const usageContainers = screen.getAllByText('3')
|
||||
expect(usageContainers.length).toBeGreaterThan(0)
|
||||
const totalContainers = screen.getAllByText('5')
|
||||
expect(totalContainers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { apiRateLimit: NUM_INFINITE },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display reset days for trigger events when applicable', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 7 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Reset text should be visible
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: true })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when user is not workspace manager', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 2. Plan Type Display Integration
|
||||
// Tests that different plan types render correct visual elements
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Plan Type Display Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render sandbox plan with upgrade button (premium badge)', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.sandbox\.for/i)).toBeInTheDocument()
|
||||
// Sandbox shows premium badge upgrade button (not plain)
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render professional plan with plain upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
// Professional shows plain button because it's not team
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render team plan with plain-style upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
// Team plan has isPlain=true, so shows "upgradeBtn.plain" text
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render upgrade button for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 3. Upgrade Flow Integration
|
||||
// Tests the flow: UpgradeBtn click → setShowPricingModal
|
||||
// and PlanUpgradeModal → close + trigger pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Upgrade Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
})
|
||||
|
||||
// UpgradeBtn triggers pricing modal
|
||||
describe('UpgradeBtn triggers pricing modal', () => {
|
||||
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
|
||||
const customOnClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn onClick={customOnClick} />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(customOnClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire gtag event with loc parameter when clicked', async () => {
|
||||
const mockGtag = vi.fn()
|
||||
;(window as unknown as Record<string, unknown>).gtag = mockGtag
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UpgradeBtn loc="billing-page" />)
|
||||
|
||||
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
|
||||
await user.click(badgeText)
|
||||
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { loc: 'billing-page' })
|
||||
delete (window as unknown as Record<string, unknown>).gtag
|
||||
})
|
||||
})
|
||||
|
||||
// PlanUpgradeModal integration: close modal and trigger pricing
|
||||
describe('PlanUpgradeModal upgrade flow', () => {
|
||||
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Upgrade Required"
|
||||
description="You need a better plan"
|
||||
/>,
|
||||
)
|
||||
|
||||
// The modal should show title and description
|
||||
expect(screen.getByText('Upgrade Required')).toBeInTheDocument()
|
||||
expect(screen.getByText('You need a better plan')).toBeInTheDocument()
|
||||
|
||||
// Click the upgrade button inside the modal
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
// Should close the current modal first
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Then open pricing modal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose and custom onUpgrade when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
// Custom onUpgrade replaces default setShowPricingModal
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when clicking dismiss button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<PlanUpgradeModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
title="Test"
|
||||
description="Test"
|
||||
/>,
|
||||
)
|
||||
|
||||
const dismissBtn = screen.getByText(/triggerLimitModal\.dismiss/i)
|
||||
await user.click(dismissBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Upgrade from PlanComp: clicking upgrade button in plan component triggers pricing
|
||||
describe('PlanComp upgrade button triggers pricing', () => {
|
||||
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PlanComp loc="test-loc" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 4. Capacity Full Components Integration
|
||||
// Tests AppsFull, VectorSpaceFull, AnnotationFull, TriggerEventsLimitModal
|
||||
// with real child components (UsageInfo, ProgressBar, UpgradeBtn)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Capacity Full Components Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// AppsFull renders with correct messaging and components
|
||||
describe('AppsFull integration', () => {
|
||||
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Should show "full" tip
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have a progress bar rendered
|
||||
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { buildApps: 48 },
|
||||
total: { buildApps: 50 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
expect(screen.getByText(/apps\.fullTip1$/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display contact tip and contact button for team plan', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.team,
|
||||
usage: { buildApps: 200 },
|
||||
total: { buildApps: 200 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
// Team plan shows different tip
|
||||
expect(screen.getByText(/apps\.fullTip2$/i)).toBeInTheDocument()
|
||||
// Team plan shows "Contact Us" instead of upgrade
|
||||
expect(screen.getByText(/apps\.contactUs/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render progress bar with correct color based on usage percentage', () => {
|
||||
// 100% usage should show error color
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
|
||||
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
|
||||
describe('VectorSpaceFull integration', () => {
|
||||
it('should display full tip, upgrade button, and vector space usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
// Should show full tip
|
||||
expect(screen.getByText(/vectorSpace\.fullTip/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/vectorSpace\.fullSolution/i)).toBeInTheDocument()
|
||||
// Should show upgrade button
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Should show vector space usage info
|
||||
expect(screen.getByText(/usagePage\.vectorSpace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFull renders with Usage component and UpgradeBtn
|
||||
describe('AnnotationFull integration', () => {
|
||||
it('should display annotation full tip, upgrade button, and usage info', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine2/i)).toBeInTheDocument()
|
||||
// UpgradeBtn rendered
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
// Usage component should show annotation quota
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AnnotationFullModal shows modal with usage and upgrade button
|
||||
describe('AnnotationFullModal integration', () => {
|
||||
it('should render modal with annotation info and upgrade button when show is true', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/annotatedResponse\.fullTipLine1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/annotatedResponse\.quotaTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourage$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when show is false', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByText(/annotatedResponse\.fullTipLine1/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
|
||||
describe('TriggerEventsLimitModal integration', () => {
|
||||
it('should display trigger limit title, usage info, and upgrade button', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={vi.fn()}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={5}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Modal title and description
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
// Embedded UsageInfo with trigger events data
|
||||
expect(screen.getByText(/triggerLimitModal\.usageTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
// Reset info
|
||||
expect(screen.getByText(/usagePage\.resetsIn/i)).toBeInTheDocument()
|
||||
// Upgrade and dismiss buttons
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose and onUpgrade when clicking upgrade', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={onUpgrade}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5. Header Billing Button Integration
|
||||
// Tests HeaderBillingBtn behavior for different plan states
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Header Billing Button Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should render UpgradeBtn (premium badge) for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "pro" badge for professional plan', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('pro')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "team" badge for team plan', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByText('team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null when billing is disabled', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { enableBilling: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should return null when plan is not fetched yet', () => {
|
||||
setupProviderContext({ type: Plan.sandbox }, { isFetchedPlan: false })
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should call onClick when clicking pro/team badge in non-display-only mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisplayOnly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
|
||||
|
||||
await user.click(screen.getByText('pro'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 6. PriorityLabel Integration
|
||||
// Tests priority badge display for different plan types
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('PriorityLabel Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should display "standard" priority for sandbox plan', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.standard/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "priority" for professional plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.priority/i)).toBeInTheDocument()
|
||||
// Professional plan should show the priority icon
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for team plan with icon', () => {
|
||||
setupProviderContext({ type: Plan.team })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display "top-priority" for enterprise plan', () => {
|
||||
setupProviderContext({ type: Plan.enterprise })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.priority\.top-priority/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 7. Usage Display Edge Cases
|
||||
// Tests storage mode, threshold logic, and progress bar color integration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Usage Display Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
// Vector space storage mode behavior
|
||||
describe('VectorSpace storage mode in PlanComp', () => {
|
||||
it('should show "< 50" for sandbox plan with low vector space usage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Storage mode: usage below threshold shows "< 50"
|
||||
expect(screen.getByText(/</)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indeterminate progress bar for usage below threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Should have an indeterminate progress bar
|
||||
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
usage: { vectorSpace: 1024 },
|
||||
total: { vectorSpace: 5120 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Pro plan above threshold shows actual value
|
||||
expect(screen.getByText('1024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Progress bar color logic through real components
|
||||
describe('Progress bar color reflects usage severity', () => {
|
||||
it('should show normal color for low usage percentage', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 1 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// 20% usage - normal color
|
||||
const progressBars = screen.getAllByTestId('billing-progress-bar')
|
||||
// At least one should have the normal progress color
|
||||
const hasNormalColor = progressBars.some(bar =>
|
||||
bar.classList.contains('bg-components-progress-bar-progress-solid'),
|
||||
)
|
||||
expect(hasNormalColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Reset days calculation in PlanComp
|
||||
describe('Reset days integration', () => {
|
||||
it('should not show reset for sandbox trigger events (no reset_date)', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: null },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Find the trigger events section - should not have reset text
|
||||
const triggerSection = screen.getByText(/usagePage\.triggerEvents/i)
|
||||
const parent = triggerSection.closest('[class*="flex flex-col"]')
|
||||
// No reset text should appear (sandbox doesn't show reset for triggerEvents)
|
||||
expect(parent?.textContent).not.toContain('usagePage.resetsIn')
|
||||
})
|
||||
|
||||
it('should show reset for professional trigger events with reset date', () => {
|
||||
setupProviderContext({
|
||||
type: Plan.professional,
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 14 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Professional plan with finite triggerEvents should show reset
|
||||
const resetTexts = screen.getAllByText(/usagePage\.resetsIn/i)
|
||||
expect(resetTexts.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 8. Cross-Component Upgrade Flow (End-to-End)
|
||||
// Tests the complete chain: capacity alert → upgrade button → pricing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('Cross-Component Upgrade Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupAppContext()
|
||||
})
|
||||
|
||||
it('should trigger pricing from AppsFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="app-create" />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
setupProviderContext({ type: Plan.professional })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={true}
|
||||
onClose={onClose}
|
||||
onUpgrade={vi.fn()}
|
||||
usage={20000}
|
||||
total={20000}
|
||||
/>,
|
||||
)
|
||||
|
||||
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
|
||||
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
|
||||
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
|
||||
await user.click(upgradeBtn)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: Plan.sandbox,
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
|
||||
await user.click(upgradeText)
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Integration test: Cloud Plan Payment Flow
|
||||
*
|
||||
* Tests the payment flow for cloud plan items:
|
||||
* CloudPlanItem → Button click → permission check → fetch URL → redirect
|
||||
*
|
||||
* Covers plan comparison, downgrade prevention, monthly/yearly pricing,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
import type { BasicPlan } from '@/app/components/billing/type'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ALL_PLANS } from '@/app/components/billing/config'
|
||||
import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockFetchSubscriptionUrls = vi.fn()
|
||||
const mockInvoices = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: (...args: unknown[]) => mockFetchSubscriptionUrls(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billing: {
|
||||
invoices: () => mockInvoices(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (args: unknown) => mockToastNotify(args) },
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
type RenderCloudPlanItemOptions = {
|
||||
currentPlan?: BasicPlan
|
||||
plan?: BasicPlan
|
||||
planRange?: PlanRange
|
||||
canPay?: boolean
|
||||
}
|
||||
|
||||
const renderCloudPlanItem = ({
|
||||
currentPlan = Plan.sandbox,
|
||||
plan = Plan.professional,
|
||||
planRange = PlanRange.monthly,
|
||||
canPay = true,
|
||||
}: RenderCloudPlanItemOptions = {}) => {
|
||||
return render(
|
||||
<CloudPlanItem
|
||||
currentPlan={currentPlan}
|
||||
plan={plan}
|
||||
planRange={planRange}
|
||||
canPay={canPay}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Cloud Plan Payment Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupAppContext()
|
||||
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' })
|
||||
mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' })
|
||||
})
|
||||
|
||||
// ─── 1. Plan Display ────────────────────────────────────────────────────
|
||||
describe('Plan display', () => {
|
||||
it('should render plan name and description', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.professional\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Free" price for sandbox plan', () => {
|
||||
renderCloudPlanItem({ plan: Plan.sandbox })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show monthly price for paid plans', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.monthly })
|
||||
|
||||
expect(screen.getByText(`$${ALL_PLANS.professional.price}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show yearly discounted price (10 months) and strikethrough original (12 months)', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional, planRange: PlanRange.yearly })
|
||||
|
||||
const yearlyPrice = ALL_PLANS.professional.price * 10
|
||||
const originalPrice = ALL_PLANS.professional.price * 12
|
||||
|
||||
expect(screen.getByText(`$${yearlyPrice}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`$${originalPrice}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge for professional plan', () => {
|
||||
renderCloudPlanItem({ plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show "most popular" badge for sandbox or team plans', () => {
|
||||
const { unmount } = renderCloudPlanItem({ plan: Plan.sandbox })
|
||||
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
renderCloudPlanItem({ plan: Plan.team })
|
||||
expect(screen.queryByText(/plansCommon\.mostPopular/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Button Text Logic ───────────────────────────────────────────────
|
||||
describe('Button text logic', () => {
|
||||
it('should show "Current Plan" when plan matches current plan', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Start for Free" for sandbox plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.startForFree/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Start Building" for professional plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Get Started" for team plan when not current', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
|
||||
|
||||
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Downgrade Prevention ────────────────────────────────────────────
|
||||
describe('Downgrade prevention', () => {
|
||||
it('should disable sandbox button when user is on professional plan (downgrade)', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.sandbox })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable sandbox and professional buttons when user is on team plan', () => {
|
||||
const { unmount } = renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.sandbox })
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
unmount()
|
||||
|
||||
renderCloudPlanItem({ currentPlan: Plan.team, plan: Plan.professional })
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable current paid plan button (for invoice management)', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable higher-tier plan buttons for upgrade', () => {
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.team })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Payment URL Flow ────────────────────────────────────────────────
|
||||
describe('Payment URL flow', () => {
|
||||
it('should call fetchSubscriptionUrls with plan and "month" for monthly range', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Simulate clicking on a professional plan button (user is on sandbox)
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.sandbox,
|
||||
plan: Plan.professional,
|
||||
planRange: PlanRange.monthly,
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call fetchSubscriptionUrls with plan and "year" for yearly range', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({
|
||||
currentPlan: Plan.sandbox,
|
||||
plan: Plan.team,
|
||||
planRange: PlanRange.yearly,
|
||||
})
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
})
|
||||
})
|
||||
|
||||
it('should open invoice management for current paid plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalled()
|
||||
})
|
||||
// Should NOT call fetchSubscriptionUrls (invoice, not subscription)
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not do anything when clicking on sandbox free plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.sandbox })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Wait a tick and verify no actions were taken
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockOpenAsyncWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Permission Check ────────────────────────────────────────────────
|
||||
describe('Permission check', () => {
|
||||
it('should show error toast when non-manager clicks upgrade button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Should not proceed with payment
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Integration test: Education Verification Flow
|
||||
*
|
||||
* Tests the education plan verification flow in PlanComp:
|
||||
* PlanComp → handleVerify → useEducationVerify → router.push → education-apply
|
||||
* PlanComp → handleVerify → error → show VerifyStateModal
|
||||
*
|
||||
* Also covers education button visibility based on context flags.
|
||||
*/
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: 'https://billing.example.com',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks ───────────────────────────────────────────────
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: ({ isShow, title, content, email, showLink }: {
|
||||
isShow: boolean
|
||||
title?: string
|
||||
content?: string
|
||||
email?: string
|
||||
showLink?: boolean
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="verify-state-modal">
|
||||
{title && <span data-testid="modal-title">{title}</span>}
|
||||
{content && <span data-testid="modal-content">{content}</span>}
|
||||
{email && <span data-testid="modal-email">{email}</span>}
|
||||
{showLink && <span data-testid="modal-show-link">link</span>}
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupContexts = (
|
||||
planOverrides: PlanOverrides = {},
|
||||
providerOverrides: Record<string, unknown> = {},
|
||||
appOverrides: Record<string, unknown> = {},
|
||||
) => {
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
...providerOverrides,
|
||||
}
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'student@university.edu' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...appOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Education Verification Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
// ─── 1. Education Button Visibility ─────────────────────────────────────
|
||||
describe('Education button visibility', () => {
|
||||
it('should not show verify button when enableEducationPlan is false', () => {
|
||||
setupContexts({}, { enableEducationPlan: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show verify button when already verified and not about to expire', () => {
|
||||
setupContexts({}, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
allowRefreshEducationVerify: false,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.queryByText(/toVerified/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show verify button when about to expire (allowRefreshEducationVerify is true)', () => {
|
||||
setupContexts({}, {
|
||||
enableEducationPlan: true,
|
||||
isEducationAccount: true,
|
||||
allowRefreshEducationVerify: true,
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Shown because isAboutToExpire = allowRefreshEducationVerify = true
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Successful Verification Flow ────────────────────────────────────
|
||||
describe('Successful verification flow', () => {
|
||||
it('should navigate to education-apply with token on successful verification', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'edu-token-123' })
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
const verifyButton = screen.getByText(/toVerified/i)
|
||||
await user.click(verifyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove education verifying flag from localStorage on success', async () => {
|
||||
mockMutateAsync.mockResolvedValue({ token: 'token-xyz' })
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Failed Verification Flow ────────────────────────────────────────
|
||||
describe('Failed verification flow', () => {
|
||||
it('should show VerifyStateModal with rejection info on error', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('Verification failed'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
// Modal should not be visible initially
|
||||
expect(screen.queryByTestId('verify-state-modal')).not.toBeInTheDocument()
|
||||
|
||||
const verifyButton = screen.getByText(/toVerified/i)
|
||||
await user.click(verifyButton)
|
||||
|
||||
// Modal should appear after verification failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Modal should display rejection title and content
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(/rejectTitle/i)
|
||||
expect(screen.getByTestId('modal-content')).toHaveTextContent(/rejectContent/i)
|
||||
})
|
||||
|
||||
it('should show email and link in VerifyStateModal', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('fail'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('modal-email')).toHaveTextContent('student@university.edu')
|
||||
expect(screen.getByTestId('modal-show-link')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not redirect on verification failure', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('fail'))
|
||||
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
await user.click(screen.getByText(/toVerified/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('verify-state-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should NOT navigate
|
||||
expect(mockRouterPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Education + Upgrade Coexistence ─────────────────────────────────
|
||||
describe('Education and upgrade button coexistence', () => {
|
||||
it('should show both education verify and upgrade buttons for sandbox user', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.sandbox },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show upgrade button for enterprise plan', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.enterprise },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show team plan with plain upgrade button and education button', () => {
|
||||
setupContexts(
|
||||
{ type: Plan.team },
|
||||
{ enableEducationPlan: true, isEducationAccount: false },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
expect(screen.getByText(/toVerified/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
/**
|
||||
* Integration test: Partner Stack Flow
|
||||
*
|
||||
* Tests the PartnerStack integration:
|
||||
* PartnerStack component → usePSInfo hook → cookie management → bind API call
|
||||
*
|
||||
* Covers URL param reading, cookie persistence, API bind on mount,
|
||||
* cookie cleanup after successful bind, and error handling for 400 status.
|
||||
*/
|
||||
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import * as React from 'react'
|
||||
import usePSInfo from '@/app/components/billing/partner-stack/use-ps-info'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
const mockMutateAsync = vi.fn()
|
||||
|
||||
// ─── Module mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => mockSearchParams,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBindPartnerStackInfo: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
useBillingUrl: () => ({
|
||||
data: '',
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
IS_CLOUD_EDITION: true,
|
||||
PARTNER_STACK_CONFIG: {
|
||||
cookieName: 'partner_stack_info',
|
||||
saveCookieDays: 90,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Cookie helpers ──────────────────────────────────────────────────────────
|
||||
const getCookieData = () => {
|
||||
const raw = Cookies.get(PARTNER_STACK_CONFIG.cookieName)
|
||||
if (!raw)
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const setCookieData = (data: Record<string, string>) => {
|
||||
Cookies.set(PARTNER_STACK_CONFIG.cookieName, JSON.stringify(data))
|
||||
}
|
||||
|
||||
const clearCookie = () => {
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Partner Stack Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
clearCookie()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
})
|
||||
|
||||
// ─── 1. URL Param Reading ───────────────────────────────────────────────
|
||||
describe('URL param reading', () => {
|
||||
it('should read ps_partner_key and ps_xid from URL search params', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-123',
|
||||
ps_xid: 'click-456',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('partner-123')
|
||||
expect(result.current.psClickId).toBe('click-456')
|
||||
})
|
||||
|
||||
it('should fall back to cookie when URL params are not present', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
it('should prefer URL params over cookie values', () => {
|
||||
setCookieData({ partnerKey: 'cookie-partner', clickId: 'cookie-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'url-partner',
|
||||
ps_xid: 'url-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('url-partner')
|
||||
expect(result.current.psClickId).toBe('url-click')
|
||||
})
|
||||
|
||||
it('should return null for both values when no params and no cookie', () => {
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Cookie Persistence (saveOrUpdate) ───────────────────────────────
|
||||
describe('Cookie persistence via saveOrUpdate', () => {
|
||||
it('should save PS info to cookie when URL params provide new values', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'new-partner',
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
const cookieData = getCookieData()
|
||||
expect(cookieData).toEqual({
|
||||
partnerKey: 'new-partner',
|
||||
clickId: 'new-click',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update cookie when values have not changed', () => {
|
||||
setCookieData({ partnerKey: 'same-partner', clickId: 'same-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'same-partner',
|
||||
ps_xid: 'same-click',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
// Should not call set because values haven't changed
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not save to cookie when partner key is missing', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not save to cookie when click ID is missing', () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-only',
|
||||
})
|
||||
|
||||
const cookieSetSpy = vi.spyOn(Cookies, 'set')
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
act(() => result.current.saveOrUpdate())
|
||||
|
||||
expect(cookieSetSpy).not.toHaveBeenCalled()
|
||||
cookieSetSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Bind API Flow ──────────────────────────────────────────────────
|
||||
describe('Bind API flow', () => {
|
||||
it('should call mutateAsync with partnerKey and clickId on bind', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'bind-partner',
|
||||
clickId: 'bind-click',
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove cookie after successful bind', async () => {
|
||||
setCookieData({ partnerKey: 'rm-partner', clickId: 'rm-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'rm-partner',
|
||||
ps_xid: 'rm-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should be removed after successful bind
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove cookie on 400 error (already bound)', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 400 })
|
||||
setCookieData({ partnerKey: 'err-partner', clickId: 'err-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'err-partner',
|
||||
ps_xid: 'err-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should be removed even on 400
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not remove cookie on non-400 errors', async () => {
|
||||
mockMutateAsync.mockRejectedValue({ status: 500 })
|
||||
setCookieData({ partnerKey: 'keep-partner', clickId: 'keep-click' })
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'keep-partner',
|
||||
ps_xid: 'keep-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
// Cookie should still exist for non-400 errors
|
||||
const cookieData = getCookieData()
|
||||
expect(cookieData).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not call bind when partner key is missing', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_xid: 'click-only',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call bind a second time (idempotency)', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'partner-once',
|
||||
ps_xid: 'click-once',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
// First bind
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second bind should be skipped (hasBind = true)
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
expect(mockMutateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. PartnerStack Component Mount ────────────────────────────────────
|
||||
describe('PartnerStack component mount behavior', () => {
|
||||
it('should call saveOrUpdate and bind on mount when IS_CLOUD_EDITION is true', async () => {
|
||||
mockSearchParams = new URLSearchParams({
|
||||
ps_partner_key: 'mount-partner',
|
||||
ps_xid: 'mount-click',
|
||||
})
|
||||
|
||||
// Use lazy import so the mocks are applied
|
||||
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
||||
|
||||
render(<PartnerStack />)
|
||||
|
||||
// The component calls saveOrUpdate and bind in useEffect
|
||||
await waitFor(() => {
|
||||
// Bind should have been called
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
partnerKey: 'mount-partner',
|
||||
clickId: 'mount-click',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie should have been saved (saveOrUpdate was called before bind)
|
||||
// After bind succeeds, cookie is removed
|
||||
expect(Cookies.get(PARTNER_STACK_CONFIG.cookieName)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should render nothing (return null)', async () => {
|
||||
const { default: PartnerStack } = await import('@/app/components/billing/partner-stack')
|
||||
|
||||
const { container } = render(<PartnerStack />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Integration test: Pricing Modal Flow
|
||||
*
|
||||
* Tests the full Pricing modal lifecycle:
|
||||
* Pricing → PlanSwitcher (category + range toggle) → Plans (cloud / self-hosted)
|
||||
* → CloudPlanItem / SelfHostedPlanItem → Footer
|
||||
*
|
||||
* Validates cross-component state propagation when the user switches between
|
||||
* cloud / self-hosted categories and monthly / yearly plan ranges.
|
||||
*/
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { ALL_PLANS } from '@/app/components/billing/config'
|
||||
import Pricing from '@/app/components/billing/pricing'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
|
||||
// ─── Mock state ──────────────────────────────────────────────────────────────
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
useGetPricingPageLanguage: () => 'en',
|
||||
}))
|
||||
|
||||
// ─── Service mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/service/billing', () => ({
|
||||
fetchSubscriptionUrls: vi.fn().mockResolvedValue({ url: 'https://pay.example.com' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
billing: {
|
||||
invoices: vi.fn().mockResolvedValue({ url: 'https://invoice.example.com' }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── Navigation mocks ───────────────────────────────────────────────────────
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/billing',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// ─── External component mocks (lightweight) ─────────────────────────────────
|
||||
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <span data-testid="icon-azure" />,
|
||||
GoogleCloud: () => <span data-testid="icon-gcloud" />,
|
||||
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
|
||||
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
// Self-hosted List uses t() with returnObjects which returns string in mock;
|
||||
// mock it to avoid deep i18n dependency (unit tests cover this component)
|
||||
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const defaultPlanData = {
|
||||
type: Plan.sandbox,
|
||||
usage: {
|
||||
buildApps: 1,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 0,
|
||||
vectorSpace: 10,
|
||||
annotatedResponse: 1,
|
||||
triggerEvents: 0,
|
||||
apiRateLimit: 0,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
}
|
||||
|
||||
const setupContexts = (planOverrides: Record<string, unknown> = {}, appOverrides: Record<string, unknown> = {}) => {
|
||||
mockProviderCtx = {
|
||||
plan: { ...defaultPlanData, ...planOverrides },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
}
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'test@example.com' },
|
||||
langGeniusVersionInfo: { current_version: '1.0.0' },
|
||||
...appOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
describe('Pricing Modal Flow', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
})
|
||||
|
||||
// ─── 1. Initial Rendering ────────────────────────────────────────────────
|
||||
describe('Initial rendering', () => {
|
||||
it('should render header with close button and footer with pricing link', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Header close button exists (multiple plan buttons also exist)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
// Footer pricing link
|
||||
expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should default to cloud category with three cloud plans', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Three cloud plans: sandbox, professional, team
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.professional\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show plan range switcher (annual billing toggle) by default for cloud', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tax tip in footer for cloud category', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Use exact match to avoid matching taxTipSecond
|
||||
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Category Switching ───────────────────────────────────────────────
|
||||
describe('Category switching', () => {
|
||||
it('should switch to self-hosted plans when clicking self-hosted tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Click the self-hosted tab
|
||||
const selfTab = screen.getByText(/plansCommon\.self/i)
|
||||
await user.click(selfTab)
|
||||
|
||||
// Self-hosted plans should appear
|
||||
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
|
||||
|
||||
// Cloud plans should disappear
|
||||
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide plan range switcher for self-hosted category', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
// Annual billing toggle should not be visible
|
||||
expect(screen.queryByText(/plansCommon\.annualBilling/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tax tip in footer for self-hosted category', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch back to cloud plans when clicking cloud tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Switch to self-hosted
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
expect(screen.queryByText(/plans\.sandbox\.name/i)).not.toBeInTheDocument()
|
||||
|
||||
// Switch back to cloud
|
||||
await user.click(screen.getByText(/plansCommon\.cloud/i))
|
||||
expect(screen.getByText(/plans\.sandbox\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Plan Range Switching (Monthly ↔ Yearly) ──────────────────────────
|
||||
describe('Plan range switching', () => {
|
||||
it('should show monthly prices by default', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Professional monthly price: $59
|
||||
const proPriceStr = `$${ALL_PLANS.professional.price}`
|
||||
expect(screen.getByText(proPriceStr)).toBeInTheDocument()
|
||||
|
||||
// Team monthly price: $159
|
||||
const teamPriceStr = `$${ALL_PLANS.team.price}`
|
||||
expect(screen.getByText(teamPriceStr)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Free" for sandbox plan regardless of range', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge only for professional plan', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Cloud Plan Button States ─────────────────────────────────────────
|
||||
describe('Cloud plan button states', () => {
|
||||
it('should show "Current Plan" for the current plan (sandbox)', () => {
|
||||
setupContexts({ type: Plan.sandbox })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show specific button text for non-current plans', () => {
|
||||
setupContexts({ type: Plan.sandbox })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Professional button text
|
||||
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
|
||||
// Team button text
|
||||
expect(screen.getByText(/plansCommon\.getStarted/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark sandbox as "Current Plan" for professional user (enterprise normalized to team)', () => {
|
||||
setupContexts({ type: Plan.enterprise })
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// Enterprise is normalized to team for display, so team is "Current Plan"
|
||||
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Self-Hosted Plan Details ─────────────────────────────────────────
|
||||
describe('Self-hosted plan details', () => {
|
||||
it('should show cloud provider icons only for premium plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
// Premium plan should show Azure and Google Cloud icons
|
||||
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "coming soon" text for premium plan cloud providers', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
await user.click(screen.getByText(/plansCommon\.self/i))
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.comingSoon/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 6. Close Handling ───────────────────────────────────────────────────
|
||||
describe('Close handling', () => {
|
||||
it('should call onCancel when pressing ESC key', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
// ahooks useKeyPress listens on document for keydown events
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
bubbles: true,
|
||||
}))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
|
||||
describe('Pricing page URL', () => {
|
||||
it('should render pricing link with correct URL', () => {
|
||||
render(<Pricing onCancel={onCancel} />)
|
||||
|
||||
const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i)
|
||||
expect(link.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://dify.ai/en/pricing#plans-and-features',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Integration test: Self-Hosted Plan Flow
|
||||
*
|
||||
* Tests the self-hosted plan items:
|
||||
* SelfHostedPlanItem → Button click → permission check → redirect to external URL
|
||||
*
|
||||
* Covers community/premium/enterprise plan rendering, external URL navigation,
|
||||
* and workspace manager permission enforcement.
|
||||
*/
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config'
|
||||
import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
|
||||
let mockAppCtx: Record<string, unknown> = {}
|
||||
const mockToastNotify = vi.fn()
|
||||
|
||||
const originalLocation = window.location
|
||||
let assignedHref = ''
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => mockAppCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/billing', () => ({
|
||||
Azure: () => <span data-testid="icon-azure" />,
|
||||
GoogleCloud: () => <span data-testid="icon-gcloud" />,
|
||||
AwsMarketplaceLight: () => <span data-testid="icon-aws-light" />,
|
||||
AwsMarketplaceDark: () => <span data-testid="icon-aws-dark" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (args: unknown) => mockToastNotify(args) },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`self-hosted-list-${plan}`}>Features</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const setupAppContext = (overrides: Record<string, unknown> = {}) => {
|
||||
mockAppCtx = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Self-Hosted Plan Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupAppContext()
|
||||
|
||||
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
|
||||
assignedHref = ''
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get href() { return assignedHref },
|
||||
set href(value: string) { assignedHref = value },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original location
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 1. Plan Rendering ──────────────────────────────────────────────────
|
||||
describe('Plan rendering', () => {
|
||||
it('should render community plan with name and description', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render premium plan with cloud provider icons', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-gcloud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render enterprise plan without cloud provider icons', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show price tip for community (free) plan', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show price tip for premium plan', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render features list for each plan', () => {
|
||||
const { unmount: unmount1 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
|
||||
unmount1()
|
||||
|
||||
const { unmount: unmount2 } = render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
|
||||
unmount2()
|
||||
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show AWS marketplace icon for premium plan button', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Navigation Flow ─────────────────────────────────────────────────
|
||||
describe('Navigation flow', () => {
|
||||
it('should redirect to GitHub when clicking community plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(getStartedWithCommunityUrl)
|
||||
})
|
||||
|
||||
it('should redirect to AWS Marketplace when clicking premium plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(getWithPremiumUrl)
|
||||
})
|
||||
|
||||
it('should redirect to Typeform when clicking enterprise plan button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(assignedHref).toBe(contactSalesUrl)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Permission Check ────────────────────────────────────────────────
|
||||
describe('Permission check', () => {
|
||||
it('should show error toast when non-manager clicks community button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
// Should NOT redirect
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
|
||||
it('should show error toast when non-manager clicks premium button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.premium} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
|
||||
it('should show error toast when non-manager clicks enterprise button', async () => {
|
||||
setupAppContext({ isCurrentWorkspaceManager: false })
|
||||
const user = userEvent.setup()
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.enterprise} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
210
web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts
Normal file
210
web/__tests__/rag-pipeline/chunk-preview-formatting.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Integration test: Chunk preview formatting pipeline
|
||||
*
|
||||
* Tests the formatPreviewChunks utility across all chunking modes
|
||||
* (text, parentChild, QA) with real data structures.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 3,
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
ChunkingMode: {
|
||||
text: 'text',
|
||||
parentChild: 'parent-child',
|
||||
qa: 'qa',
|
||||
},
|
||||
}))
|
||||
|
||||
const { formatPreviewChunks } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/result/result-preview/utils',
|
||||
)
|
||||
|
||||
describe('Chunk Preview Formatting', () => {
|
||||
describe('general text chunks', () => {
|
||||
it('should format text chunks correctly', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'text',
|
||||
preview: [
|
||||
{ content: 'Chunk 1 content', summary: 'Summary 1' },
|
||||
{ content: 'Chunk 2 content' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
const chunks = result as Array<{ content: string, summary?: string }>
|
||||
expect(chunks).toHaveLength(2)
|
||||
expect(chunks[0].content).toBe('Chunk 1 content')
|
||||
expect(chunks[0].summary).toBe('Summary 1')
|
||||
expect(chunks[1].content).toBe('Chunk 2 content')
|
||||
})
|
||||
|
||||
it('should limit chunks to RAG_PIPELINE_PREVIEW_CHUNK_NUM', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'text',
|
||||
preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `Chunk ${i + 1}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs)
|
||||
const chunks = result as Array<{ content: string }>
|
||||
|
||||
expect(chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent-child chunks — paragraph mode', () => {
|
||||
it('should format paragraph parent-child chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'paragraph',
|
||||
preview: [
|
||||
{
|
||||
content: 'Parent paragraph',
|
||||
child_chunks: ['Child 1', 'Child 2'],
|
||||
summary: 'Parent summary',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{
|
||||
parent_content: string
|
||||
parent_summary?: string
|
||||
child_contents: string[]
|
||||
parent_mode: string
|
||||
}>
|
||||
parent_mode: string
|
||||
}
|
||||
|
||||
expect(result.parent_mode).toBe('paragraph')
|
||||
expect(result.parent_child_chunks).toHaveLength(1)
|
||||
expect(result.parent_child_chunks[0].parent_content).toBe('Parent paragraph')
|
||||
expect(result.parent_child_chunks[0].parent_summary).toBe('Parent summary')
|
||||
expect(result.parent_child_chunks[0].child_contents).toEqual(['Child 1', 'Child 2'])
|
||||
})
|
||||
|
||||
it('should limit parent chunks in paragraph mode', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'paragraph',
|
||||
preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `Parent ${i + 1}`,
|
||||
child_chunks: [`Child of ${i + 1}`],
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: unknown[]
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent-child chunks — full-doc mode', () => {
|
||||
it('should format full-doc parent-child chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{
|
||||
content: 'Full document content',
|
||||
child_chunks: ['Section 1', 'Section 2', 'Section 3'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{
|
||||
parent_content: string
|
||||
child_contents: string[]
|
||||
parent_mode: string
|
||||
}>
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks).toHaveLength(1)
|
||||
expect(result.parent_child_chunks[0].parent_content).toBe('Full document content')
|
||||
expect(result.parent_child_chunks[0].parent_mode).toBe('full-doc')
|
||||
})
|
||||
|
||||
it('should limit child chunks in full-doc mode', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'parent-child',
|
||||
parent_mode: 'full-doc',
|
||||
preview: [
|
||||
{
|
||||
content: 'Document',
|
||||
child_chunks: Array.from({ length: 20 }, (_, i) => `Section ${i + 1}`),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
parent_child_chunks: Array<{ child_contents: string[] }>
|
||||
}
|
||||
|
||||
expect(result.parent_child_chunks[0].child_contents).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('QA chunks', () => {
|
||||
it('should format QA chunks correctly', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'qa',
|
||||
qa_preview: [
|
||||
{ question: 'What is AI?', answer: 'Artificial Intelligence is...' },
|
||||
{ question: 'What is ML?', answer: 'Machine Learning is...' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
qa_chunks: Array<{ question: string, answer: string }>
|
||||
}
|
||||
|
||||
expect(result.qa_chunks).toHaveLength(2)
|
||||
expect(result.qa_chunks[0].question).toBe('What is AI?')
|
||||
expect(result.qa_chunks[0].answer).toBe('Artificial Intelligence is...')
|
||||
})
|
||||
|
||||
it('should limit QA chunks', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'qa',
|
||||
qa_preview: Array.from({ length: 10 }, (_, i) => ({
|
||||
question: `Q${i + 1}`,
|
||||
answer: `A${i + 1}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const result = formatPreviewChunks(outputs) as {
|
||||
qa_chunks: unknown[]
|
||||
}
|
||||
|
||||
expect(result.qa_chunks).toHaveLength(3) // Mocked limit
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return undefined for null outputs', () => {
|
||||
expect(formatPreviewChunks(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for undefined outputs', () => {
|
||||
expect(formatPreviewChunks(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for unknown chunk_structure', () => {
|
||||
const outputs = {
|
||||
chunk_structure: 'unknown-type',
|
||||
preview: [],
|
||||
}
|
||||
|
||||
expect(formatPreviewChunks(outputs)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
179
web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts
Normal file
179
web/__tests__/rag-pipeline/dsl-export-import-flow.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Integration test: DSL export/import flow
|
||||
*
|
||||
* Validates DSL export logic (sync draft → check secrets → download)
|
||||
* and DSL import modal state management.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
|
||||
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
|
||||
const mockNotify = vi.fn()
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
pipelineId: 'pipeline-abc',
|
||||
knowledgeName: 'My Pipeline',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipelineConfig,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DSL Export/Import Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Export Flow', () => {
|
||||
it('should sync draft then export then download', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: false,
|
||||
})
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'My Pipeline.pipeline',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should export with include flag when specified', async () => {
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-abc',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify on export error', async () => {
|
||||
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Export Check Flow', () => {
|
||||
it('should export directly when no secret environment variables', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
// Should proceed to export directly (no secret vars)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
|
||||
environment_variables: [
|
||||
{ value_type: 'secret', key: 'API_KEY', value: '***' },
|
||||
],
|
||||
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
payload: expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ value_type: 'secret' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should notify on export check error', async () => {
|
||||
const { fetchWorkflowDraft } = await import('@/service/workflow')
|
||||
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
278
web/__tests__/rag-pipeline/input-field-crud-flow.test.ts
Normal file
278
web/__tests__/rag-pipeline/input-field-crud-flow.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Integration test: Input field CRUD complete flow
|
||||
*
|
||||
* Validates the full lifecycle of input fields:
|
||||
* creation, editing, renaming, removal, and data conversion round-trip.
|
||||
*/
|
||||
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
|
||||
type: 'text-input',
|
||||
label: '',
|
||||
variable: '',
|
||||
max_length: 48,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Input Field CRUD Flow', () => {
|
||||
describe('Create → Edit → Convert Round-trip', () => {
|
||||
it('should create a text field and roundtrip through form data', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
// Create new field from template (no data passed)
|
||||
const newFormData = convertToInputFieldFormData()
|
||||
expect(newFormData.type).toBe('text-input')
|
||||
expect(newFormData.variable).toBe('')
|
||||
expect(newFormData.label).toBe('')
|
||||
expect(newFormData.required).toBe(true)
|
||||
|
||||
// Simulate user editing form data
|
||||
const editedFormData: FormData = {
|
||||
...newFormData,
|
||||
variable: 'user_name',
|
||||
label: 'User Name',
|
||||
maxLength: 100,
|
||||
default: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
placeholder: 'Type here...',
|
||||
allowedTypesAndExtensions: {},
|
||||
}
|
||||
|
||||
// Convert back to InputVar
|
||||
const inputVar = convertFormDataToINputField(editedFormData)
|
||||
|
||||
expect(inputVar.variable).toBe('user_name')
|
||||
expect(inputVar.label).toBe('User Name')
|
||||
expect(inputVar.max_length).toBe(100)
|
||||
expect(inputVar.default_value).toBe('John')
|
||||
expect(inputVar.tooltips).toBe('Enter your name')
|
||||
expect(inputVar.placeholder).toBe('Type here...')
|
||||
expect(inputVar.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle file field with upload settings', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const fileInputVar: InputVar = {
|
||||
type: PipelineInputVarType.singleFile,
|
||||
label: 'Upload Document',
|
||||
variable: 'doc_file',
|
||||
max_length: 1,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: 'Upload a PDF',
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: ['.pdf', '.docx'],
|
||||
}
|
||||
|
||||
// Convert to form data
|
||||
const formData = convertToInputFieldFormData(fileInputVar)
|
||||
expect(formData.allowedFileUploadMethods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
|
||||
expect(formData.allowedTypesAndExtensions).toEqual({
|
||||
allowedFileTypes: [SupportUploadFileTypes.document],
|
||||
allowedFileExtensions: ['.pdf', '.docx'],
|
||||
})
|
||||
|
||||
// Round-trip back
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.allowed_file_upload_methods).toEqual([TransferMethod.local_file, TransferMethod.remote_url])
|
||||
expect(restored.allowed_file_types).toEqual([SupportUploadFileTypes.document])
|
||||
expect(restored.allowed_file_extensions).toEqual(['.pdf', '.docx'])
|
||||
})
|
||||
|
||||
it('should handle select field with options', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const selectVar: InputVar = {
|
||||
type: PipelineInputVarType.select,
|
||||
label: 'Priority',
|
||||
variable: 'priority',
|
||||
max_length: 0,
|
||||
default_value: 'medium',
|
||||
required: false,
|
||||
tooltips: 'Select priority level',
|
||||
options: ['low', 'medium', 'high'],
|
||||
placeholder: 'Choose...',
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(selectVar)
|
||||
expect(formData.options).toEqual(['low', 'medium', 'high'])
|
||||
expect(formData.default).toBe('medium')
|
||||
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.options).toEqual(['low', 'medium', 'high'])
|
||||
expect(restored.default_value).toBe('medium')
|
||||
})
|
||||
|
||||
it('should handle number field with unit', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const numberVar: InputVar = {
|
||||
type: PipelineInputVarType.number,
|
||||
label: 'Max Tokens',
|
||||
variable: 'max_tokens',
|
||||
max_length: 0,
|
||||
default_value: '1024',
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: 'tokens',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(numberVar)
|
||||
expect(formData.unit).toBe('tokens')
|
||||
expect(formData.default).toBe('1024')
|
||||
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
expect(restored.unit).toBe('tokens')
|
||||
expect(restored.default_value).toBe('1024')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Omit optional fields', () => {
|
||||
it('should not include tooltips when undefined', async () => {
|
||||
const { convertToInputFieldFormData } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const inputVar: InputVar = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test',
|
||||
variable: 'test',
|
||||
max_length: 48,
|
||||
default_value: undefined,
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
// Optional fields should not be present
|
||||
expect('tooltips' in formData).toBe(false)
|
||||
expect('placeholder' in formData).toBe(false)
|
||||
expect('unit' in formData).toBe(false)
|
||||
expect('default' in formData).toBe(false)
|
||||
})
|
||||
|
||||
it('should include optional fields when explicitly set to empty string', async () => {
|
||||
const { convertToInputFieldFormData } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const inputVar: InputVar = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test',
|
||||
variable: 'test',
|
||||
max_length: 48,
|
||||
default_value: '',
|
||||
required: true,
|
||||
tooltips: '',
|
||||
options: [],
|
||||
placeholder: '',
|
||||
unit: '',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
}
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.default).toBe('')
|
||||
expect(formData.tooltips).toBe('')
|
||||
expect(formData.placeholder).toBe('')
|
||||
expect(formData.unit).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple fields workflow', () => {
|
||||
it('should process multiple fields independently', async () => {
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
const fields: InputVar[] = [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'name',
|
||||
max_length: 48,
|
||||
default_value: 'Alice',
|
||||
required: true,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: undefined,
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
{
|
||||
type: PipelineInputVarType.number,
|
||||
label: 'Count',
|
||||
variable: 'count',
|
||||
max_length: 0,
|
||||
default_value: '10',
|
||||
required: false,
|
||||
tooltips: undefined,
|
||||
options: [],
|
||||
placeholder: undefined,
|
||||
unit: 'items',
|
||||
allowed_file_upload_methods: undefined,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const formDataList = fields.map(f => convertToInputFieldFormData(f))
|
||||
const restoredFields = formDataList.map(fd => convertFormDataToINputField(fd))
|
||||
|
||||
expect(restoredFields).toHaveLength(2)
|
||||
expect(restoredFields[0].variable).toBe('name')
|
||||
expect(restoredFields[0].default_value).toBe('Alice')
|
||||
expect(restoredFields[1].variable).toBe('count')
|
||||
expect(restoredFields[1].default_value).toBe('10')
|
||||
expect(restoredFields[1].unit).toBe('items')
|
||||
})
|
||||
})
|
||||
})
|
||||
199
web/__tests__/rag-pipeline/input-field-editor-flow.test.ts
Normal file
199
web/__tests__/rag-pipeline/input-field-editor-flow.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Integration test: Input field editor data conversion flow
|
||||
*
|
||||
* Tests the full pipeline: InputVar -> FormData -> InputVar roundtrip
|
||||
* and schema validation for various input types.
|
||||
*/
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
// Mock the config module for VAR_ITEM_TEMPLATE_IN_PIPELINE
|
||||
vi.mock('@/config', () => ({
|
||||
VAR_ITEM_TEMPLATE_IN_PIPELINE: {
|
||||
type: 'text-input',
|
||||
label: '',
|
||||
variable: '',
|
||||
max_length: 48,
|
||||
required: false,
|
||||
options: [],
|
||||
allowed_file_upload_methods: [],
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
},
|
||||
MAX_VAR_KEY_LENGTH: 30,
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 10,
|
||||
}))
|
||||
|
||||
// Import real functions (not mocked)
|
||||
const { convertToInputFieldFormData, convertFormDataToINputField } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/input-field/editor/utils',
|
||||
)
|
||||
|
||||
describe('Input Field Editor Data Flow', () => {
|
||||
describe('convertToInputFieldFormData', () => {
|
||||
it('should convert a text input InputVar to FormData', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Name',
|
||||
variable: 'user_name',
|
||||
max_length: 100,
|
||||
required: true,
|
||||
default_value: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
placeholder: 'Type here...',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.type).toBe('text-input')
|
||||
expect(formData.label).toBe('Name')
|
||||
expect(formData.variable).toBe('user_name')
|
||||
expect(formData.maxLength).toBe(100)
|
||||
expect(formData.required).toBe(true)
|
||||
expect(formData.default).toBe('John')
|
||||
expect(formData.tooltips).toBe('Enter your name')
|
||||
expect(formData.placeholder).toBe('Type here...')
|
||||
})
|
||||
|
||||
it('should handle file input with upload settings', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'file',
|
||||
label: 'Document',
|
||||
variable: 'doc',
|
||||
required: false,
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
allowed_file_types: ['document', 'image'],
|
||||
allowed_file_extensions: ['.pdf', '.jpg'],
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.allowedFileUploadMethods).toEqual(['local_file', 'remote_url'])
|
||||
expect(formData.allowedTypesAndExtensions).toEqual({
|
||||
allowedFileTypes: ['document', 'image'],
|
||||
allowedFileExtensions: ['.pdf', '.jpg'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should use template defaults when no data provided', () => {
|
||||
const formData = convertToInputFieldFormData(undefined)
|
||||
|
||||
expect(formData.type).toBe('text-input')
|
||||
expect(formData.maxLength).toBe(48)
|
||||
expect(formData.required).toBe(false)
|
||||
})
|
||||
|
||||
it('should omit undefined/null optional fields', () => {
|
||||
const inputVar: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Simple',
|
||||
variable: 'simple_var',
|
||||
max_length: 50,
|
||||
required: false,
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(inputVar)
|
||||
|
||||
expect(formData.default).toBeUndefined()
|
||||
expect(formData.tooltips).toBeUndefined()
|
||||
expect(formData.placeholder).toBeUndefined()
|
||||
expect(formData.unit).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertFormDataToINputField', () => {
|
||||
it('should convert FormData back to InputVar', () => {
|
||||
const formData = {
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Name',
|
||||
variable: 'user_name',
|
||||
maxLength: 100,
|
||||
required: true,
|
||||
default: 'John',
|
||||
tooltips: 'Enter your name',
|
||||
options: [],
|
||||
placeholder: 'Type here...',
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: undefined,
|
||||
allowedFileExtensions: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const inputVar = convertFormDataToINputField(formData)
|
||||
|
||||
expect(inputVar.type).toBe('text-input')
|
||||
expect(inputVar.label).toBe('Name')
|
||||
expect(inputVar.variable).toBe('user_name')
|
||||
expect(inputVar.max_length).toBe(100)
|
||||
expect(inputVar.required).toBe(true)
|
||||
expect(inputVar.default_value).toBe('John')
|
||||
expect(inputVar.tooltips).toBe('Enter your name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('roundtrip conversion', () => {
|
||||
it('should preserve text input data through roundtrip', () => {
|
||||
const original: InputVar = {
|
||||
type: 'text-input',
|
||||
label: 'Question',
|
||||
variable: 'question',
|
||||
max_length: 200,
|
||||
required: true,
|
||||
default_value: 'What is AI?',
|
||||
tooltips: 'Enter your question',
|
||||
placeholder: 'Ask something...',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.type).toBe(original.type)
|
||||
expect(restored.label).toBe(original.label)
|
||||
expect(restored.variable).toBe(original.variable)
|
||||
expect(restored.max_length).toBe(original.max_length)
|
||||
expect(restored.required).toBe(original.required)
|
||||
expect(restored.default_value).toBe(original.default_value)
|
||||
expect(restored.tooltips).toBe(original.tooltips)
|
||||
expect(restored.placeholder).toBe(original.placeholder)
|
||||
})
|
||||
|
||||
it('should preserve number input data through roundtrip', () => {
|
||||
const original = {
|
||||
type: 'number',
|
||||
label: 'Temperature',
|
||||
variable: 'temp',
|
||||
required: false,
|
||||
default_value: '0.7',
|
||||
unit: '°C',
|
||||
options: [],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.type).toBe('number')
|
||||
expect(restored.unit).toBe('°C')
|
||||
expect(restored.default_value).toBe('0.7')
|
||||
})
|
||||
|
||||
it('should preserve select options through roundtrip', () => {
|
||||
const original: InputVar = {
|
||||
type: 'select',
|
||||
label: 'Mode',
|
||||
variable: 'mode',
|
||||
required: true,
|
||||
options: ['fast', 'balanced', 'quality'],
|
||||
} as InputVar
|
||||
|
||||
const formData = convertToInputFieldFormData(original)
|
||||
const restored = convertFormDataToINputField(formData)
|
||||
|
||||
expect(restored.options).toEqual(['fast', 'balanced', 'quality'])
|
||||
})
|
||||
})
|
||||
})
|
||||
282
web/__tests__/rag-pipeline/test-run-flow.test.ts
Normal file
282
web/__tests__/rag-pipeline/test-run-flow.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Integration test: Test run end-to-end flow
|
||||
*
|
||||
* Validates the data flow through test-run preparation hooks:
|
||||
* step navigation, datasource filtering, and data clearing.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Use string literals inside vi.hoisted to avoid import-before-init
|
||||
// BlockEnum.DataSource = 'datasource', BlockEnum.KnowledgeBase = 'knowledge-base'
|
||||
const mockNodes = vi.hoisted(() => [
|
||||
{
|
||||
id: 'ds-1',
|
||||
data: {
|
||||
type: 'datasource',
|
||||
title: 'Local Files',
|
||||
datasource_type: 'upload_file',
|
||||
datasource_configurations: { datasource_label: 'Upload', upload_file_config: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ds-2',
|
||||
data: {
|
||||
type: 'datasource',
|
||||
title: 'Web Crawl',
|
||||
datasource_type: 'website_crawl',
|
||||
datasource_configurations: { datasource_label: 'Crawl' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kb-1',
|
||||
data: {
|
||||
type: 'knowledge-base',
|
||||
title: 'Knowledge Base',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockNodes,
|
||||
}))
|
||||
|
||||
// Mock the Zustand store used by the hooks
|
||||
const mockSetDocumentsData = vi.fn()
|
||||
const mockSetSearchValue = vi.fn()
|
||||
const mockSetSelectedPagesId = vi.fn()
|
||||
const mockSetOnlineDocuments = vi.fn()
|
||||
const mockSetCurrentDocument = vi.fn()
|
||||
const mockSetStep = vi.fn()
|
||||
const mockSetCrawlResult = vi.fn()
|
||||
const mockSetWebsitePages = vi.fn()
|
||||
const mockSetPreviewIndex = vi.fn()
|
||||
const mockSetCurrentWebsite = vi.fn()
|
||||
const mockSetOnlineDriveFileList = vi.fn()
|
||||
const mockSetBucket = vi.fn()
|
||||
const mockSetPrefix = vi.fn()
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetSelectedFileIds = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: () => ({
|
||||
getState: () => ({
|
||||
setDocumentsData: mockSetDocumentsData,
|
||||
setSearchValue: mockSetSearchValue,
|
||||
setSelectedPagesId: mockSetSelectedPagesId,
|
||||
setOnlineDocuments: mockSetOnlineDocuments,
|
||||
setCurrentDocument: mockSetCurrentDocument,
|
||||
setStep: mockSetStep,
|
||||
setCrawlResult: mockSetCrawlResult,
|
||||
setWebsitePages: mockSetWebsitePages,
|
||||
setPreviewIndex: mockSetPreviewIndex,
|
||||
setCurrentWebsite: mockSetCurrentWebsite,
|
||||
setOnlineDriveFileList: mockSetOnlineDriveFileList,
|
||||
setBucket: mockSetBucket,
|
||||
setPrefix: mockSetPrefix,
|
||||
setKeywords: mockSetKeywords,
|
||||
setSelectedFileIds: mockSetSelectedFileIds,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/components/panel/test-run/types', () => ({
|
||||
TestRunStep: {
|
||||
dataSource: 'data_source',
|
||||
documentProcessing: 'document_processing',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
CrawlStep: {
|
||||
init: 'init',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Test Run Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Step Navigation', () => {
|
||||
it('should start at step 1 and navigate forward', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should navigate back from step 2 to step 1', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should provide labeled steps', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.steps).toHaveLength(2)
|
||||
expect(result.current.steps[0].value).toBe('data_source')
|
||||
expect(result.current.steps[1].value).toBe('document_processing')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Datasource Options', () => {
|
||||
it('should filter nodes to only DataSource type', async () => {
|
||||
const { useDatasourceOptions } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
// Should only include DataSource nodes, not KnowledgeBase
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0].value).toBe('ds-1')
|
||||
expect(result.current[1].value).toBe('ds-2')
|
||||
})
|
||||
|
||||
it('should include node data in options', async () => {
|
||||
const { useDatasourceOptions } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current[0].label).toBe('Local Files')
|
||||
expect(result.current[0].data.type).toBe('datasource')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Clearing Flow', () => {
|
||||
it('should clear online document data', async () => {
|
||||
const { useOnlineDocument } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useOnlineDocument())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDocumentData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetSearchValue).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(expect.any(Set))
|
||||
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
|
||||
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should clear website crawl data', async () => {
|
||||
const { useWebsiteCrawl } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useWebsiteCrawl())
|
||||
|
||||
act(() => {
|
||||
result.current.clearWebsiteCrawlData()
|
||||
})
|
||||
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
|
||||
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
|
||||
})
|
||||
|
||||
it('should clear online drive data', async () => {
|
||||
const { useOnlineDrive } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useOnlineDrive())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
expect(mockSetBucket).toHaveBeenCalledWith('')
|
||||
expect(mockSetPrefix).toHaveBeenCalledWith([])
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full Flow Simulation', () => {
|
||||
it('should support complete step navigation cycle', async () => {
|
||||
const { useTestRunSteps } = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
// Start at step 1
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
// Move to step 2
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
// Go back to step 1
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
|
||||
// Move forward again
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should not regress when clearing all data sources in sequence', async () => {
|
||||
const {
|
||||
useOnlineDocument,
|
||||
useWebsiteCrawl,
|
||||
useOnlineDrive,
|
||||
} = await import(
|
||||
'@/app/components/rag-pipeline/components/panel/test-run/preparation/hooks',
|
||||
)
|
||||
const { result: docResult } = renderHook(() => useOnlineDocument())
|
||||
const { result: crawlResult } = renderHook(() => useWebsiteCrawl())
|
||||
const { result: driveResult } = renderHook(() => useOnlineDrive())
|
||||
|
||||
// Clear all data sources
|
||||
act(() => {
|
||||
docResult.current.clearOnlineDocumentData()
|
||||
crawlResult.current.clearWebsiteCrawlData()
|
||||
driveResult.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -204,23 +204,10 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If we have a complete JSON structure but it doesn't parse,
|
||||
// it's likely an error rather than incomplete data
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
// Avoid executing arbitrary code; require valid JSON for chart options.
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,19 +236,9 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
isValidOption = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Both parsing methods failed, but content looks complete
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
}
|
||||
// Only accept JSON to avoid executing arbitrary code from the message.
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
}
|
||||
|
||||
if (isValidOption) {
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config'
|
||||
import { Priority } from '../type'
|
||||
|
||||
describe('Billing Config', () => {
|
||||
describe('Constants', () => {
|
||||
it('should define NUM_INFINITE as -1', () => {
|
||||
expect(NUM_INFINITE).toBe(-1)
|
||||
})
|
||||
|
||||
it('should define contractSales string', () => {
|
||||
expect(contractSales).toBe('contractSales')
|
||||
})
|
||||
|
||||
it('should define unAvailable string', () => {
|
||||
expect(unAvailable).toBe('unAvailable')
|
||||
})
|
||||
|
||||
it('should define valid URL constants', () => {
|
||||
expect(contactSalesUrl).toMatch(/^https:\/\//)
|
||||
expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//)
|
||||
expect(getWithPremiumUrl).toMatch(/^https:\/\//)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALL_PLANS', () => {
|
||||
const requiredFields: (keyof typeof ALL_PLANS.sandbox)[] = [
|
||||
'level',
|
||||
'price',
|
||||
'modelProviders',
|
||||
'teamWorkspace',
|
||||
'teamMembers',
|
||||
'buildApps',
|
||||
'documents',
|
||||
'vectorSpace',
|
||||
'documentsUploadQuota',
|
||||
'documentsRequestQuota',
|
||||
'apiRateLimit',
|
||||
'documentProcessingPriority',
|
||||
'messageRequest',
|
||||
'triggerEvents',
|
||||
'annotatedResponse',
|
||||
'logHistory',
|
||||
]
|
||||
|
||||
it.each(['sandbox', 'professional', 'team'] as const)('should have all required fields for %s plan', (planKey) => {
|
||||
const plan = ALL_PLANS[planKey]
|
||||
for (const field of requiredFields)
|
||||
expect(plan[field]).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have ascending plan levels: sandbox < professional < team', () => {
|
||||
expect(ALL_PLANS.sandbox.level).toBeLessThan(ALL_PLANS.professional.level)
|
||||
expect(ALL_PLANS.professional.level).toBeLessThan(ALL_PLANS.team.level)
|
||||
})
|
||||
|
||||
it('should have ascending plan prices: sandbox < professional < team', () => {
|
||||
expect(ALL_PLANS.sandbox.price).toBeLessThan(ALL_PLANS.professional.price)
|
||||
expect(ALL_PLANS.professional.price).toBeLessThan(ALL_PLANS.team.price)
|
||||
})
|
||||
|
||||
it('should have sandbox as the free plan', () => {
|
||||
expect(ALL_PLANS.sandbox.price).toBe(0)
|
||||
})
|
||||
|
||||
it('should have ascending team member limits', () => {
|
||||
expect(ALL_PLANS.sandbox.teamMembers).toBeLessThan(ALL_PLANS.professional.teamMembers)
|
||||
expect(ALL_PLANS.professional.teamMembers).toBeLessThan(ALL_PLANS.team.teamMembers)
|
||||
})
|
||||
|
||||
it('should have ascending document processing priority', () => {
|
||||
expect(ALL_PLANS.sandbox.documentProcessingPriority).toBe(Priority.standard)
|
||||
expect(ALL_PLANS.professional.documentProcessingPriority).toBe(Priority.priority)
|
||||
expect(ALL_PLANS.team.documentProcessingPriority).toBe(Priority.topPriority)
|
||||
})
|
||||
|
||||
it('should have unlimited API rate limit for professional and team plans', () => {
|
||||
expect(ALL_PLANS.sandbox.apiRateLimit).not.toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.professional.apiRateLimit).toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.team.apiRateLimit).toBe(NUM_INFINITE)
|
||||
})
|
||||
|
||||
it('should have unlimited log history for professional and team plans', () => {
|
||||
expect(ALL_PLANS.professional.logHistory).toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.team.logHistory).toBe(NUM_INFINITE)
|
||||
})
|
||||
|
||||
it('should have unlimited trigger events only for team plan', () => {
|
||||
expect(ALL_PLANS.sandbox.triggerEvents).not.toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.professional.triggerEvents).not.toBe(NUM_INFINITE)
|
||||
expect(ALL_PLANS.team.triggerEvents).toBe(NUM_INFINITE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultPlan', () => {
|
||||
it('should default to sandbox plan type', () => {
|
||||
expect(defaultPlan.type).toBe('sandbox')
|
||||
})
|
||||
|
||||
it('should have usage object with all required fields', () => {
|
||||
const { usage } = defaultPlan
|
||||
expect(usage).toHaveProperty('documents')
|
||||
expect(usage).toHaveProperty('vectorSpace')
|
||||
expect(usage).toHaveProperty('buildApps')
|
||||
expect(usage).toHaveProperty('teamMembers')
|
||||
expect(usage).toHaveProperty('annotatedResponse')
|
||||
expect(usage).toHaveProperty('documentsUploadQuota')
|
||||
expect(usage).toHaveProperty('apiRateLimit')
|
||||
expect(usage).toHaveProperty('triggerEvents')
|
||||
})
|
||||
|
||||
it('should have total object with all required fields', () => {
|
||||
const { total } = defaultPlan
|
||||
expect(total).toHaveProperty('documents')
|
||||
expect(total).toHaveProperty('vectorSpace')
|
||||
expect(total).toHaveProperty('buildApps')
|
||||
expect(total).toHaveProperty('teamMembers')
|
||||
expect(total).toHaveProperty('annotatedResponse')
|
||||
expect(total).toHaveProperty('documentsUploadQuota')
|
||||
expect(total).toHaveProperty('apiRateLimit')
|
||||
expect(total).toHaveProperty('triggerEvents')
|
||||
})
|
||||
|
||||
it('should use sandbox plan API rate limit and trigger events in total', () => {
|
||||
expect(defaultPlan.total.apiRateLimit).toBe(ALL_PLANS.sandbox.apiRateLimit)
|
||||
expect(defaultPlan.total.triggerEvents).toBe(ALL_PLANS.sandbox.triggerEvents)
|
||||
})
|
||||
|
||||
it('should have reset info with null values', () => {
|
||||
expect(defaultPlan.reset.apiRateLimit).toBeNull()
|
||||
expect(defaultPlan.reset.triggerEvents).toBeNull()
|
||||
})
|
||||
|
||||
it('should have usage values not exceeding totals', () => {
|
||||
expect(defaultPlan.usage.documents).toBeLessThanOrEqual(defaultPlan.total.documents)
|
||||
expect(defaultPlan.usage.vectorSpace).toBeLessThanOrEqual(defaultPlan.total.vectorSpace)
|
||||
expect(defaultPlan.usage.buildApps).toBeLessThanOrEqual(defaultPlan.total.buildApps)
|
||||
expect(defaultPlan.usage.teamMembers).toBeLessThanOrEqual(defaultPlan.total.teamMembers)
|
||||
expect(defaultPlan.usage.annotatedResponse).toBeLessThanOrEqual(defaultPlan.total.annotatedResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AnnotationFull from '../index'
|
||||
import AnnotationFull from './index'
|
||||
|
||||
vi.mock('../usage', () => ({
|
||||
vi.mock('./usage', () => ({
|
||||
default: (props: { className?: string }) => {
|
||||
return (
|
||||
<div data-testid="usage-component" data-classname={props.className ?? ''}>
|
||||
@@ -11,7 +11,7 @@ vi.mock('../usage', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: (props: { loc?: string }) => {
|
||||
return (
|
||||
<button type="button" data-testid="upgrade-btn">
|
||||
@@ -29,21 +29,27 @@ describe('AnnotationFull', () => {
|
||||
// Rendering marketing copy with action button
|
||||
describe('Rendering', () => {
|
||||
it('should render tips when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upgrade button when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Usage component when rendered', () => {
|
||||
// Act
|
||||
render(<AnnotationFull />)
|
||||
|
||||
// Assert
|
||||
const usageComponent = screen.getByTestId('usage-component')
|
||||
expect(usageComponent).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AnnotationFullModal from '../modal'
|
||||
import AnnotationFullModal from './modal'
|
||||
|
||||
vi.mock('../usage', () => ({
|
||||
vi.mock('./usage', () => ({
|
||||
default: (props: { className?: string }) => {
|
||||
return (
|
||||
<div data-testid="usage-component" data-classname={props.className ?? ''}>
|
||||
@@ -12,7 +12,7 @@ vi.mock('../usage', () => ({
|
||||
}))
|
||||
|
||||
let mockUpgradeBtnProps: { loc?: string } | null = null
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: (props: { loc?: string }) => {
|
||||
mockUpgradeBtnProps = props
|
||||
return (
|
||||
@@ -29,7 +29,7 @@ type ModalSnapshot = {
|
||||
className?: string
|
||||
}
|
||||
let mockModalProps: ModalSnapshot | null = null
|
||||
vi.mock('../../../base/modal', () => ({
|
||||
vi.mock('../../base/modal', () => ({
|
||||
default: ({ isShow, children, onClose, closable, className }: { isShow: boolean, children: React.ReactNode, onClose: () => void, closable?: boolean, className?: string }) => {
|
||||
mockModalProps = {
|
||||
isShow,
|
||||
@@ -61,8 +61,10 @@ describe('AnnotationFullModal', () => {
|
||||
// Rendering marketing copy inside modal
|
||||
describe('Rendering', () => {
|
||||
it('should display main info when visible', () => {
|
||||
// Act
|
||||
render(<AnnotationFullModal show onHide={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.annotatedResponse.fullTipLine2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('usage-component')).toHaveAttribute('data-classname', 'mt-4')
|
||||
@@ -79,8 +81,10 @@ describe('AnnotationFullModal', () => {
|
||||
// Controlling modal visibility
|
||||
describe('Visibility', () => {
|
||||
it('should not render content when hidden', () => {
|
||||
// Act
|
||||
const { container } = render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(mockModalProps).toEqual(expect.objectContaining({ isShow: false }))
|
||||
})
|
||||
@@ -89,11 +93,14 @@ describe('AnnotationFullModal', () => {
|
||||
// Handling close interactions
|
||||
describe('Close handling', () => {
|
||||
it('should trigger onHide when close control is clicked', () => {
|
||||
// Arrange
|
||||
const onHide = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<AnnotationFullModal show onHide={onHide} />)
|
||||
fireEvent.click(screen.getByTestId('mock-modal-close'))
|
||||
|
||||
// Assert
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Usage from '../usage'
|
||||
import Usage from './usage'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockPlan = {
|
||||
usage: {
|
||||
@@ -17,25 +23,33 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}))
|
||||
|
||||
describe('Usage', () => {
|
||||
// Rendering: renders UsageInfo with correct props from context
|
||||
describe('Rendering', () => {
|
||||
it('should render usage info with data from provider context', () => {
|
||||
// Arrange & Act
|
||||
render(<Usage />)
|
||||
|
||||
expect(screen.getByText('billing.annotatedResponse.quotaTitle')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText('annotatedResponse.quotaTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass className to UsageInfo component', () => {
|
||||
// Arrange
|
||||
const testClassName = 'mt-4'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Usage className={testClassName} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass(testClassName)
|
||||
})
|
||||
|
||||
it('should display usage and total values from context', () => {
|
||||
// Arrange & Act
|
||||
render(<Usage />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('50')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { Plan } from '@/app/components/billing/type'
|
||||
import { mailToSupport } from '@/app/components/header/utils/util'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import AppsFull from '../index'
|
||||
import AppsFull from './index'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
@@ -120,8 +120,10 @@ describe('AppsFull', () => {
|
||||
// Rendering behavior for non-team plans.
|
||||
describe('Rendering', () => {
|
||||
it('should render the sandbox messaging and upgrade button', () => {
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.apps.fullTip1des')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
@@ -129,8 +131,10 @@ describe('AppsFull', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior for team plans and contact CTA.
|
||||
describe('Props', () => {
|
||||
it('should render team messaging and contact button for non-sandbox plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -145,6 +149,7 @@ describe('AppsFull', () => {
|
||||
}))
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.apps.fullTip2des')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
@@ -153,6 +158,7 @@ describe('AppsFull', () => {
|
||||
})
|
||||
|
||||
it('should render upgrade button for professional plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -166,14 +172,17 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.apps.contactUs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render contact button for enterprise plans', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -187,8 +196,10 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.apps.fullTip1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'billing.apps.contactUs' })).toHaveAttribute('href', 'mailto:support@example.com')
|
||||
@@ -196,8 +207,10 @@ describe('AppsFull', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases for progress color thresholds.
|
||||
describe('Edge Cases', () => {
|
||||
it('should use the success color when usage is below 50%', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -211,12 +224,15 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-bar-progress-solid')
|
||||
})
|
||||
|
||||
it('should use the warning color when usage is between 50% and 80%', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -230,12 +246,15 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-warning-progress')
|
||||
})
|
||||
|
||||
it('should use the error color when usage is 80% or higher', () => {
|
||||
// Arrange
|
||||
;(useProviderContext as Mock).mockReturnValue(buildProviderContext({
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
@@ -249,8 +268,10 @@ describe('AppsFull', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
// Act
|
||||
render(<AppsFull loc="billing_dialog" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Billing from '../index'
|
||||
import Billing from './index'
|
||||
|
||||
let currentBillingUrl: string | null = 'https://billing'
|
||||
let fetching = false
|
||||
@@ -33,7 +33,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../plan', () => ({
|
||||
vi.mock('../plan', () => ({
|
||||
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Plan } from '../../type'
|
||||
import HeaderBillingBtn from '../index'
|
||||
import { Plan } from '../type'
|
||||
import HeaderBillingBtn from './index'
|
||||
|
||||
type HeaderGlobal = typeof globalThis & {
|
||||
__mockProviderContext?: ReturnType<typeof vi.fn>
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/context/provider-context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
@@ -70,42 +70,6 @@ describe('HeaderBillingBtn', () => {
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders team badge for team plan with correct styling', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: { type: Plan.team },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
const badge = screen.getByText('team').closest('div')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge).toHaveClass('bg-[#E0EAFF]')
|
||||
})
|
||||
|
||||
it('renders nothing when plan is not fetched', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: { type: Plan.professional },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: false,
|
||||
})
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders sandbox upgrade btn with undefined onClick in display-only mode', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: { type: Plan.sandbox },
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
|
||||
render(<HeaderBillingBtn isDisplayOnly />)
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders plan badge and forwards clicks when not display-only', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import PartnerStack from '../index'
|
||||
import PartnerStack from './index'
|
||||
|
||||
let isCloudEdition = true
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('@/config', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../use-ps-info', () => ({
|
||||
vi.mock('./use-ps-info', () => ({
|
||||
default: () => ({
|
||||
saveOrUpdate,
|
||||
bind,
|
||||
@@ -40,23 +40,4 @@ describe('PartnerStack', () => {
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders null (no visible DOM)', () => {
|
||||
const { container } = render(<PartnerStack />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('does not call helpers again on rerender', () => {
|
||||
const { rerender } = render(<PartnerStack />)
|
||||
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<PartnerStack />)
|
||||
|
||||
// useEffect with [] should not run again on rerender
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import usePSInfo from '../use-ps-info'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
let searchParamsValues: Record<string, string | null> = {}
|
||||
const setSearchParams = (values: Record<string, string | null>) => {
|
||||
@@ -193,107 +193,4 @@ describe('usePSInfo', () => {
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
})
|
||||
|
||||
// Cookie parse failure: covers catch block (L14-16)
|
||||
it('should fall back to empty object when cookie contains invalid JSON', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('not-valid-json{{{')
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams({
|
||||
ps_partner_key: 'from-url',
|
||||
ps_xid: 'click-url',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse partner stack info from cookie:',
|
||||
expect.any(SyntaxError),
|
||||
)
|
||||
// Should still pick up values from search params
|
||||
expect(result.current.psPartnerKey).toBe('from-url')
|
||||
expect(result.current.psClickId).toBe('click-url')
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
// No keys at all: covers saveOrUpdate early return (L30) and bind no-op (L45 false branch)
|
||||
it('should not save or bind when neither search params nor cookie have keys', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBeUndefined()
|
||||
expect(result.current.psClickId).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call mutateAsync when keys are missing during bind', async () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Non-400 error: covers L55 false branch (shouldRemoveCookie stays false)
|
||||
it('should not remove cookie when bind fails with non-400 error', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 500 })
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
const { remove } = ensureCookieMocks()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Fallback to cookie values: covers L19-20 right side of || operator
|
||||
it('should use cookie values when search params are absent', () => {
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'cookie-partner',
|
||||
clickId: 'cookie-click',
|
||||
}))
|
||||
setSearchParams({})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('cookie-partner')
|
||||
expect(result.current.psClickId).toBe('cookie-click')
|
||||
})
|
||||
|
||||
// Partial key missing: only partnerKey present, no clickId
|
||||
it('should not save when only one key is available', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue('{}')
|
||||
setSearchParams({ ps_partner_key: 'partial-key' })
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import PlanUpgradeModal from '../index'
|
||||
import PlanUpgradeModal from './index'
|
||||
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
@@ -39,11 +39,13 @@ describe('PlanUpgradeModal', () => {
|
||||
|
||||
// Rendering and props-driven content
|
||||
it('should render modal with provided content when visible', () => {
|
||||
// Arrange
|
||||
const extraInfoText = 'Additional upgrade details'
|
||||
renderComponent({
|
||||
extraInfo: <div>{extraInfoText}</div>,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(baseProps.title)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseProps.description)).toBeInTheDocument()
|
||||
expect(screen.getByText(extraInfoText)).toBeInTheDocument()
|
||||
@@ -53,32 +55,40 @@ describe('PlanUpgradeModal', () => {
|
||||
|
||||
// Guard against rendering when modal is hidden
|
||||
it('should not render content when show is false', () => {
|
||||
// Act
|
||||
renderComponent({ show: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(baseProps.title)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(baseProps.description)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// User closes the modal from dismiss button
|
||||
it('should call onClose when dismiss button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderComponent({ onClose })
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('billing.triggerLimitModal.dismiss'))
|
||||
|
||||
// Assert
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Upgrade path uses provided callback over pricing modal
|
||||
it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
renderComponent({ onClose, onUpgrade })
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
|
||||
|
||||
// Assert
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
@@ -86,12 +96,15 @@ describe('PlanUpgradeModal', () => {
|
||||
|
||||
// Fallback upgrade path opens pricing modal when no onUpgrade is supplied
|
||||
it('should open pricing modal when upgrade button is clicked without onUpgrade', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderComponent({ onClose, onUpgrade: undefined })
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
|
||||
|
||||
// Assert
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Enterprise from '../enterprise'
|
||||
import Enterprise from './enterprise'
|
||||
|
||||
describe('Enterprise Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import EnterpriseDirect from '../enterprise'
|
||||
import EnterpriseDirect from './enterprise'
|
||||
|
||||
import { Enterprise, Professional, Sandbox, Team } from '../index'
|
||||
import ProfessionalDirect from '../professional'
|
||||
import { Enterprise, Professional, Sandbox, Team } from './index'
|
||||
import ProfessionalDirect from './professional'
|
||||
// Import real components for comparison
|
||||
import SandboxDirect from '../sandbox'
|
||||
import TeamDirect from '../team'
|
||||
import SandboxDirect from './sandbox'
|
||||
import TeamDirect from './team'
|
||||
|
||||
describe('Billing Plan Assets - Integration Tests', () => {
|
||||
describe('Exports', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Professional from '../professional'
|
||||
import Professional from './professional'
|
||||
|
||||
describe('Professional Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Sandbox from '../sandbox'
|
||||
import Sandbox from './sandbox'
|
||||
|
||||
describe('Sandbox Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import Team from '../team'
|
||||
import Team from './team'
|
||||
|
||||
describe('Team Icon Component', () => {
|
||||
describe('Rendering', () => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
|
||||
import { Plan, SelfHostedPlan } from '../../type'
|
||||
import PlanComp from '../index'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
|
||||
let currentPath = '/billing'
|
||||
|
||||
@@ -14,7 +14,8 @@ vi.mock('next/navigation', () => ({
|
||||
|
||||
const setShowAccountSettingModalMock = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: (selector: (state: { setShowAccountSettingModal: typeof setShowAccountSettingModalMock }) => unknown) => selector({
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
useModalContextSelector: (selector: any) => selector({
|
||||
setShowAccountSettingModal: setShowAccountSettingModalMock,
|
||||
}),
|
||||
}))
|
||||
@@ -46,10 +47,11 @@ const verifyStateModalMock = vi.fn(props => (
|
||||
</div>
|
||||
))
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
default: (props: { isShow: boolean, title?: string, content?: string, email?: string, showLink?: boolean, onConfirm?: () => void, onCancel?: () => void }) => verifyStateModalMock(props),
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => verifyStateModalMock(props),
|
||||
}))
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
@@ -170,66 +172,6 @@ describe('PlanComp', () => {
|
||||
expect(screen.getByText('education.toVerified')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders enterprise plan without upgrade button', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: { ...planMock, type: SelfHostedPlan.enterprise },
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.getByText('billing.plans.enterprise.name')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plan-upgrade-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows apiRateLimit reset info for sandbox plan', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: {
|
||||
...planMock,
|
||||
type: Plan.sandbox,
|
||||
total: { ...planMock.total, apiRateLimit: 5000 },
|
||||
reset: { ...planMock.reset, apiRateLimit: null },
|
||||
},
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
// Sandbox plan with finite apiRateLimit and null reset uses getDaysUntilEndOfMonth()
|
||||
expect(screen.getByText('billing.plans.sandbox.name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows apiRateLimit reset info when reset is a number', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: {
|
||||
...planMock,
|
||||
type: Plan.professional,
|
||||
total: { ...planMock.total, apiRateLimit: 5000 },
|
||||
reset: { ...planMock.reset, apiRateLimit: 3 },
|
||||
},
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show education verify when enableEducationPlan is false', () => {
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: planMock,
|
||||
enableEducationPlan: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.queryByText('education.toVerified')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles modal onConfirm and onCancel callbacks', async () => {
|
||||
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
@@ -1,81 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import {
|
||||
Cloud,
|
||||
Community,
|
||||
Enterprise,
|
||||
EnterpriseNoise,
|
||||
NoiseBottom,
|
||||
NoiseTop,
|
||||
Premium,
|
||||
PremiumNoise,
|
||||
Professional,
|
||||
Sandbox,
|
||||
SelfHosted,
|
||||
Team,
|
||||
} from '../index'
|
||||
|
||||
// Static SVG components (no props)
|
||||
describe('Static Pricing Asset Components', () => {
|
||||
const staticComponents = [
|
||||
{ name: 'Community', Component: Community },
|
||||
{ name: 'Enterprise', Component: Enterprise },
|
||||
{ name: 'EnterpriseNoise', Component: EnterpriseNoise },
|
||||
{ name: 'NoiseBottom', Component: NoiseBottom },
|
||||
{ name: 'NoiseTop', Component: NoiseTop },
|
||||
{ name: 'Premium', Component: Premium },
|
||||
{ name: 'PremiumNoise', Component: PremiumNoise },
|
||||
{ name: 'Professional', Component: Professional },
|
||||
{ name: 'Sandbox', Component: Sandbox },
|
||||
{ name: 'Team', Component: Team },
|
||||
]
|
||||
|
||||
it.each(staticComponents)('$name should render an SVG element', ({ Component }) => {
|
||||
const { container } = render(<Component />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(staticComponents)('$name should render without errors on rerender', ({ Component }) => {
|
||||
const { container, rerender } = render(<Component />)
|
||||
rerender(<Component />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interactive SVG components with isActive prop
|
||||
describe('Cloud', () => {
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<Cloud isActive={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use primary color when inactive', () => {
|
||||
const { container } = render(<Cloud isActive={false} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should use accent color when active', () => {
|
||||
const { container } = render(<Cloud isActive={true} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelfHosted', () => {
|
||||
it('should render an SVG element', () => {
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use primary color when inactive', () => {
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-text-primary)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should use accent color when active', () => {
|
||||
const { container } = render(<SelfHosted isActive={true} />)
|
||||
const rects = container.querySelectorAll('rect[fill="var(--color-saas-dify-blue-accessible)"]')
|
||||
expect(rects.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
Sandbox,
|
||||
SelfHosted,
|
||||
Team,
|
||||
} from '../index'
|
||||
} from './index'
|
||||
|
||||
describe('Pricing Assets', () => {
|
||||
// Rendering: each asset should render an svg.
|
||||
describe('Rendering', () => {
|
||||
it('should render static assets without crashing', () => {
|
||||
// Arrange
|
||||
const assets = [
|
||||
<Community key="community" />,
|
||||
<Enterprise key="enterprise" />,
|
||||
@@ -42,29 +44,37 @@ describe('Pricing Assets', () => {
|
||||
// Props: active state should change fill color for selectable assets.
|
||||
describe('Props', () => {
|
||||
it('should render active state for Cloud', () => {
|
||||
// Arrange
|
||||
const { container } = render(<Cloud isActive />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render inactive state for Cloud', () => {
|
||||
// Arrange
|
||||
const { container } = render(<Cloud isActive={false} />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render active state for SelfHosted', () => {
|
||||
// Arrange
|
||||
const { container } = render(<SelfHosted isActive />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-saas-dify-blue-accessible)')).toBe(true)
|
||||
})
|
||||
|
||||
it('should render inactive state for SelfHosted', () => {
|
||||
// Arrange
|
||||
const { container } = render(<SelfHosted isActive={false} />)
|
||||
|
||||
// Assert
|
||||
const rects = Array.from(container.querySelectorAll('rect'))
|
||||
expect(rects.some(rect => rect.getAttribute('fill') === 'var(--color-text-primary)')).toBe(true)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '..'
|
||||
import Footer from '../footer'
|
||||
import { CategoryEnum } from '.'
|
||||
import Footer from './footer'
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
@@ -16,10 +16,13 @@ describe('Footer', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render tax tips and comparison link when in cloud category', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.CLOUD} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.taxTipSecond')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
|
||||
@@ -27,19 +30,25 @@ describe('Footer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should hide tax tips when category is self-hosted', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="https://dify.ai/pricing#plans-and-features" currentCategory={CategoryEnum.SELF} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('billing.plansCommon.taxTip')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plansCommon.taxTipSecond')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render link even when pricing URL is empty', () => {
|
||||
// Arrange
|
||||
render(<Footer pricingPageURL="" currentCategory={CategoryEnum.CLOUD} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', '')
|
||||
})
|
||||
})
|
||||
@@ -1,39 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Header from '../header'
|
||||
import Header from './header'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render title and description translations', () => {
|
||||
// Arrange
|
||||
const handleClose = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Header onClose={handleClose} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should invoke onClose when close button is clicked', () => {
|
||||
// Arrange
|
||||
const handleClose = vi.fn()
|
||||
render(<Header onClose={handleClose} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render structural elements with translation keys', () => {
|
||||
it('should render structure when translations are empty strings', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.title.plans': '',
|
||||
'billing.plansCommon.title.description': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<Header onClose={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('span')).toBeInTheDocument()
|
||||
expect(container.querySelector('p')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
@@ -1,24 +1,17 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../../type'
|
||||
import type { UsagePlanInfo } from '../type'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetPricingPageLanguage } from '@/context/i18n'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
import Pricing from '../index'
|
||||
import { Plan } from '../type'
|
||||
import Pricing from './index'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
let mockLanguage: string | null = 'en'
|
||||
|
||||
vi.mock('../plans/self-hosted-plan-item/list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`list-${plan}`}>
|
||||
List for
|
||||
{plan}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
<a href={href} className={className} target={target} data-testid="pricing-link">
|
||||
@@ -27,6 +20,10 @@ vi.mock('next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
@@ -39,6 +36,24 @@ vi.mock('@/context/i18n', () => ({
|
||||
useGetPricingPageLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { returnObjects?: boolean, ns?: string }) => {
|
||||
if (options?.returnObjects)
|
||||
return mockTranslations[key] ?? []
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
}
|
||||
})
|
||||
|
||||
const buildUsage = (): UsagePlanInfo => ({
|
||||
buildApps: 0,
|
||||
teamMembers: 0,
|
||||
@@ -52,6 +67,7 @@ const buildUsage = (): UsagePlanInfo => ({
|
||||
describe('Pricing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
mockLanguage = 'en'
|
||||
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
@@ -64,33 +80,42 @@ describe('Pricing', () => {
|
||||
;(useGetPricingPageLanguage as Mock).mockImplementation(() => mockLanguage)
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render pricing header and localized footer link', () => {
|
||||
// Arrange
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/en/pricing#plans-and-features')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should allow switching categories and handle esc key', () => {
|
||||
it('should register esc key handler and allow switching categories', () => {
|
||||
// Arrange
|
||||
const handleCancel = vi.fn()
|
||||
render(<Pricing onCancel={handleCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
// Assert
|
||||
expect(useKeyPress).toHaveBeenCalledWith(['esc'], handleCancel)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should fall back to default pricing URL when language is empty', () => {
|
||||
// Arrange
|
||||
mockLanguage = ''
|
||||
render(<Pricing onCancel={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('pricing-link')).toHaveAttribute('href', 'https://dify.ai/pricing#plans-and-features')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,36 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { CategoryEnum } from '../../index'
|
||||
import PlanSwitcher from '../index'
|
||||
import { PlanRange } from '../plan-range-switcher'
|
||||
import { CategoryEnum } from '../index'
|
||||
import PlanSwitcher from './index'
|
||||
import { PlanRange } from './plan-range-switcher'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (key in mockTranslations)
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlanSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render category tabs and plan range switcher for cloud', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.CLOUD}
|
||||
@@ -20,14 +40,17 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.cloud')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plansCommon.self')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should call onChangeCategory when selecting a tab', () => {
|
||||
// Arrange
|
||||
const handleChangeCategory = vi.fn()
|
||||
render(
|
||||
<PlanSwitcher
|
||||
@@ -38,13 +61,16 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('billing.plansCommon.self'))
|
||||
|
||||
// Assert
|
||||
expect(handleChangeCategory).toHaveBeenCalledTimes(1)
|
||||
expect(handleChangeCategory).toHaveBeenCalledWith(CategoryEnum.SELF)
|
||||
})
|
||||
|
||||
it('should hide plan range switcher when category is self-hosted', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
@@ -54,12 +80,21 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render tabs with translation keys', () => {
|
||||
it('should render tabs when translation strings are empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'plansCommon.cloud': '',
|
||||
'plansCommon.self': '',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<PlanSwitcher
|
||||
currentCategory={CategoryEnum.SELF}
|
||||
@@ -69,10 +104,11 @@ describe('PlanSwitcher', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labels = container.querySelectorAll('span')
|
||||
expect(labels).toHaveLength(2)
|
||||
expect(labels[0]?.textContent).toBe('billing.plansCommon.cloud')
|
||||
expect(labels[1]?.textContent).toBe('billing.plansCommon.self')
|
||||
expect(labels[0]?.textContent).toBe('')
|
||||
expect(labels[1]?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +1,86 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PlanRangeSwitcher, { PlanRange } from '../plan-range-switcher'
|
||||
import PlanRangeSwitcher, { PlanRange } from './plan-range-switcher'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlanRangeSwitcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render the annual billing label', () => {
|
||||
// Arrange
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText(/billing\.plansCommon\.annualBilling/)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.annualBilling')).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should switch to yearly when toggled from monthly', () => {
|
||||
// Arrange
|
||||
const handleChange = vi.fn()
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={handleChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(PlanRange.yearly)
|
||||
})
|
||||
|
||||
it('should switch to monthly when toggled from yearly', () => {
|
||||
// Arrange
|
||||
const handleChange = vi.fn()
|
||||
render(<PlanRangeSwitcher value={PlanRange.yearly} onChange={handleChange} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Assert
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleChange).toHaveBeenCalledWith(PlanRange.monthly)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render label with translation key and params', () => {
|
||||
render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
it('should render when the translation string is empty', () => {
|
||||
// Arrange
|
||||
mockTranslations = {
|
||||
'billing.plansCommon.annualBilling': '',
|
||||
}
|
||||
|
||||
const label = screen.getByText(/billing\.plansCommon\.annualBilling/)
|
||||
// Act
|
||||
const { container } = render(<PlanRangeSwitcher value={PlanRange.monthly} onChange={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label.textContent).toContain('percent')
|
||||
expect(label?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Tab from '../tab'
|
||||
import Tab from './tab'
|
||||
|
||||
const Icon = ({ isActive }: { isActive: boolean }) => (
|
||||
<svg data-testid="tab-icon" data-active={isActive ? 'true' : 'false'} />
|
||||
@@ -11,8 +11,10 @@ describe('PlanSwitcherTab', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
describe('Rendering', () => {
|
||||
it('should render label and icon', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@@ -23,13 +25,16 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Cloud')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
// Prop-driven behavior
|
||||
describe('Props', () => {
|
||||
it('should call onClick with the provided value', () => {
|
||||
// Arrange
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Tab
|
||||
@@ -41,13 +46,16 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('Self'))
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(handleClick).toHaveBeenCalledWith('self')
|
||||
})
|
||||
|
||||
it('should apply active text class when isActive is true', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@@ -58,13 +66,16 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Cloud')).toHaveClass('text-saas-dify-blue-accessible')
|
||||
expect(screen.getByTestId('tab-icon')).toHaveAttribute('data-active', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge case rendering behavior
|
||||
describe('Edge Cases', () => {
|
||||
it('should render when label is empty', () => {
|
||||
// Arrange
|
||||
const { container } = render(
|
||||
<Tab
|
||||
Icon={Icon}
|
||||
@@ -75,6 +86,7 @@ describe('PlanSwitcherTab', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('span')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('')
|
||||
@@ -1,12 +1,13 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Plan } from '../../../../type'
|
||||
import Button from '../button'
|
||||
import { Plan } from '../../../type'
|
||||
import Button from './button'
|
||||
|
||||
describe('CloudPlanButton', () => {
|
||||
describe('Disabled state', () => {
|
||||
it('should disable button and hide arrow when plan is not available', () => {
|
||||
const handleGetPayUrl = vi.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<Button
|
||||
plan={Plan.team}
|
||||
@@ -17,6 +18,7 @@ describe('CloudPlanButton', () => {
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Get started/i })
|
||||
// Assert
|
||||
expect(button).toBeDisabled()
|
||||
expect(button.className).toContain('cursor-not-allowed')
|
||||
expect(handleGetPayUrl).not.toHaveBeenCalled()
|
||||
@@ -26,6 +28,7 @@ describe('CloudPlanButton', () => {
|
||||
describe('Enabled state', () => {
|
||||
it('should invoke handler and render arrow when plan is available', () => {
|
||||
const handleGetPayUrl = vi.fn()
|
||||
// Arrange
|
||||
render(
|
||||
<Button
|
||||
plan={Plan.sandbox}
|
||||
@@ -36,8 +39,10 @@ describe('CloudPlanButton', () => {
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /Start now/i })
|
||||
// Act
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleGetPayUrl).toHaveBeenCalledTimes(1)
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
@@ -5,13 +5,13 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { fetchSubscriptionUrls } from '@/service/billing'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import Toast from '../../../../../base/toast'
|
||||
import { ALL_PLANS } from '../../../../config'
|
||||
import { Plan } from '../../../../type'
|
||||
import { PlanRange } from '../../../plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from '../index'
|
||||
import Toast from '../../../../base/toast'
|
||||
import { ALL_PLANS } from '../../../config'
|
||||
import { Plan } from '../../../type'
|
||||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import CloudPlanItem from './index'
|
||||
|
||||
vi.mock('../../../../../base/toast', () => ({
|
||||
vi.mock('../../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
@@ -37,7 +37,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../assets', () => ({
|
||||
vi.mock('../../assets', () => ({
|
||||
Sandbox: () => <div>Sandbox Icon</div>,
|
||||
Professional: () => <div>Professional Icon</div>,
|
||||
Team: () => <div>Team Icon</div>,
|
||||
@@ -66,6 +66,13 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
@@ -75,13 +82,6 @@ beforeEach(() => {
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
describe('CloudPlanItem', () => {
|
||||
// Static content for each plan
|
||||
describe('Rendering', () => {
|
||||
@@ -117,32 +117,6 @@ describe('CloudPlanItem', () => {
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.year/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "most popular" badge for professional plan', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show "most popular" badge for non-professional plans', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('billing.plansCommon.mostPopular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable CTA when workspace already on higher tier', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
@@ -218,128 +192,5 @@ describe('CloudPlanItem', () => {
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L92-93: isFreePlan guard inside handleGetPayUrl
|
||||
it('should do nothing when clicking sandbox plan CTA that is not the current plan', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.sandbox}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
// Sandbox viewed from a higher plan is disabled, but let's verify no API calls
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
|
||||
expect(mockBillingInvoices).not.toHaveBeenCalled()
|
||||
expect(assignedHref).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L95: yearly subscription URL ('year' parameter)
|
||||
it('should fetch yearly subscription url when planRange is yearly', async () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.yearly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
|
||||
expect(assignedHref).toBe('https://subscription.example')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L62-63: loading guard prevents double click
|
||||
it('should ignore second click while loading', async () => {
|
||||
// Make the first fetch hang until we resolve it
|
||||
let resolveFirst!: (v: { url: string }) => void
|
||||
mockFetchSubscriptionUrls.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveFirst = resolve }),
|
||||
)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })
|
||||
|
||||
// First click starts loading
|
||||
fireEvent.click(button)
|
||||
// Second click while loading should be ignored
|
||||
fireEvent.click(button)
|
||||
|
||||
// Resolve first request
|
||||
resolveFirst({ url: 'https://first.example' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSubscriptionUrls).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Covers L82-83, L85-87: openAsyncWindow error path when invoices returns no url
|
||||
it('should invoke onError when billing invoices returns empty url', async () => {
|
||||
mockBillingInvoices.mockResolvedValue({ url: '' })
|
||||
const openWindow = vi.fn(async (cb: () => Promise<string>, opts: { onError?: (e: Error) => void }) => {
|
||||
try {
|
||||
await cb()
|
||||
}
|
||||
catch (e) {
|
||||
opts.onError?.(e as Error)
|
||||
}
|
||||
})
|
||||
mockUseAsyncWindowOpen.mockReturnValue(openWindow)
|
||||
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.professional}
|
||||
currentPlan={Plan.professional}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openWindow).toHaveBeenCalledTimes(1)
|
||||
// The onError callback should have been passed to openAsyncWindow
|
||||
const callArgs = openWindow.mock.calls[0]
|
||||
expect(callArgs[1]).toHaveProperty('onError')
|
||||
})
|
||||
})
|
||||
|
||||
// Covers monthly price display (L139 !isYear branch for price)
|
||||
it('should display monthly pricing without discount', () => {
|
||||
render(
|
||||
<CloudPlanItem
|
||||
plan={Plan.team}
|
||||
currentPlan={Plan.sandbox}
|
||||
planRange={PlanRange.monthly}
|
||||
canPay
|
||||
/>,
|
||||
)
|
||||
|
||||
const teamPlan = ALL_PLANS[Plan.team]
|
||||
expect(screen.getByText(`$${teamPlan.price}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.plansCommon\.priceTip.*billing\.plansCommon\.month/)).toBeInTheDocument()
|
||||
// Should NOT show crossed-out yearly price
|
||||
expect(screen.queryByText(`$${teamPlan.price * 12}`)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Plan } from '../../../../../type'
|
||||
import List from '../index'
|
||||
import { Plan } from '../../../../type'
|
||||
import List from './index'
|
||||
|
||||
describe('CloudPlanItem/List', () => {
|
||||
it('should show sandbox specific quotas', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Item from '../index'
|
||||
import Item from './index'
|
||||
|
||||
describe('Item', () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,10 +9,13 @@ describe('Item', () => {
|
||||
// Rendering the plan item row
|
||||
describe('Rendering', () => {
|
||||
it('should render the provided label when tooltip is absent', () => {
|
||||
// Arrange
|
||||
const label = 'Monthly credits'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).toBeNull()
|
||||
})
|
||||
@@ -21,21 +24,27 @@ describe('Item', () => {
|
||||
// Toggling the optional tooltip indicator
|
||||
describe('Tooltip behavior', () => {
|
||||
it('should render tooltip content when tooltip text is provided', () => {
|
||||
// Arrange
|
||||
const label = 'Workspace seats'
|
||||
const tooltip = 'Seats define how many teammates can join the workspace.'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} tooltip={tooltip} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByText(tooltip)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should treat an empty tooltip string as absent', () => {
|
||||
// Arrange
|
||||
const label = 'Vector storage'
|
||||
|
||||
// Act
|
||||
const { container } = render(<Item label={label} tooltip="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(container.querySelector('.group')).toBeNull()
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Tooltip from '../tooltip'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
describe('Tooltip', () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,20 +9,26 @@ describe('Tooltip', () => {
|
||||
// Rendering the info tooltip container
|
||||
describe('Rendering', () => {
|
||||
it('should render the content panel when provide with text', () => {
|
||||
// Arrange
|
||||
const content = 'Usage resets on the first day of every month.'
|
||||
|
||||
// Act
|
||||
render(<Tooltip content={content} />)
|
||||
|
||||
// Assert
|
||||
expect(() => screen.getByText(content)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon rendering', () => {
|
||||
it('should render the icon when provided with content', () => {
|
||||
// Arrange
|
||||
const content = 'Tooltips explain each plan detail.'
|
||||
|
||||
// Act
|
||||
render(<Tooltip content={content} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tooltip-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -30,6 +36,7 @@ describe('Tooltip', () => {
|
||||
// Handling empty strings while keeping structure consistent
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when passed empty content', () => {
|
||||
// Arrange
|
||||
const content = ''
|
||||
|
||||
// Act and Assert
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { UsagePlanInfo } from '../../../type'
|
||||
import type { UsagePlanInfo } from '../../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { Plan } from '../../../type'
|
||||
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
|
||||
import cloudPlanItem from '../cloud-plan-item'
|
||||
import Plans from '../index'
|
||||
import selfHostedPlanItem from '../self-hosted-plan-item'
|
||||
import { Plan } from '../../type'
|
||||
import { PlanRange } from '../plan-switcher/plan-range-switcher'
|
||||
import cloudPlanItem from './cloud-plan-item'
|
||||
import Plans from './index'
|
||||
import selfHostedPlanItem from './self-hosted-plan-item'
|
||||
|
||||
vi.mock('../cloud-plan-item', () => ({
|
||||
vi.mock('./cloud-plan-item', () => ({
|
||||
default: vi.fn(props => (
|
||||
<div data-testid={`cloud-plan-${props.plan}`} data-current-plan={props.currentPlan}>
|
||||
Cloud
|
||||
@@ -18,7 +18,7 @@ vi.mock('../cloud-plan-item', () => ({
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('../self-hosted-plan-item', () => ({
|
||||
vi.mock('./self-hosted-plan-item', () => ({
|
||||
default: vi.fn(props => (
|
||||
<div data-testid={`self-plan-${props.plan}`}>
|
||||
Self
|
||||
@@ -3,8 +3,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import { SelfHostedPlan } from '../../../../type'
|
||||
import Button from '../button'
|
||||
import { SelfHostedPlan } from '../../../type'
|
||||
import Button from './button'
|
||||
|
||||
vi.mock('@/hooks/use-theme')
|
||||
|
||||
@@ -2,21 +2,30 @@ import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import Toast from '../../../../../base/toast'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config'
|
||||
import { SelfHostedPlan } from '../../../../type'
|
||||
import SelfHostedPlanItem from '../index'
|
||||
import Toast from '../../../../base/toast'
|
||||
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config'
|
||||
import { SelfHostedPlan } from '../../../type'
|
||||
import SelfHostedPlanItem from './index'
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
default: ({ plan }: { plan: string }) => (
|
||||
<div data-testid={`list-${plan}`}>
|
||||
List for
|
||||
{plan}
|
||||
</div>
|
||||
),
|
||||
const featuresTranslations: Record<string, string[]> = {
|
||||
'billing.plans.community.features': ['community-feature-1', 'community-feature-2'],
|
||||
'billing.plans.premium.features': ['premium-feature-1'],
|
||||
'billing.plans.enterprise.features': ['enterprise-feature-1'],
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
if (options?.returnObjects)
|
||||
return featuresTranslations[`${prefix}${key}`] || []
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../../../base/toast', () => ({
|
||||
vi.mock('../../../../base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
@@ -26,7 +35,7 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../assets', () => ({
|
||||
vi.mock('../../assets', () => ({
|
||||
Community: () => <div>Community Icon</div>,
|
||||
Premium: () => <div>Premium Icon</div>,
|
||||
Enterprise: () => <div>Enterprise Icon</div>,
|
||||
@@ -54,12 +63,6 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
@@ -67,7 +70,14 @@ afterAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
|
||||
assignedHref = ''
|
||||
})
|
||||
|
||||
describe('SelfHostedPlanItem', () => {
|
||||
// Copy rendering for each plan
|
||||
describe('Rendering', () => {
|
||||
it('should display community plan info', () => {
|
||||
render(<SelfHostedPlanItem plan={SelfHostedPlan.community} />)
|
||||
@@ -75,7 +85,8 @@ describe('SelfHostedPlanItem', () => {
|
||||
expect(screen.getByText('billing.plans.community.name')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.price')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('list-community')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('community-feature-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show premium extras such as cloud provider notice', () => {
|
||||
@@ -86,6 +97,7 @@ describe('SelfHostedPlanItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// CTA behavior for each plan
|
||||
describe('CTA interactions', () => {
|
||||
it('should show toast when non-manager tries to proceed', () => {
|
||||
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
|
||||
@@ -1,20 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import List from '../index'
|
||||
|
||||
// Override global i18n mock to support returnObjects: true for feature arrays
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'billing.plans.community.features': ['Feature A', 'Feature B'],
|
||||
}))
|
||||
|
||||
describe('SelfHostedPlanItem/List', () => {
|
||||
it('should render plan info', () => {
|
||||
render(<List plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText('plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Item from '../item'
|
||||
|
||||
describe('SelfHostedPlanItem/List/Item', () => {
|
||||
it('should display provided feature label', () => {
|
||||
const { container } = render(<Item label="Dedicated support" />)
|
||||
|
||||
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should render the check icon', () => {
|
||||
const { container } = render(<Item label="Custom branding" />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(svg).toHaveClass('size-4')
|
||||
})
|
||||
|
||||
it('should render different labels correctly', () => {
|
||||
const { rerender } = render(<Item label="Feature A" />)
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
|
||||
rerender(<Item label="Feature B" />)
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Feature A')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty label', () => {
|
||||
const { container } = render(<Item label="" />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { SelfHostedPlan } from '@/app/components/billing/type'
|
||||
import List from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return ['Feature A', 'Feature B']
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, ns }: { i18nKey: string, ns?: string }) => <span>{ns ? `${ns}.${i18nKey}` : i18nKey}</span>,
|
||||
}))
|
||||
|
||||
describe('SelfHostedPlanItem/List', () => {
|
||||
it('should render plan info', () => {
|
||||
render(<List plan={SelfHostedPlan.community} />)
|
||||
|
||||
expect(screen.getByText('billing.plans.community.includesTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Item from './item'
|
||||
|
||||
describe('SelfHostedPlanItem/List/Item', () => {
|
||||
it('should display provided feature label', () => {
|
||||
const { container } = render(<Item label="Dedicated support" />)
|
||||
|
||||
expect(screen.getByText('Dedicated support')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@ import type { Mock } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createMockPlan } from '@/__mocks__/provider-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { Plan } from '../../type'
|
||||
import PriorityLabel from '../index'
|
||||
import { Plan } from '../type'
|
||||
import PriorityLabel from './index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
@@ -20,12 +20,16 @@ describe('PriorityLabel', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: basic label output for sandbox plan.
|
||||
describe('Rendering', () => {
|
||||
it('should render the standard priority label when plan is sandbox', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -33,10 +37,13 @@ describe('PriorityLabel', () => {
|
||||
// Props: custom class name applied to the label container.
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to the label container', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
expect(label).toHaveClass('custom-class')
|
||||
})
|
||||
@@ -45,53 +52,54 @@ describe('PriorityLabel', () => {
|
||||
// Plan types: label text and icon visibility for different plans.
|
||||
describe('Plan Types', () => {
|
||||
it('should render priority label and icon when plan is professional', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.professional)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render top priority label and icon when plan is team', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.team)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render standard label without icon when plan is sandbox', () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Enterprise plan tests
|
||||
describe('Enterprise Plan', () => {
|
||||
it('should render top-priority label with icon for enterprise plan', () => {
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: tooltip content varies by priority level.
|
||||
describe('Edge Cases', () => {
|
||||
it('should show the tip text when priority is not top priority', async () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.sandbox)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
|
||||
)).toBeInTheDocument()
|
||||
@@ -99,12 +107,15 @@ describe('PriorityLabel', () => {
|
||||
})
|
||||
|
||||
it('should hide the tip text when priority is top priority', async () => {
|
||||
// Arrange
|
||||
setupPlan(Plan.enterprise)
|
||||
|
||||
// Act
|
||||
render(<PriorityLabel />)
|
||||
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
|
||||
fireEvent.mouseEnter(label as HTMLElement)
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText(
|
||||
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
|
||||
)).toBeInTheDocument()
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ProgressBar from '../index'
|
||||
import ProgressBar from './index'
|
||||
|
||||
describe('ProgressBar', () => {
|
||||
describe('Normal Mode (determinate)', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import TriggerEventsLimitModal from '../index'
|
||||
import TriggerEventsLimitModal from './index'
|
||||
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpgrade = vi.fn()
|
||||
@@ -16,7 +16,8 @@ const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, descr
|
||||
))
|
||||
|
||||
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
|
||||
default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props),
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => planUpgradeModalMock(props),
|
||||
}))
|
||||
|
||||
describe('TriggerEventsLimitModal', () => {
|
||||
@@ -65,53 +66,4 @@ describe('TriggerEventsLimitModal', () => {
|
||||
expect(planUpgradeModalMock).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
|
||||
})
|
||||
|
||||
it('renders reset info when resetInDays is provided', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={18000}
|
||||
total={20000}
|
||||
resetInDays={7}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('18000')).toBeInTheDocument()
|
||||
expect(screen.getByText('20000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes correct title and description translations', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={0}
|
||||
total={0}
|
||||
/>,
|
||||
)
|
||||
|
||||
const modal = screen.getByTestId('plan-upgrade-modal')
|
||||
expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title')
|
||||
expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description')
|
||||
})
|
||||
|
||||
it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={0}
|
||||
total={0}
|
||||
/>,
|
||||
)
|
||||
|
||||
const passedProps = planUpgradeModalMock.mock.calls[0][0]
|
||||
expect(passedProps.onClose).toBe(mockOnClose)
|
||||
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UpgradeBtn from '../index'
|
||||
import UpgradeBtn from './index'
|
||||
|
||||
// ✅ Import real project components (DO NOT mock these)
|
||||
// PremiumBadge, Button, SparklesSoft are all base components
|
||||
@@ -14,117 +14,146 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Typed window accessor for gtag tracking tests
|
||||
const gtagWindow = window as unknown as Record<string, Mock | undefined>
|
||||
// Mock gtag for tracking tests
|
||||
let mockGtag: Mock | undefined
|
||||
|
||||
describe('UpgradeBtn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGtag = vi.fn()
|
||||
gtagWindow.gtag = mockGtag
|
||||
;(window as any).gtag = mockGtag
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete gtagWindow.gtag
|
||||
delete (window as any).gtag
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing with default props', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - should render with default text
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render premium badge by default', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - PremiumBadge renders with text content
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain button when isPlain is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
// Assert - Button should be rendered with plain text
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render short text when isShort is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isShort />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label when labelKey is provided', () => {
|
||||
render(<UpgradeBtn labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={'custom.label.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
|
||||
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey={'custom.label.key' as any} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom\.label\.key/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to premium badge', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-upgrade-btn'
|
||||
|
||||
// Act
|
||||
const { container } = render(<UpgradeBtn className={customClass} />)
|
||||
|
||||
// Assert - Check the root element has the custom class
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should apply custom className to plain button', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-button-class'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain className={customClass} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should apply custom style to premium badge', () => {
|
||||
// Arrange
|
||||
const customStyle = { padding: '10px' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<UpgradeBtn style={customStyle} />)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should apply custom style to plain button', () => {
|
||||
// Arrange
|
||||
const customStyle = { margin: '5px' }
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain style={customStyle} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should render with size "s"', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn size="s" />)
|
||||
|
||||
// Assert - Component renders successfully with size prop
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size "m" by default', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - Component renders successfully
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size "custom"', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn size="custom" />)
|
||||
|
||||
// Assert - Component renders successfully with custom size
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -132,57 +161,72 @@ describe('UpgradeBtn', () => {
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call custom onClick when provided and premium badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call custom onClick when provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should track gtag event when loc is provided and badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const loc = 'header-navigation'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc={loc} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
@@ -190,13 +234,16 @@ describe('UpgradeBtn', () => {
|
||||
})
|
||||
|
||||
it('should track gtag event when loc is provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const loc = 'footer-section'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain loc={loc} />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
@@ -204,35 +251,44 @@ describe('UpgradeBtn', () => {
|
||||
})
|
||||
|
||||
it('should not track gtag event when loc is not provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not track gtag event when gtag is not available', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
delete gtagWindow.gtag
|
||||
delete (window as any).gtag
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc="test-location" />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - should not throw error
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call both custom onClick and track gtag when both are provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
const loc = 'settings-page'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} loc={loc} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
@@ -244,95 +300,121 @@ describe('UpgradeBtn', () => {
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined className', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn className={undefined} />)
|
||||
|
||||
// Assert - should render without error
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined style', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn style={undefined} />)
|
||||
|
||||
// Assert - should render without error
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onClick', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={undefined} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - should fall back to setShowPricingModal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle undefined loc', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc={undefined} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - should not attempt to track gtag
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle undefined labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={undefined} />)
|
||||
|
||||
// Assert - should use default label
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn className="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string loc', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc="" />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - empty loc should not trigger gtag
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle labelKey with isShort - labelKey takes precedence', () => {
|
||||
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
|
||||
it('should handle empty string labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={'' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
// Assert - empty labelKey is falsy, so it falls back to default label
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop Combinations
|
||||
describe('Prop Combinations', () => {
|
||||
it('should handle isPlain with isShort', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain isShort />)
|
||||
|
||||
// Assert - isShort should not affect plain button text
|
||||
expect(screen.getByText(/billing\.upgradeBtn\.plain/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isPlain with custom labelKey', () => {
|
||||
render(<UpgradeBtn isPlain labelKey="triggerLimitModal.upgrade" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey={'custom.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.upgrade/i)).toBeInTheDocument()
|
||||
// Assert - labelKey should override plain text
|
||||
expect(screen.getByText(/custom\.key/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.plain/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isShort with custom labelKey', () => {
|
||||
render(<UpgradeBtn isShort labelKey="triggerLimitModal.title" />)
|
||||
// Act
|
||||
render(<UpgradeBtn isShort labelKey={'custom.short.key' as any} />)
|
||||
|
||||
expect(screen.getByText(/triggerLimitModal\.title/i)).toBeInTheDocument()
|
||||
// Assert - labelKey should override isShort behavior
|
||||
expect(screen.getByText(/custom\.short\.key/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/billing\.upgradeBtn\.encourageShort/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all custom props together', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
const customStyle = { margin: '10px' }
|
||||
const customClass = 'all-custom'
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<UpgradeBtn
|
||||
className={customClass}
|
||||
@@ -341,16 +423,17 @@ describe('UpgradeBtn', () => {
|
||||
isShort
|
||||
onClick={handleClick}
|
||||
loc="test-loc"
|
||||
labelKey="triggerLimitModal.description"
|
||||
labelKey={'custom.all' as any}
|
||||
/>,
|
||||
)
|
||||
const badge = screen.getByText(/triggerLimitModal\.description/i)
|
||||
const badge = screen.getByText(/custom\.all/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
expect(screen.getByText(/triggerLimitModal\.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom\.all/i)).toBeInTheDocument()
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc: 'test-loc',
|
||||
@@ -361,9 +444,11 @@ describe('UpgradeBtn', () => {
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should be keyboard accessible with plain button', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
@@ -374,38 +459,47 @@ describe('UpgradeBtn', () => {
|
||||
// Press Enter
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be keyboard accessible with Space key', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
|
||||
// Tab to button and press Space
|
||||
await user.tab()
|
||||
await user.keyboard(' ')
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be clickable for premium badge variant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
|
||||
// Click badge
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should have proper button role when isPlain is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
// Assert - Plain button should have button role
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
@@ -414,25 +508,31 @@ describe('UpgradeBtn', () => {
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should work with modal context for pricing modal', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should integrate onClick with analytics tracking', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
|
||||
const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i)
|
||||
await user.click(badge)
|
||||
|
||||
// Assert - Both onClick and gtag should be called
|
||||
await waitFor(() => {
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
@@ -1,67 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../../config'
|
||||
import AppsInfo from '../apps-info'
|
||||
|
||||
const mockProviderContext = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext(),
|
||||
}))
|
||||
|
||||
describe('AppsInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
usage: { ...defaultPlan.usage, buildApps: 7 },
|
||||
total: { ...defaultPlan.total, buildApps: 15 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders build apps usage information with context data', () => {
|
||||
render(<AppsInfo className="apps-info-class" />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
expect(screen.getByText('15')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without className', () => {
|
||||
render(<AppsInfo />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders zero usage correctly', () => {
|
||||
mockProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
usage: { ...defaultPlan.usage, buildApps: 0 },
|
||||
total: { ...defaultPlan.total, buildApps: 5 },
|
||||
},
|
||||
})
|
||||
|
||||
render(<AppsInfo />)
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders when usage equals total (at capacity)', () => {
|
||||
mockProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
usage: { ...defaultPlan.usage, buildApps: 10 },
|
||||
total: { ...defaultPlan.total, buildApps: 10 },
|
||||
},
|
||||
})
|
||||
|
||||
render(<AppsInfo />)
|
||||
|
||||
const tens = screen.getAllByText('10')
|
||||
expect(tens.length).toBe(2)
|
||||
})
|
||||
})
|
||||
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../config'
|
||||
import AppsInfo from './apps-info'
|
||||
|
||||
const appsUsage = 7
|
||||
const appsTotal = 15
|
||||
|
||||
const mockPlan = {
|
||||
...defaultPlan,
|
||||
usage: {
|
||||
...defaultPlan.usage,
|
||||
buildApps: appsUsage,
|
||||
},
|
||||
total: {
|
||||
...defaultPlan.total,
|
||||
buildApps: appsTotal,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: mockPlan,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AppsInfo', () => {
|
||||
it('renders build apps usage information with context data', () => {
|
||||
render(<AppsInfo className="apps-info-class" />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NUM_INFINITE } from '../../config'
|
||||
import UsageInfo from '../index'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
import UsageInfo from './index'
|
||||
|
||||
const TestIcon = () => <span data-testid="usage-icon" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../../config'
|
||||
import { Plan } from '../../type'
|
||||
import VectorSpaceInfo from '../vector-space-info'
|
||||
import { defaultPlan } from '../config'
|
||||
import { Plan } from '../type'
|
||||
import VectorSpaceInfo from './vector-space-info'
|
||||
|
||||
// Mock provider context with configurable plan
|
||||
let mockPlanType = Plan.sandbox
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CurrentPlanInfoBackend } from '../../type'
|
||||
import { DocumentProcessingPriority, Plan } from '../../type'
|
||||
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from '../index'
|
||||
import type { CurrentPlanInfoBackend } from '../type'
|
||||
import { DocumentProcessingPriority, Plan } from '../type'
|
||||
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from './index'
|
||||
|
||||
describe('billing utils', () => {
|
||||
// parseVectorSpaceToMB tests
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VectorSpaceFull from '../index'
|
||||
import VectorSpaceFull from './index'
|
||||
|
||||
type VectorProviderGlobal = typeof globalThis & {
|
||||
__vectorProviderContext?: ReturnType<typeof vi.fn>
|
||||
@@ -17,12 +17,12 @@ vi.mock('@/context/provider-context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../upgrade-btn', () => ({
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
// Mock utils to control threshold and plan limits
|
||||
vi.mock('../../utils', () => ({
|
||||
vi.mock('../utils', () => ({
|
||||
getPlanVectorSpaceLimitMB: (planType: string) => {
|
||||
// Return 5 for sandbox (threshold) and 100 for team
|
||||
if (planType === 'sandbox')
|
||||
@@ -66,26 +66,4 @@ describe('VectorSpaceFull', () => {
|
||||
expect(screen.getByText('8')).toBeInTheDocument()
|
||||
expect(screen.getByText('100MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders vector space info section', () => {
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.vectorSpace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with sandbox plan', () => {
|
||||
const globals = getVectorGlobal()
|
||||
globals.__vectorProviderContext?.mockReturnValue({
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 2 },
|
||||
total: { vectorSpace: 50 },
|
||||
},
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,45 +3,36 @@ import { cleanup, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
// Import real utility functions (pure functions, no side effects)
|
||||
|
||||
// Import mocked modules for manipulation
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { usePipelineInit } from './hooks'
|
||||
import RagPipelineWrapper from './index'
|
||||
import { processNodesWithoutDataSource } from './utils'
|
||||
import { usePipelineInit } from '../hooks'
|
||||
import RagPipelineWrapper from '../index'
|
||||
import { processNodesWithoutDataSource } from '../utils'
|
||||
|
||||
// Mock: Context - need to control return values
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock: Hook with API calls
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
usePipelineInit: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock: Store creator
|
||||
vi.mock('./store', () => ({
|
||||
vi.mock('../store', () => ({
|
||||
createRagPipelineSliceSlice: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
// Mock: Utility with complex workflow dependencies (generateNewNode, etc.)
|
||||
vi.mock('./utils', () => ({
|
||||
vi.mock('../utils', () => ({
|
||||
processNodesWithoutDataSource: vi.fn((nodes, viewport) => ({
|
||||
nodes,
|
||||
viewport,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock: Complex component with useParams, Toast, API calls
|
||||
vi.mock('./components/conversion', () => ({
|
||||
vi.mock('../components/conversion', () => ({
|
||||
default: () => <div data-testid="conversion-component">Conversion Component</div>,
|
||||
}))
|
||||
|
||||
// Mock: Complex component with many hooks and workflow dependencies
|
||||
vi.mock('./components/rag-pipeline-main', () => ({
|
||||
default: ({ nodes, edges, viewport }: any) => (
|
||||
vi.mock('../components/rag-pipeline-main', () => ({
|
||||
default: ({ nodes, edges, viewport }: { nodes?: unknown[], edges?: unknown[], viewport?: { zoom?: number } }) => (
|
||||
<div data-testid="rag-pipeline-main">
|
||||
<span data-testid="nodes-count">{nodes?.length ?? 0}</span>
|
||||
<span data-testid="edges-count">{edges?.length ?? 0}</span>
|
||||
@@ -50,35 +41,29 @@ vi.mock('./components/rag-pipeline-main', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock: Complex component with ReactFlow and many providers
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-default-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock: Context provider
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Type assertions for mocked functions
|
||||
const mockUseDatasetDetailContextWithSelector = vi.mocked(useDatasetDetailContextWithSelector)
|
||||
const mockUsePipelineInit = vi.mocked(usePipelineInit)
|
||||
const mockProcessNodesWithoutDataSource = vi.mocked(processNodesWithoutDataSource)
|
||||
|
||||
// Helper to mock selector with actual execution (increases function coverage)
|
||||
// This executes the real selector function: s => s.dataset?.pipeline_id
|
||||
const mockSelectorWithDataset = (pipelineId: string | null | undefined) => {
|
||||
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: any) => any) => {
|
||||
mockUseDatasetDetailContextWithSelector.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const mockState = { dataset: pipelineId ? { pipeline_id: pipelineId } : null }
|
||||
return selector(mockState)
|
||||
})
|
||||
}
|
||||
|
||||
// Test data factory
|
||||
const createMockWorkflowData = (overrides?: Partial<FetchWorkflowDraftResponse>): FetchWorkflowDraftResponse => ({
|
||||
graph: {
|
||||
nodes: [
|
||||
@@ -157,7 +142,6 @@ describe('RagPipelineWrapper', () => {
|
||||
|
||||
describe('RagPipeline', () => {
|
||||
beforeEach(() => {
|
||||
// Default setup for RagPipeline tests - execute real selector function
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
})
|
||||
|
||||
@@ -167,7 +151,6 @@ describe('RagPipeline', () => {
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// Real Loading component has role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -240,8 +223,6 @@ describe('RagPipeline', () => {
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// initialNodes is a real function - verify nodes are rendered
|
||||
// The real initialNodes processes nodes and adds position data
|
||||
expect(screen.getByTestId('rag-pipeline-main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -251,7 +232,6 @@ describe('RagPipeline', () => {
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// initialEdges is a real function - verify component renders with edges
|
||||
expect(screen.getByTestId('edges-count').textContent).toBe('1')
|
||||
})
|
||||
|
||||
@@ -269,7 +249,6 @@ describe('RagPipeline', () => {
|
||||
|
||||
render(<RagPipelineWrapper />)
|
||||
|
||||
// When data is undefined, Loading is shown, processNodesWithoutDataSource is not called
|
||||
expect(mockProcessNodesWithoutDataSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -279,13 +258,10 @@ describe('RagPipeline', () => {
|
||||
|
||||
const { rerender } = render(<RagPipelineWrapper />)
|
||||
|
||||
// Clear mock call count after initial render
|
||||
mockProcessNodesWithoutDataSource.mockClear()
|
||||
|
||||
// Rerender with same data reference (no change to mockUsePipelineInit)
|
||||
rerender(<RagPipelineWrapper />)
|
||||
|
||||
// processNodesWithoutDataSource should not be called again due to useMemo
|
||||
// Note: React strict mode may cause double render, so we check it's not excessive
|
||||
expect(mockProcessNodesWithoutDataSource.mock.calls.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
@@ -327,7 +303,7 @@ describe('RagPipeline', () => {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: undefined as any,
|
||||
viewport: undefined as never,
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
@@ -342,7 +318,7 @@ describe('RagPipeline', () => {
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: null as any,
|
||||
viewport: null as never,
|
||||
},
|
||||
})
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
@@ -438,7 +414,7 @@ describe('processNodesWithoutDataSource utility integration', () => {
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
mockProcessNodesWithoutDataSource.mockReturnValue({
|
||||
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as any,
|
||||
nodes: [{ id: 'processed-node', type: 'custom', data: { type: BlockEnum.Start, title: 'Processed', desc: '' }, position: { x: 0, y: 0 } }] as unknown as ReturnType<typeof processNodesWithoutDataSource>['nodes'],
|
||||
viewport: { x: 0, y: 0, zoom: 2 },
|
||||
})
|
||||
|
||||
@@ -467,14 +443,11 @@ describe('Conditional Rendering Flow', () => {
|
||||
it('should transition from loading to loaded state', () => {
|
||||
mockSelectorWithDataset('pipeline-123')
|
||||
|
||||
// Start with loading state
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
const { rerender } = render(<RagPipelineWrapper />)
|
||||
|
||||
// Real Loading component has role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Transition to loaded state
|
||||
const mockData = createMockWorkflowData()
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
rerender(<RagPipelineWrapper />)
|
||||
@@ -483,7 +456,6 @@ describe('Conditional Rendering Flow', () => {
|
||||
})
|
||||
|
||||
it('should switch from Conversion to Pipeline when pipelineId becomes available', () => {
|
||||
// Start without pipelineId
|
||||
mockSelectorWithDataset(null)
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: false })
|
||||
|
||||
@@ -491,13 +463,11 @@ describe('Conditional Rendering Flow', () => {
|
||||
|
||||
expect(screen.getByTestId('conversion-component')).toBeInTheDocument()
|
||||
|
||||
// PipelineId becomes available
|
||||
mockSelectorWithDataset('new-pipeline-id')
|
||||
mockUsePipelineInit.mockReturnValue({ data: undefined, isLoading: true })
|
||||
rerender(<RagPipelineWrapper />)
|
||||
|
||||
expect(screen.queryByTestId('conversion-component')).not.toBeInTheDocument()
|
||||
// Real Loading component has role="status"
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -510,21 +480,18 @@ describe('Error Handling', () => {
|
||||
it('should throw when graph nodes is null', () => {
|
||||
const mockData = {
|
||||
graph: {
|
||||
nodes: null as any,
|
||||
edges: null as any,
|
||||
nodes: null,
|
||||
edges: null,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
hash: 'test',
|
||||
updated_at: 123,
|
||||
} as FetchWorkflowDraftResponse
|
||||
} as unknown as FetchWorkflowDraftResponse
|
||||
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
// Suppress console.error for expected error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Real initialNodes will throw when nodes is null
|
||||
// This documents the component's current behavior - it requires valid nodes array
|
||||
expect(() => render(<RagPipelineWrapper />)).toThrow()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
@@ -538,11 +505,8 @@ describe('Error Handling', () => {
|
||||
|
||||
mockUsePipelineInit.mockReturnValue({ data: mockData, isLoading: false })
|
||||
|
||||
// Suppress console.error for expected error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// When graph is undefined, component throws because data.graph.nodes is accessed
|
||||
// This documents the component's current behavior - it requires graph to be present
|
||||
expect(() => render(<RagPipelineWrapper />)).toThrow()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
@@ -0,0 +1,182 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Conversion from '../conversion'
|
||||
|
||||
const mockConvert = vi.fn()
|
||||
const mockInvalidDatasetDetail = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'ds-123' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useConvertDatasetToPipeline: () => ({
|
||||
mutateAsync: mockConvert,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
datasetDetailQueryKeyPrefix: ['dataset-detail'],
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-base', () => ({
|
||||
useInvalid: () => mockInvalidDatasetDetail,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/button', () => ({
|
||||
default: ({ children, onClick, ...props }: Record<string, unknown>) => (
|
||||
<button onClick={onClick as () => void} {...props}>{children as string}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
title: string
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-modal">
|
||||
<span>{title}</span>
|
||||
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-btn" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('../screenshot', () => ({
|
||||
default: () => <div data-testid="screenshot" />,
|
||||
}))
|
||||
|
||||
describe('Conversion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render conversion title and description', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.conversion.descriptionChunk1')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.conversion.descriptionChunk2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render convert button', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.operations.convert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render warning text', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
expect(screen.getByText('datasetPipeline.conversion.warning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render screenshot component', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
expect(screen.getByTestId('screenshot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show confirm modal when convert button clicked', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
|
||||
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.conversion.confirm.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide confirm modal when cancel is clicked', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('cancel-btn'))
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call convert when confirm is clicked', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(mockConvert).toHaveBeenCalledWith('ds-123', expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle successful conversion', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => {
|
||||
opts.onSuccess({ status: 'success' })
|
||||
})
|
||||
|
||||
render(<Conversion />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
expect(mockInvalidDatasetDetail).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle failed conversion', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockConvert.mockImplementation((_id: string, opts: { onSuccess: (res: { status: string }) => void }) => {
|
||||
opts.onSuccess({ status: 'failed' })
|
||||
})
|
||||
|
||||
render(<Conversion />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle conversion error', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockConvert.mockImplementation((_id: string, opts: { onError: () => void }) => {
|
||||
opts.onError()
|
||||
})
|
||||
|
||||
render(<Conversion />)
|
||||
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.convert'))
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -3,29 +3,19 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
|
||||
// ============================================================================
|
||||
// Import Components After Mocks Setup
|
||||
// ============================================================================
|
||||
import Conversion from '../conversion'
|
||||
import RagPipelinePanel from '../panel'
|
||||
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal'
|
||||
import PublishToast from '../publish-toast'
|
||||
import RagPipelineChildren from '../rag-pipeline-children'
|
||||
import PipelineScreenShot from '../screenshot'
|
||||
|
||||
import Conversion from './conversion'
|
||||
import RagPipelinePanel from './panel'
|
||||
import PublishAsKnowledgePipelineModal from './publish-as-knowledge-pipeline-modal'
|
||||
import PublishToast from './publish-toast'
|
||||
import RagPipelineChildren from './rag-pipeline-children'
|
||||
import PipelineScreenShot from './screenshot'
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Dependencies - All vi.mock calls must come before any imports
|
||||
// ============================================================================
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'test-dataset-id' }),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => (
|
||||
// eslint-disable-next-line next/no-img-element
|
||||
@@ -33,7 +23,6 @@ vi.mock('next/image', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock next/dynamic
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (importFn: () => Promise<{ default: React.ComponentType<unknown> }>, options?: { ssr?: boolean }) => {
|
||||
const DynamicComponent = ({ children, ...props }: PropsWithChildren) => {
|
||||
@@ -44,7 +33,6 @@ vi.mock('next/dynamic', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock workflow store - using controllable state
|
||||
let mockShowImportDSLModal = false
|
||||
const mockSetShowImportDSLModal = vi.fn((value: boolean) => {
|
||||
mockShowImportDSLModal = value
|
||||
@@ -112,7 +100,6 @@ vi.mock('@/app/components/workflow/store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock workflow hooks - extract mock functions for assertions using vi.hoisted
|
||||
const {
|
||||
mockHandlePaneContextmenuCancel,
|
||||
mockExportCheck,
|
||||
@@ -148,8 +135,7 @@ vi.mock('@/app/components/workflow/hooks', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock rag-pipeline hooks
|
||||
vi.mock('../hooks', () => ({
|
||||
vi.mock('../../hooks', () => ({
|
||||
useAvailableNodesMetaData: () => ({}),
|
||||
useDSL: () => ({
|
||||
exportCheck: mockExportCheck,
|
||||
@@ -178,18 +164,15 @@ vi.mock('../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock rag-pipeline search hook
|
||||
vi.mock('../hooks/use-rag-pipeline-search', () => ({
|
||||
vi.mock('../../hooks/use-rag-pipeline-search', () => ({
|
||||
useRagPipelineSearch: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock configs-map hook
|
||||
vi.mock('../hooks/use-configs-map', () => ({
|
||||
vi.mock('../../hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => ({}),
|
||||
}))
|
||||
|
||||
// Mock inspect-vars-crud hook
|
||||
vi.mock('../hooks/use-inspect-vars-crud', () => ({
|
||||
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
|
||||
useInspectVarsCrud: () => ({
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
@@ -208,14 +191,12 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow hooks for fetch-workflow-inspect-vars
|
||||
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks - with controllable convert function
|
||||
let mockConvertFn = vi.fn()
|
||||
let mockIsPending = false
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
@@ -253,7 +234,6 @@ vi.mock('@/service/workflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock event emitter context - with controllable subscription
|
||||
let mockEventSubscriptionCallback: ((v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null
|
||||
const mockUseSubscription = vi.fn((callback: (v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => {
|
||||
mockEventSubscriptionCallback = callback
|
||||
@@ -267,7 +247,6 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
@@ -280,33 +259,28 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTheme hook
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: 'light',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock basePath
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '/public',
|
||||
}))
|
||||
|
||||
// Mock provider context
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => createMockProviderContextValue(),
|
||||
useProviderContextSelector: <T,>(selector: (state: ReturnType<typeof createMockProviderContextValue>) => T): T =>
|
||||
selector(createMockProviderContextValue()),
|
||||
}))
|
||||
|
||||
// Mock WorkflowWithInnerContext
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
WorkflowWithInnerContext: ({ children }: PropsWithChildren) => (
|
||||
<div data-testid="workflow-inner-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock workflow panel
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
|
||||
<div data-testid="workflow-panel">
|
||||
@@ -316,19 +290,16 @@ vi.mock('@/app/components/workflow/panel', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock PluginDependency
|
||||
vi.mock('../../workflow/plugin-dependency', () => ({
|
||||
vi.mock('../../../workflow/plugin-dependency', () => ({
|
||||
default: () => <div data-testid="plugin-dependency" />,
|
||||
}))
|
||||
|
||||
// Mock plugin-dependency hooks
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock DSLExportConfirmModal
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => (
|
||||
<div data-testid="dsl-export-confirm-modal">
|
||||
@@ -339,13 +310,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock workflow constants
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
|
||||
}))
|
||||
|
||||
// Mock workflow utils
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
initialNodes: vi.fn(nodes => nodes),
|
||||
initialEdges: vi.fn(edges => edges),
|
||||
@@ -353,7 +322,6 @@ vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
// Mock Confirm component
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: {
|
||||
title: string
|
||||
@@ -381,7 +349,6 @@ vi.mock('@/app/components/base/confirm', () => ({
|
||||
: null,
|
||||
}))
|
||||
|
||||
// Mock Modal component
|
||||
vi.mock('@/app/components/base/modal', () => ({
|
||||
default: ({ children, isShow, onClose, className }: PropsWithChildren<{
|
||||
isShow: boolean
|
||||
@@ -396,7 +363,6 @@ vi.mock('@/app/components/base/modal', () => ({
|
||||
: null,
|
||||
}))
|
||||
|
||||
// Mock Input component
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({ value, onChange, placeholder }: {
|
||||
value: string
|
||||
@@ -412,7 +378,6 @@ vi.mock('@/app/components/base/input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Textarea component
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, placeholder, className }: {
|
||||
value: string
|
||||
@@ -430,7 +395,6 @@ vi.mock('@/app/components/base/textarea', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock AppIcon component
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick, iconType, icon, background, imageUrl, className, size }: {
|
||||
onClick?: () => void
|
||||
@@ -454,7 +418,6 @@ vi.mock('@/app/components/base/app-icon', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock AppIconPicker component
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
default: ({ onSelect, onClose }: {
|
||||
onSelect: (item: { type: string, icon?: string, background?: string, url?: string }) => void
|
||||
@@ -478,7 +441,6 @@ vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Uploader component
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
|
||||
default: ({ file, updateFile, className, accept, displayName }: {
|
||||
file?: File
|
||||
@@ -504,25 +466,21 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock use-context-selector
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(() => ({
|
||||
notify: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock RagPipelineHeader
|
||||
vi.mock('./rag-pipeline-header', () => ({
|
||||
vi.mock('../rag-pipeline-header', () => ({
|
||||
default: () => <div data-testid="rag-pipeline-header" />,
|
||||
}))
|
||||
|
||||
// Mock PublishToast
|
||||
vi.mock('./publish-toast', () => ({
|
||||
vi.mock('../publish-toast', () => ({
|
||||
default: () => <div data-testid="publish-toast" />,
|
||||
}))
|
||||
|
||||
// Mock UpdateDSLModal for RagPipelineChildren tests
|
||||
vi.mock('./update-dsl-modal', () => ({
|
||||
vi.mock('../update-dsl-modal', () => ({
|
||||
default: ({ onCancel, onBackup, onImport }: {
|
||||
onCancel: () => void
|
||||
onBackup: () => void
|
||||
@@ -536,7 +494,6 @@ vi.mock('./update-dsl-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock DSLExportConfirmModal for RagPipelineChildren tests
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({ envList, onConfirm, onClose }: {
|
||||
envList: EnvironmentVariable[]
|
||||
@@ -555,18 +512,11 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Suites
|
||||
// ============================================================================
|
||||
|
||||
describe('Conversion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render conversion component without crashing', () => {
|
||||
render(<Conversion />)
|
||||
@@ -600,9 +550,6 @@ describe('Conversion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should show confirm modal when convert button is clicked', () => {
|
||||
render(<Conversion />)
|
||||
@@ -617,20 +564,15 @@ describe('Conversion', () => {
|
||||
it('should hide confirm modal when cancel is clicked', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
// Open modal
|
||||
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
|
||||
fireEvent.click(convertButton)
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
|
||||
// Cancel modal
|
||||
fireEvent.click(screen.getByTestId('cancel-btn'))
|
||||
expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API Callback Tests - covers lines 21-39
|
||||
// --------------------------------------------------------------------------
|
||||
describe('API Callbacks', () => {
|
||||
beforeEach(() => {
|
||||
mockConvertFn = vi.fn()
|
||||
@@ -638,14 +580,12 @@ describe('Conversion', () => {
|
||||
})
|
||||
|
||||
it('should call convert with datasetId and show success toast on success', async () => {
|
||||
// Setup mock to capture and call onSuccess callback
|
||||
mockConvertFn.mockImplementation((_datasetId: string, options: { onSuccess: (res: { status: string }) => void }) => {
|
||||
options.onSuccess({ status: 'success' })
|
||||
})
|
||||
|
||||
render(<Conversion />)
|
||||
|
||||
// Open modal and confirm
|
||||
const convertButton = screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })
|
||||
fireEvent.click(convertButton)
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
@@ -690,7 +630,6 @@ describe('Conversion', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockConvertFn).toHaveBeenCalled()
|
||||
})
|
||||
// Modal should still be visible since conversion failed
|
||||
expect(screen.getByTestId('confirm-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -711,32 +650,23 @@ describe('Conversion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Conversion is exported with React.memo
|
||||
expect((Conversion as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
|
||||
it('should use useCallback for handleConvert', () => {
|
||||
const { rerender } = render(<Conversion />)
|
||||
|
||||
// Rerender should not cause issues with callback
|
||||
rerender(<Conversion />)
|
||||
expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.convert/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing datasetId gracefully', () => {
|
||||
render(<Conversion />)
|
||||
|
||||
// Component should render without crashing
|
||||
expect(screen.getByText('datasetPipeline.conversion.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -747,9 +677,6 @@ describe('PipelineScreenShot', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<PipelineScreenShot />)
|
||||
@@ -770,14 +697,10 @@ describe('PipelineScreenShot', () => {
|
||||
render(<PipelineScreenShot />)
|
||||
|
||||
const img = screen.getByTestId('mock-image')
|
||||
// Default theme is 'light' from mock
|
||||
expect(img).toHaveAttribute('src', '/public/screenshots/light/Pipeline.png')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((PipelineScreenShot as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
@@ -790,9 +713,6 @@ describe('PublishToast', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Note: PublishToast is mocked, so we just verify the mock renders
|
||||
@@ -802,12 +722,8 @@ describe('PublishToast', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be defined', () => {
|
||||
// The real PublishToast is mocked, but we can verify the import
|
||||
expect(PublishToast).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -826,9 +742,6 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
onConfirm: mockOnConfirm,
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render modal with title', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
@@ -863,9 +776,6 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should update name when input changes', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
@@ -906,11 +816,9 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Update values
|
||||
fireEvent.change(screen.getByTestId('input'), { target: { value: ' Trimmed Name ' } })
|
||||
fireEvent.change(screen.getByTestId('textarea'), { target: { value: ' Trimmed Description ' } })
|
||||
|
||||
// Click publish
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith(
|
||||
@@ -931,52 +839,39 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
it('should update icon when emoji is selected', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Open picker
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
|
||||
// Select emoji
|
||||
fireEvent.click(screen.getByTestId('select-emoji'))
|
||||
|
||||
// Picker should close
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update icon when image is selected', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Open picker
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
|
||||
// Select image
|
||||
fireEvent.click(screen.getByTestId('select-image'))
|
||||
|
||||
// Picker should close
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close picker and restore icon when picker is closed', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Open picker
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
|
||||
// Close picker
|
||||
fireEvent.click(screen.getByTestId('close-picker'))
|
||||
|
||||
// Picker should close
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Validation Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Validation', () => {
|
||||
it('should disable publish button when name is empty', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Clear the name
|
||||
fireEvent.change(screen.getByTestId('input'), { target: { value: '' } })
|
||||
|
||||
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
|
||||
@@ -986,7 +881,6 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
it('should disable publish button when name is only whitespace', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Set whitespace-only name
|
||||
fireEvent.change(screen.getByTestId('input'), { target: { value: ' ' } })
|
||||
|
||||
const publishButton = screen.getByRole('button', { name: /workflow\.common\.publish/i })
|
||||
@@ -1009,14 +903,10 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should use useCallback for handleSelectIcon', () => {
|
||||
const { rerender } = render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
// Rerender should not cause issues
|
||||
rerender(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
expect(screen.getByTestId('app-icon')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1028,9 +918,6 @@ describe('RagPipelinePanel', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render panel component without crashing', () => {
|
||||
render(<RagPipelinePanel />)
|
||||
@@ -1046,9 +933,6 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with memo', () => {
|
||||
expect((RagPipelinePanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
@@ -1063,9 +947,6 @@ describe('RagPipelineChildren', () => {
|
||||
mockEventSubscriptionCallback = null
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<RagPipelineChildren />)
|
||||
@@ -1090,9 +971,6 @@ describe('RagPipelineChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Event Subscription Tests - covers lines 37-40
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Event Subscription', () => {
|
||||
it('should subscribe to event emitter', () => {
|
||||
render(<RagPipelineChildren />)
|
||||
@@ -1103,12 +981,10 @@ describe('RagPipelineChildren', () => {
|
||||
it('should handle DSL_EXPORT_CHECK event and set secretEnvList', async () => {
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
// Simulate DSL_EXPORT_CHECK event
|
||||
const mockEnvVariables: EnvironmentVariable[] = [
|
||||
{ id: '1', name: 'SECRET_KEY', value: 'test-secret', value_type: 'secret' as const, description: '' },
|
||||
]
|
||||
|
||||
// Trigger the subscription callback
|
||||
if (mockEventSubscriptionCallback) {
|
||||
mockEventSubscriptionCallback({
|
||||
type: 'DSL_EXPORT_CHECK',
|
||||
@@ -1116,7 +992,6 @@ describe('RagPipelineChildren', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// DSLExportConfirmModal should be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1125,7 +1000,6 @@ describe('RagPipelineChildren', () => {
|
||||
it('should not show DSLExportConfirmModal for non-DSL_EXPORT_CHECK events', () => {
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
// Trigger a different event type
|
||||
if (mockEventSubscriptionCallback) {
|
||||
mockEventSubscriptionCallback({
|
||||
type: 'OTHER_EVENT',
|
||||
@@ -1136,9 +1010,6 @@ describe('RagPipelineChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// UpdateDSLModal Handlers Tests - covers lines 48-51
|
||||
// --------------------------------------------------------------------------
|
||||
describe('UpdateDSLModal Handlers', () => {
|
||||
beforeEach(() => {
|
||||
mockShowImportDSLModal = true
|
||||
@@ -1168,14 +1039,10 @@ describe('RagPipelineChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DSLExportConfirmModal Tests - covers lines 55-60
|
||||
// --------------------------------------------------------------------------
|
||||
describe('DSLExportConfirmModal', () => {
|
||||
it('should render DSLExportConfirmModal when secretEnvList has items', async () => {
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
// Simulate DSL_EXPORT_CHECK event with secrets
|
||||
const mockEnvVariables: EnvironmentVariable[] = [
|
||||
{ id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' },
|
||||
]
|
||||
@@ -1195,7 +1062,6 @@ describe('RagPipelineChildren', () => {
|
||||
it('should close DSLExportConfirmModal when onClose is triggered', async () => {
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
// First show the modal
|
||||
const mockEnvVariables: EnvironmentVariable[] = [
|
||||
{ id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' },
|
||||
]
|
||||
@@ -1211,7 +1077,6 @@ describe('RagPipelineChildren', () => {
|
||||
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close the modal
|
||||
fireEvent.click(screen.getByTestId('dsl-export-close'))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1222,7 +1087,6 @@ describe('RagPipelineChildren', () => {
|
||||
it('should call handleExportDSL when onConfirm is triggered', async () => {
|
||||
render(<RagPipelineChildren />)
|
||||
|
||||
// Show the modal
|
||||
const mockEnvVariables: EnvironmentVariable[] = [
|
||||
{ id: '1', name: 'API_KEY', value: 'secret-value', value_type: 'secret' as const, description: '' },
|
||||
]
|
||||
@@ -1238,16 +1102,12 @@ describe('RagPipelineChildren', () => {
|
||||
expect(screen.getByTestId('dsl-export-confirm-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Confirm export
|
||||
fireEvent.click(screen.getByTestId('dsl-export-confirm'))
|
||||
|
||||
expect(mockHandleExportDSL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with memo', () => {
|
||||
expect((RagPipelineChildren as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
@@ -1255,10 +1115,6 @@ describe('RagPipelineChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1276,17 +1132,13 @@ describe('Integration Tests', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Update name
|
||||
fireEvent.change(screen.getByTestId('input'), { target: { value: 'My Pipeline' } })
|
||||
|
||||
// Add description
|
||||
fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'A great pipeline' } })
|
||||
|
||||
// Change icon
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
fireEvent.click(screen.getByTestId('select-emoji'))
|
||||
|
||||
// Publish
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1304,10 +1156,6 @@ describe('Integration Tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1322,7 +1170,6 @@ describe('Edge Cases', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Clear the name
|
||||
const input = screen.getByTestId('input')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(input).toHaveValue('')
|
||||
@@ -1360,10 +1207,6 @@ describe('Edge Cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Accessibility Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Accessibility', () => {
|
||||
describe('Conversion', () => {
|
||||
it('should have accessible button', () => {
|
||||
@@ -0,0 +1,244 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PublishAsKnowledgePipelineModal from '../publish-as-knowledge-pipeline-modal'
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
knowledgeName: 'Test Pipeline',
|
||||
knowledgeIcon: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🔧',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/modal', () => ({
|
||||
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) =>
|
||||
isShow ? <div data-testid="modal">{children}</div> : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/button', () => ({
|
||||
default: ({ children, onClick, disabled, ...props }: Record<string, unknown>) => (
|
||||
<button onClick={onClick as () => void} disabled={disabled as boolean} {...props}>
|
||||
{children as string}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/input', () => ({
|
||||
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
|
||||
<input
|
||||
data-testid="name-input"
|
||||
value={value as string}
|
||||
onChange={onChange as () => void}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
|
||||
<textarea
|
||||
data-testid="description-textarea"
|
||||
value={value as string}
|
||||
onChange={onChange as () => void}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick }: { onClick?: () => void }) => (
|
||||
<div data-testid="app-icon" onClick={onClick} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
default: ({ onSelect, onClose }: { onSelect: (item: { type: string, icon: string, background: string, url: string }) => void, onClose: () => void }) => (
|
||||
<div data-testid="icon-picker">
|
||||
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎉', background: '#eee', url: '' })}>
|
||||
Select Emoji
|
||||
</button>
|
||||
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', icon: '', background: '', url: 'http://img.png' })}>
|
||||
Select Image
|
||||
</button>
|
||||
<button data-testid="close-picker" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/function', () => ({
|
||||
noop: () => {},
|
||||
}))
|
||||
|
||||
describe('PublishAsKnowledgePipelineModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const defaultProps = {
|
||||
onCancel: mockOnCancel,
|
||||
onConfirm: mockOnConfirm,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render modal with title', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize with knowledgeName from store', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByTestId('name-input') as HTMLInputElement
|
||||
expect(nameInput.value).toBe('Test Pipeline')
|
||||
})
|
||||
|
||||
it('should initialize description as empty', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const textarea = screen.getByTestId('description-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
|
||||
it('should call onCancel when close button clicked', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('publish-modal-close-btn'))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button clicked', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onConfirm with name, icon, and description when confirm clicked', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith(
|
||||
'Test Pipeline',
|
||||
expect.objectContaining({ icon_type: 'emoji', icon: '🔧' }),
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
it('should update pipeline name when input changes', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByTestId('name-input')
|
||||
fireEvent.change(nameInput, { target: { value: 'New Name' } })
|
||||
|
||||
expect((nameInput as HTMLInputElement).value).toBe('New Name')
|
||||
})
|
||||
|
||||
it('should update description when textarea changes', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const textarea = screen.getByTestId('description-textarea')
|
||||
fireEvent.change(textarea, { target: { value: 'My description' } })
|
||||
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('My description')
|
||||
})
|
||||
|
||||
it('should disable confirm button when name is empty', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByTestId('name-input')
|
||||
fireEvent.change(nameInput, { target: { value: '' } })
|
||||
|
||||
const confirmBtn = screen.getByText('workflow.common.publish')
|
||||
expect(confirmBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable confirm button when confirmDisabled is true', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} confirmDisabled />)
|
||||
|
||||
const confirmBtn = screen.getByText('workflow.common.publish')
|
||||
expect(confirmBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not call onConfirm when confirmDisabled is true', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} confirmDisabled />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show icon picker when app icon clicked', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
|
||||
expect(screen.getByTestId('icon-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update icon when emoji is selected', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
fireEvent.click(screen.getByTestId('select-emoji'))
|
||||
|
||||
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update icon when image is selected', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
fireEvent.click(screen.getByTestId('select-image'))
|
||||
|
||||
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close icon picker when close is clicked', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('app-icon'))
|
||||
fireEvent.click(screen.getByTestId('close-picker'))
|
||||
|
||||
expect(screen.queryByTestId('icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trim name and description before submitting', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByTestId('name-input')
|
||||
fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } })
|
||||
|
||||
const textarea = screen.getByTestId('description-textarea')
|
||||
fireEvent.change(textarea, { target: { value: ' Some desc ' } })
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith(
|
||||
'Trimmed Name',
|
||||
expect.any(Object),
|
||||
'Some desc',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PublishToast from './publish-toast'
|
||||
import PublishToast from '../publish-toast'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow store with controllable state
|
||||
let mockPublishedAt = 0
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
@@ -32,19 +24,19 @@ describe('PublishToast', () => {
|
||||
mockPublishedAt = 0
|
||||
render(<PublishToast />)
|
||||
|
||||
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render toast title', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render toast description', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
expect(screen.getByText('publishToast.desc')).toBeInTheDocument()
|
||||
expect(screen.getByText('pipeline.publishToast.desc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when publishedAt is set', () => {
|
||||
@@ -57,14 +49,13 @@ describe('PublishToast', () => {
|
||||
it('should have correct positioning classes', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
const container = screen.getByText('publishToast.title').closest('.absolute')
|
||||
const container = screen.getByText('pipeline.publishToast.title').closest('.absolute')
|
||||
expect(container).toHaveClass('bottom-[45px]', 'left-0', 'right-0', 'z-10')
|
||||
})
|
||||
|
||||
it('should render info icon', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
// The RiInformation2Fill icon should be rendered
|
||||
const iconContainer = container.querySelector('.text-text-accent')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
@@ -72,7 +63,6 @@ describe('PublishToast', () => {
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
// The close button is a div with cursor-pointer, not a semantic button
|
||||
const closeButton = container.querySelector('.cursor-pointer')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
@@ -82,25 +72,23 @@ describe('PublishToast', () => {
|
||||
it('should hide toast when close button is clicked', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
// The close button is a div with cursor-pointer, not a semantic button
|
||||
const closeButton = container.querySelector('.cursor-pointer')
|
||||
expect(screen.getByText('publishToast.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should remain hidden after close button is clicked', () => {
|
||||
const { container, rerender } = render(<PublishToast />)
|
||||
|
||||
// The close button is a div with cursor-pointer, not a semantic button
|
||||
const closeButton = container.querySelector('.cursor-pointer')
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
rerender(<PublishToast />)
|
||||
|
||||
expect(screen.queryByText('publishToast.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,14 +103,14 @@ describe('PublishToast', () => {
|
||||
it('should have correct toast width', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
const toastContainer = screen.getByText('publishToast.title').closest('.w-\\[420px\\]')
|
||||
const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.w-\\[420px\\]')
|
||||
expect(toastContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rounded border', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
const toastContainer = screen.getByText('publishToast.title').closest('.rounded-xl')
|
||||
const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.rounded-xl')
|
||||
expect(toastContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,9 @@ import type { PropsWithChildren } from 'react'
|
||||
import type { Edge, Node, Viewport } from 'reactflow'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import RagPipelineMain from './rag-pipeline-main'
|
||||
import RagPipelineMain from '../rag-pipeline-main'
|
||||
|
||||
// Mock hooks from ../hooks
|
||||
vi.mock('../hooks', () => ({
|
||||
vi.mock('../../hooks', () => ({
|
||||
useAvailableNodesMetaData: () => ({ nodes: [], nodesMap: {} }),
|
||||
useDSL: () => ({
|
||||
exportCheck: vi.fn(),
|
||||
@@ -34,8 +33,7 @@ vi.mock('../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useConfigsMap
|
||||
vi.mock('../hooks/use-configs-map', () => ({
|
||||
vi.mock('../../hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => ({
|
||||
flowId: 'test-flow-id',
|
||||
flowType: 'ragPipeline',
|
||||
@@ -43,8 +41,7 @@ vi.mock('../hooks/use-configs-map', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useInspectVarsCrud
|
||||
vi.mock('../hooks/use-inspect-vars-crud', () => ({
|
||||
vi.mock('../../hooks/use-inspect-vars-crud', () => ({
|
||||
useInspectVarsCrud: () => ({
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
@@ -63,7 +60,6 @@ vi.mock('../hooks/use-inspect-vars-crud', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
const mockSetRagPipelineVariables = vi.fn()
|
||||
const mockSetEnvironmentVariables = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
@@ -75,14 +71,12 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow hooks
|
||||
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock WorkflowWithInnerContext
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
WorkflowWithInnerContext: ({ children, onWorkflowDataUpdate }: PropsWithChildren<{ onWorkflowDataUpdate?: (payload: unknown) => void }>) => (
|
||||
<div data-testid="workflow-inner-context">
|
||||
@@ -108,8 +102,7 @@ vi.mock('@/app/components/workflow', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock RagPipelineChildren
|
||||
vi.mock('./rag-pipeline-children', () => ({
|
||||
vi.mock('../rag-pipeline-children', () => ({
|
||||
default: () => <div data-testid="rag-pipeline-children">Children</div>,
|
||||
}))
|
||||
|
||||
@@ -201,7 +194,6 @@ describe('RagPipelineMain', () => {
|
||||
it('should use useNodesSyncDraft hook', () => {
|
||||
render(<RagPipelineMain {...defaultProps} />)
|
||||
|
||||
// If the component renders, the hook was called successfully
|
||||
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import UpdateDSLModal from './update-dsl-modal'
|
||||
import UpdateDSLModal from '../update-dsl-modal'
|
||||
|
||||
class MockFileReader {
|
||||
onload: ((this: FileReader, event: ProgressEvent<FileReader>) => void) | null = null
|
||||
@@ -15,25 +15,15 @@ class MockFileReader {
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader)
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock use-context-selector
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
ToastContext: { Provider: ({ children }: PropsWithChildren) => children },
|
||||
}))
|
||||
|
||||
// Mock event emitter
|
||||
const mockEmit = vi.fn()
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
@@ -41,7 +31,6 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
@@ -50,13 +39,11 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow utils
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
initialNodes: (nodes: unknown[]) => nodes,
|
||||
initialEdges: (edges: unknown[]) => edges,
|
||||
}))
|
||||
|
||||
// Mock plugin dependencies
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
@@ -64,7 +51,6 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock pipeline service
|
||||
const mockImportDSL = vi.fn()
|
||||
const mockImportDSLConfirm = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
@@ -72,7 +58,6 @@ vi.mock('@/service/use-pipeline', () => ({
|
||||
useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }),
|
||||
}))
|
||||
|
||||
// Mock workflow service
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn().mockResolvedValue({
|
||||
graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
@@ -81,7 +66,6 @@ vi.mock('@/service/workflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Uploader
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
|
||||
default: ({ updateFile }: { updateFile: (file?: File) => void }) => (
|
||||
<div data-testid="uploader">
|
||||
@@ -103,7 +87,6 @@ vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Button
|
||||
vi.mock('@/app/components/base/button', () => ({
|
||||
default: ({ children, onClick, disabled, className, variant, loading }: {
|
||||
children: React.ReactNode
|
||||
@@ -125,7 +108,6 @@ vi.mock('@/app/components/base/button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Modal
|
||||
vi.mock('@/app/components/base/modal', () => ({
|
||||
default: ({ children, isShow, _onClose, className }: PropsWithChildren<{
|
||||
isShow: boolean
|
||||
@@ -140,7 +122,6 @@ vi.mock('@/app/components/base/modal', () => ({
|
||||
: null,
|
||||
}))
|
||||
|
||||
// Mock workflow constants
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
|
||||
}))
|
||||
@@ -176,15 +157,13 @@ describe('UpdateDSLModal', () => {
|
||||
it('should render title', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The component uses t('common.importDSL', { ns: 'workflow' }) which returns 'common.importDSL'
|
||||
expect(screen.getByText('common.importDSL')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.importDSL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render warning tip', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The component uses t('common.importDSLTip', { ns: 'workflow' })
|
||||
expect(screen.getByText('common.importDSLTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.importDSLTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render uploader', () => {
|
||||
@@ -196,29 +175,25 @@ describe('UpdateDSLModal', () => {
|
||||
it('should render backup button', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The component uses t('common.backupCurrentDraft', { ns: 'workflow' })
|
||||
expect(screen.getByText('common.backupCurrentDraft')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.backupCurrentDraft')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The component uses t('newApp.Cancel', { ns: 'app' })
|
||||
expect(screen.getByText('newApp.Cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.Cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render import button', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The component uses t('common.overwriteAndImport', { ns: 'workflow' })
|
||||
expect(screen.getByText('common.overwriteAndImport')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.overwriteAndImport')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render choose DSL section', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The component uses t('common.chooseDSL', { ns: 'workflow' })
|
||||
expect(screen.getByText('common.chooseDSL')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.chooseDSL')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,7 +201,7 @@ describe('UpdateDSLModal', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
const cancelButton = screen.getByText('newApp.Cancel')
|
||||
const cancelButton = screen.getByText('app.newApp.Cancel')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
@@ -235,7 +210,7 @@ describe('UpdateDSLModal', () => {
|
||||
it('should call onBackup when backup button is clicked', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
const backupButton = screen.getByText('common.backupCurrentDraft')
|
||||
const backupButton = screen.getByText('workflow.common.backupCurrentDraft')
|
||||
fireEvent.click(backupButton)
|
||||
|
||||
expect(mockOnBackup).toHaveBeenCalled()
|
||||
@@ -249,7 +224,6 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// File should be processed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('uploader')).toBeInTheDocument()
|
||||
})
|
||||
@@ -261,14 +235,12 @@ describe('UpdateDSLModal', () => {
|
||||
const clearButton = screen.getByTestId('clear-file')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// File should be cleared
|
||||
expect(screen.getByTestId('uploader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon is clicked', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The close icon is in a div with onClick={onCancel}
|
||||
const closeIconContainer = document.querySelector('.cursor-pointer')
|
||||
if (closeIconContainer) {
|
||||
fireEvent.click(closeIconContainer)
|
||||
@@ -281,7 +253,7 @@ describe('UpdateDSLModal', () => {
|
||||
it('should show import button disabled when no file is selected', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -294,7 +266,7 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -302,22 +274,20 @@ describe('UpdateDSLModal', () => {
|
||||
it('should disable import button after file is cleared', async () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// First select a file
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Clear the file
|
||||
const clearButton = screen.getByTestId('clear-file')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -344,15 +314,14 @@ describe('UpdateDSLModal', () => {
|
||||
it('should render import button with warning variant', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).toHaveAttribute('data-variant', 'warning')
|
||||
})
|
||||
|
||||
it('should render backup button with secondary variant', () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// The backup button text is inside a nested div, so we need to find the closest button
|
||||
const backupButtonText = screen.getByText('common.backupCurrentDraft')
|
||||
const backupButtonText = screen.getByText('workflow.common.backupCurrentDraft')
|
||||
const backupButton = backupButtonText.closest('button')
|
||||
expect(backupButton).toHaveAttribute('data-variant', 'secondary')
|
||||
})
|
||||
@@ -362,22 +331,18 @@ describe('UpdateDSLModal', () => {
|
||||
it('should call importDSL when import button is clicked with file content', async () => {
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// Select a file
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// Wait for FileReader to process
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Click import button
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Wait for import to be called
|
||||
await waitFor(() => {
|
||||
expect(mockImportDSL).toHaveBeenCalled()
|
||||
})
|
||||
@@ -392,17 +357,16 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// Select a file and click import
|
||||
const fileInput = screen.getByTestId('file-input')
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -426,11 +390,11 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -452,11 +416,11 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -478,11 +442,11 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -506,11 +470,11 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -533,13 +497,12 @@ describe('UpdateDSLModal', () => {
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// Wait for FileReader to process and button to be enabled
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -558,13 +521,12 @@ describe('UpdateDSLModal', () => {
|
||||
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
// Wait for FileReader to complete and button to be enabled
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -588,16 +550,15 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Flush the FileReader microtask to ensure fileContent is set
|
||||
await act(async () => {
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -619,11 +580,11 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -649,23 +610,20 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(importButton)
|
||||
// Flush the promise resolution from mockImportDSL
|
||||
await Promise.resolve()
|
||||
// Advance past the 300ms setTimeout in the component
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
@@ -687,14 +645,13 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Wait for error modal with version info
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.0.0')).toBeInTheDocument()
|
||||
@@ -717,20 +674,18 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Wait for error modal
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
// Find and click cancel button in error modal - it should be the one with secondary variant
|
||||
const cancelButtons = screen.getAllByText('newApp.Cancel')
|
||||
const cancelButtons = screen.getAllByText('app.newApp.Cancel')
|
||||
const errorModalCancelButton = cancelButtons.find(btn =>
|
||||
btn.getAttribute('data-variant') === 'secondary',
|
||||
)
|
||||
@@ -738,9 +693,8 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.click(errorModalCancelButton)
|
||||
}
|
||||
|
||||
// Modal should be closed
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.newApp.appCreateDSLErrorTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -767,27 +721,23 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(importButton)
|
||||
// Flush the promise resolution from mockImportDSL
|
||||
await Promise.resolve()
|
||||
// Advance past the 300ms setTimeout in the component
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
// Click confirm button
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -818,18 +768,18 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -860,18 +810,18 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -899,18 +849,18 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -941,18 +891,18 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -983,18 +933,18 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1025,26 +975,23 @@ describe('UpdateDSLModal', () => {
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
// Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve))
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(importButton)
|
||||
// Flush the promise resolution from mockImportDSL
|
||||
await Promise.resolve()
|
||||
// Advance past the 300ms setTimeout in the component
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
|
||||
const confirmButton = screen.getByText('newApp.Confirm')
|
||||
const confirmButton = screen.getByText('app.newApp.Confirm')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1070,25 +1017,21 @@ describe('UpdateDSLModal', () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
expect(importButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const importButton = screen.getByText('common.overwriteAndImport')
|
||||
const importButton = screen.getByText('workflow.common.overwriteAndImport')
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Should show error modal even with undefined versions
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
|
||||
}, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('should not call importDSLConfirm when importId is not set', async () => {
|
||||
// Render without triggering PENDING status first
|
||||
render(<UpdateDSLModal {...defaultProps} />)
|
||||
|
||||
// importId is not set, so confirm should not be called
|
||||
// This is hard to test directly, but we can verify by checking the confirm flow
|
||||
expect(mockImportDSLConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import VersionMismatchModal from './version-mismatch-modal'
|
||||
import VersionMismatchModal from '../version-mismatch-modal'
|
||||
|
||||
describe('VersionMismatchModal', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ParentChildChunk } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import ChunkCard from '../chunk-card'
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/detail/completed/common/dot', () => ({
|
||||
default: () => <span data-testid="dot" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/detail/completed/common/segment-index-tag', () => ({
|
||||
default: ({ positionId, labelPrefix }: { positionId?: string | number, labelPrefix: string }) => (
|
||||
<span data-testid="segment-tag">
|
||||
{labelPrefix}
|
||||
-
|
||||
{positionId}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({
|
||||
default: ({ summary }: { summary: string }) => <span data-testid="summary">{summary}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/formatted-text/flavours/preview-slice', () => ({
|
||||
PreviewSlice: ({ label, text }: { label: string, text: string }) => (
|
||||
<span data-testid="preview-slice">
|
||||
{label}
|
||||
:
|
||||
{' '}
|
||||
{text}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
ChunkingMode: {
|
||||
text: 'text',
|
||||
parentChild: 'parent-child',
|
||||
qa: 'qa',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatNumber: (n: number) => String(n),
|
||||
}))
|
||||
|
||||
vi.mock('../q-a-item', () => ({
|
||||
default: ({ type, text }: { type: string, text: string }) => (
|
||||
<span data-testid={`qa-${type}`}>{text}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../types', () => ({
|
||||
QAItemType: {
|
||||
Question: 'question',
|
||||
Answer: 'answer',
|
||||
},
|
||||
}))
|
||||
|
||||
const makeParentChildContent = (overrides: Partial<ParentChildChunk> = {}): ParentChildChunk => ({
|
||||
child_contents: ['Child'],
|
||||
parent_content: '',
|
||||
parent_summary: '',
|
||||
parent_mode: 'paragraph',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ChunkCard', () => {
|
||||
describe('Text mode', () => {
|
||||
it('should render text content', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
content={{ content: 'Hello world', summary: 'Summary text' }}
|
||||
positionId={1}
|
||||
wordCount={42}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render segment index tag with Chunk prefix', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
content={{ content: 'Test', summary: '' }}
|
||||
positionId={5}
|
||||
wordCount={10}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Chunk-5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render word count', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
content={{ content: 'Test', summary: '' }}
|
||||
positionId={1}
|
||||
wordCount={100}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/100/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render summary when available', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
content={{ content: 'Test', summary: 'A summary' }}
|
||||
positionId={1}
|
||||
wordCount={10}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('summary')).toHaveTextContent('A summary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parent-Child mode (paragraph)', () => {
|
||||
it('should render child contents as preview slices', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
parentMode="paragraph"
|
||||
content={makeParentChildContent({
|
||||
child_contents: ['Child 1', 'Child 2'],
|
||||
parent_summary: 'Parent summary',
|
||||
})}
|
||||
positionId={3}
|
||||
wordCount={50}
|
||||
/>,
|
||||
)
|
||||
|
||||
const slices = screen.getAllByTestId('preview-slice')
|
||||
expect(slices).toHaveLength(2)
|
||||
expect(slices[0]).toHaveTextContent('C-1: Child 1')
|
||||
expect(slices[1]).toHaveTextContent('C-2: Child 2')
|
||||
})
|
||||
|
||||
it('should render Parent-Chunk prefix for paragraph mode', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
parentMode="paragraph"
|
||||
content={makeParentChildContent()}
|
||||
positionId={2}
|
||||
wordCount={20}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Parent-Chunk-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parent summary', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
parentMode="paragraph"
|
||||
content={makeParentChildContent({
|
||||
child_contents: ['C1'],
|
||||
parent_summary: 'Overview',
|
||||
})}
|
||||
positionId={1}
|
||||
wordCount={10}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('summary')).toHaveTextContent('Overview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parent-Child mode (full-doc)', () => {
|
||||
it('should hide segment tag in full-doc mode', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
parentMode="full-doc"
|
||||
content={makeParentChildContent({
|
||||
child_contents: ['Full doc child'],
|
||||
parent_mode: 'full-doc',
|
||||
})}
|
||||
positionId={1}
|
||||
wordCount={300}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('segment-tag')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('QA mode', () => {
|
||||
it('should render question and answer items', () => {
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.qa}
|
||||
content={{ question: 'What is X?', answer: 'X is Y' }}
|
||||
positionId={1}
|
||||
wordCount={15}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('qa-question')).toHaveTextContent('What is X?')
|
||||
expect(screen.getByTestId('qa-answer')).toHaveTextContent('X is Y')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types'
|
||||
import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import ChunkCard from './chunk-card'
|
||||
import { ChunkCardList } from './index'
|
||||
import QAItem from './q-a-item'
|
||||
import { QAItemType } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// Test Data Factories
|
||||
// =============================================================================
|
||||
import ChunkCard from '../chunk-card'
|
||||
import { ChunkCardList } from '../index'
|
||||
import QAItem from '../q-a-item'
|
||||
import { QAItemType } from '../types'
|
||||
|
||||
const createGeneralChunks = (overrides: GeneralChunks = []): GeneralChunks => {
|
||||
if (overrides.length > 0)
|
||||
@@ -56,99 +52,71 @@ const createQAChunks = (overrides: Partial<QAChunks> = {}): QAChunks => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// QAItem Component Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('QAItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for basic rendering of QAItem component
|
||||
describe('Rendering', () => {
|
||||
it('should render question type with Q prefix', () => {
|
||||
// Arrange & Act
|
||||
render(<QAItem type={QAItemType.Question} text="What is this?" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('What is this?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render answer type with A prefix', () => {
|
||||
// Arrange & Act
|
||||
render(<QAItem type={QAItemType.Answer} text="This is the answer." />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('This is the answer.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for different prop variations
|
||||
describe('Props', () => {
|
||||
it('should render with empty text', () => {
|
||||
// Arrange & Act
|
||||
render(<QAItem type={QAItemType.Question} text="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text content', () => {
|
||||
// Arrange
|
||||
const longText = 'A'.repeat(1000)
|
||||
|
||||
// Act
|
||||
render(<QAItem type={QAItemType.Answer} text={longText} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with special characters in text', () => {
|
||||
// Arrange
|
||||
const specialText = '<script>alert("xss")</script> & "quotes" \'apostrophe\''
|
||||
|
||||
// Act
|
||||
render(<QAItem type={QAItemType.Question} text={specialText} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(specialText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for memoization behavior
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
// Arrange & Act
|
||||
const { rerender } = render(<QAItem type={QAItemType.Question} text="Test" />)
|
||||
|
||||
// Assert - component should render consistently
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
|
||||
// Rerender with same props - should not cause issues
|
||||
rerender(<QAItem type={QAItemType.Question} text="Test" />)
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ChunkCard Component Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('ChunkCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for basic rendering with different chunk types
|
||||
describe('Rendering', () => {
|
||||
it('should render text chunk type correctly', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -158,19 +126,16 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('This is the first chunk of text content.')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render QA chunk type with question and answer', () => {
|
||||
// Arrange
|
||||
const qaContent: QAChunk = {
|
||||
question: 'What is React?',
|
||||
answer: 'React is a JavaScript library.',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -180,7 +145,6 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('What is React?')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
@@ -188,10 +152,8 @@ describe('ChunkCard', () => {
|
||||
})
|
||||
|
||||
it('should render parent-child chunk type with child contents', () => {
|
||||
// Arrange
|
||||
const childContents = ['Child 1 content', 'Child 2 content']
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -202,7 +164,6 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Child 1 content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child 2 content')).toBeInTheDocument()
|
||||
expect(screen.getByText('C-1')).toBeInTheDocument()
|
||||
@@ -210,10 +171,8 @@ describe('ChunkCard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for parent mode variations
|
||||
describe('Parent Mode Variations', () => {
|
||||
it('should show Parent-Chunk label prefix for paragraph mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -224,12 +183,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide segment index tag for full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -240,13 +197,11 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should not show Chunk or Parent-Chunk label
|
||||
expect(screen.queryByText(/Chunk/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show Chunk label prefix for text mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -256,15 +211,12 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/Chunk-05/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for word count display
|
||||
describe('Word Count Display', () => {
|
||||
it('should display formatted word count', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -274,12 +226,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - formatNumber(1234) returns '1,234'
|
||||
expect(screen.getByText(/1,234/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display word count with character translation key', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -289,12 +239,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - translation key is returned as-is by mock
|
||||
expect(screen.getByText(/100\s+(?:\S.*)?characters/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display word count info for full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -305,15 +253,12 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - the header with word count should be hidden
|
||||
expect(screen.queryByText(/500/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for position ID variations
|
||||
describe('Position ID', () => {
|
||||
it('should handle numeric position ID', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -323,12 +268,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/Chunk-42/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle string position ID', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -338,12 +281,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/Chunk-99/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pad single digit position ID', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -353,15 +294,12 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/Chunk-03/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for memoization dependencies
|
||||
describe('Memoization', () => {
|
||||
it('should update isFullDoc memo when parentMode changes', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -372,10 +310,8 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - paragraph mode shows label
|
||||
expect(screen.getByText(/Parent-Chunk/)).toBeInTheDocument()
|
||||
|
||||
// Act - change to full-doc
|
||||
rerender(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -386,12 +322,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - full-doc mode hides label
|
||||
expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update contentElement memo when content changes', () => {
|
||||
// Arrange
|
||||
const initialContent = { content: 'Initial content' }
|
||||
const updatedContent = { content: 'Updated content' }
|
||||
|
||||
@@ -404,10 +338,8 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Initial content')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -417,13 +349,11 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Updated content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Initial content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update contentElement memo when chunkType changes', () => {
|
||||
// Arrange
|
||||
const textContent = { content: 'Text content' }
|
||||
const { rerender } = render(
|
||||
<ChunkCard
|
||||
@@ -434,10 +364,8 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Text content')).toBeInTheDocument()
|
||||
|
||||
// Act - change to QA type
|
||||
const qaContent: QAChunk = { question: 'Q?', answer: 'A.' }
|
||||
rerender(
|
||||
<ChunkCard
|
||||
@@ -448,16 +376,13 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('Q?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty child contents array', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -468,15 +393,12 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should render without errors
|
||||
expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle QA chunk with empty strings', () => {
|
||||
// Arrange
|
||||
const emptyQA: QAChunk = { question: '', answer: '' }
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -486,17 +408,14 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very long content', () => {
|
||||
// Arrange
|
||||
const longContent = 'A'.repeat(10000)
|
||||
const longContentChunk = { content: longContent }
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -506,12 +425,10 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(longContent)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero word count', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkCard
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -521,28 +438,20 @@ describe('ChunkCard', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - formatNumber returns falsy for 0, so it shows 0
|
||||
expect(screen.getByText(/0\s+(?:\S.*)?characters/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// ChunkCardList Component Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('ChunkCardList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for rendering with different chunk types
|
||||
describe('Rendering', () => {
|
||||
it('should render text chunks correctly', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -550,17 +459,14 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(chunks[0].content)).toBeInTheDocument()
|
||||
expect(screen.getByText(chunks[1].content)).toBeInTheDocument()
|
||||
expect(screen.getByText(chunks[2].content)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render parent-child chunks correctly', () => {
|
||||
// Arrange
|
||||
const chunks = createParentChildChunks()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -569,17 +475,14 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should render child contents from parent-child chunks
|
||||
expect(screen.getByText('Child content 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child content 2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Another child 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render QA chunks correctly', () => {
|
||||
// Arrange
|
||||
const chunks = createQAChunks()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -587,7 +490,6 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('What is the answer to life?')).toBeInTheDocument()
|
||||
expect(screen.getByText('The answer is 42.')).toBeInTheDocument()
|
||||
expect(screen.getByText('How does this work?')).toBeInTheDocument()
|
||||
@@ -595,16 +497,13 @@ describe('ChunkCardList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for chunkList memoization
|
||||
describe('Memoization - chunkList', () => {
|
||||
it('should extract chunks from GeneralChunks for text mode', () => {
|
||||
// Arrange
|
||||
const chunks: GeneralChunks = [
|
||||
{ content: 'Chunk 1' },
|
||||
{ content: 'Chunk 2' },
|
||||
]
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -612,20 +511,17 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chunk 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should extract parent_child_chunks from ParentChildChunks for parentChild mode', () => {
|
||||
// Arrange
|
||||
const chunks = createParentChildChunks({
|
||||
parent_child_chunks: [
|
||||
createParentChildChunk({ child_contents: ['Specific child'] }),
|
||||
],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -634,19 +530,16 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Specific child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should extract qa_chunks from QAChunks for qa mode', () => {
|
||||
// Arrange
|
||||
const chunks: QAChunks = {
|
||||
qa_chunks: [
|
||||
{ question: 'Specific Q', answer: 'Specific A' },
|
||||
],
|
||||
}
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -654,13 +547,11 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Specific Q')).toBeInTheDocument()
|
||||
expect(screen.getByText('Specific A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update chunkList when chunkInfo changes', () => {
|
||||
// Arrange
|
||||
const initialChunks = createGeneralChunks([{ content: 'Initial chunk' }])
|
||||
|
||||
const { rerender } = render(
|
||||
@@ -670,10 +561,8 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert initial state
|
||||
expect(screen.getByText('Initial chunk')).toBeInTheDocument()
|
||||
|
||||
// Act - update chunks
|
||||
const updatedChunks = createGeneralChunks([{ content: 'Updated chunk' }])
|
||||
rerender(
|
||||
<ChunkCardList
|
||||
@@ -682,19 +571,15 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert updated state
|
||||
expect(screen.getByText('Updated chunk')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Initial chunk')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getWordCount function
|
||||
describe('Word Count Calculation', () => {
|
||||
it('should calculate word count for text chunks using string length', () => {
|
||||
// Arrange - "Hello" has 5 characters
|
||||
const chunks = createGeneralChunks([{ content: 'Hello' }])
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -702,12 +587,10 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - word count should be 5 (string length)
|
||||
expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should calculate word count for parent-child chunks using parent_content length', () => {
|
||||
// Arrange - parent_content length determines word count
|
||||
const chunks = createParentChildChunks({
|
||||
parent_child_chunks: [
|
||||
createParentChildChunk({
|
||||
@@ -717,7 +600,6 @@ describe('ChunkCardList', () => {
|
||||
],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -726,19 +608,16 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - word count should be 6 (parent_content length)
|
||||
expect(screen.getByText(/6\s+(?:\S.*)?characters/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should calculate word count for QA chunks using question + answer length', () => {
|
||||
// Arrange - "Hi" (2) + "Bye" (3) = 5
|
||||
const chunks: QAChunks = {
|
||||
qa_chunks: [
|
||||
{ question: 'Hi', answer: 'Bye' },
|
||||
],
|
||||
}
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -746,22 +625,18 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - word count should be 5 (question.length + answer.length)
|
||||
expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for position ID assignment
|
||||
describe('Position ID', () => {
|
||||
it('should assign 1-based position IDs to chunks', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks([
|
||||
{ content: 'First' },
|
||||
{ content: 'Second' },
|
||||
{ content: 'Third' },
|
||||
])
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -769,20 +644,16 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - position IDs should be 1, 2, 3
|
||||
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-02/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-03/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for className prop
|
||||
describe('Custom className', () => {
|
||||
it('should apply custom className to container', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks([{ content: 'Test' }])
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -791,15 +662,12 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks([{ content: 'Test' }])
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -808,7 +676,6 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should have both default and custom classes
|
||||
expect(container.firstChild).toHaveClass('flex')
|
||||
expect(container.firstChild).toHaveClass('w-full')
|
||||
expect(container.firstChild).toHaveClass('flex-col')
|
||||
@@ -816,10 +683,8 @@ describe('ChunkCardList', () => {
|
||||
})
|
||||
|
||||
it('should render without className prop', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks([{ content: 'Test' }])
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -827,19 +692,15 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should have default classes
|
||||
expect(container.firstChild).toHaveClass('flex')
|
||||
expect(container.firstChild).toHaveClass('w-full')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for parentMode prop
|
||||
describe('Parent Mode', () => {
|
||||
it('should pass parentMode to ChunkCard for parent-child type', () => {
|
||||
// Arrange
|
||||
const chunks = createParentChildChunks()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -848,15 +709,12 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - paragraph mode shows Parent-Chunk label
|
||||
expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle full-doc parentMode', () => {
|
||||
// Arrange
|
||||
const chunks = createParentChildChunks()
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -865,16 +723,13 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - full-doc mode hides chunk labels
|
||||
expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/Chunk-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not use parentMode for text type', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks([{ content: 'Text' }])
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -883,18 +738,14 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should show Chunk label, not affected by parentMode
|
||||
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty GeneralChunks array', () => {
|
||||
// Arrange
|
||||
const chunks: GeneralChunks = []
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -902,19 +753,16 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should render empty container
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild?.childNodes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle empty ParentChildChunks', () => {
|
||||
// Arrange
|
||||
const chunks: ParentChildChunks = {
|
||||
parent_child_chunks: [],
|
||||
parent_mode: 'paragraph',
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -923,18 +771,15 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild?.childNodes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle empty QAChunks', () => {
|
||||
// Arrange
|
||||
const chunks: QAChunks = {
|
||||
qa_chunks: [],
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -942,16 +787,13 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild?.childNodes.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle single item in chunks', () => {
|
||||
// Arrange
|
||||
const chunks = createGeneralChunks([{ content: 'Single chunk' }])
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -959,16 +801,13 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Single chunk')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large number of chunks', () => {
|
||||
// Arrange
|
||||
const chunks = Array.from({ length: 100 }, (_, i) => ({ content: `Chunk number ${i + 1}` }))
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -976,23 +815,19 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk number 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Chunk number 100')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-100/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for key uniqueness
|
||||
describe('Key Generation', () => {
|
||||
it('should generate unique keys for chunks', () => {
|
||||
// Arrange - chunks with same content
|
||||
const chunks = createGeneralChunks([
|
||||
{ content: 'Same content' },
|
||||
{ content: 'Same content' },
|
||||
{ content: 'Same content' },
|
||||
])
|
||||
// Act
|
||||
const { container } = render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -1000,33 +835,25 @@ describe('ChunkCardList', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - all three should render (keys are based on chunkType-index)
|
||||
const chunkCards = container.querySelectorAll('.bg-components-panel-bg')
|
||||
expect(chunkCards.length).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('ChunkCardList Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Tests for complete workflow scenarios
|
||||
describe('Complete Workflows', () => {
|
||||
it('should render complete text chunking workflow', () => {
|
||||
// Arrange
|
||||
const textChunks = createGeneralChunks([
|
||||
{ content: 'First paragraph of the document.' },
|
||||
{ content: 'Second paragraph with more information.' },
|
||||
{ content: 'Final paragraph concluding the content.' },
|
||||
])
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.text}
|
||||
@@ -1034,10 +861,8 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('First paragraph of the document.')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
|
||||
// "First paragraph of the document." = 32 characters
|
||||
expect(screen.getByText(/32\s+(?:\S.*)?characters/)).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('Second paragraph with more information.')).toBeInTheDocument()
|
||||
@@ -1048,7 +873,6 @@ describe('ChunkCardList Integration', () => {
|
||||
})
|
||||
|
||||
it('should render complete parent-child chunking workflow', () => {
|
||||
// Arrange
|
||||
const parentChildChunks = createParentChildChunks({
|
||||
parent_child_chunks: [
|
||||
{
|
||||
@@ -1062,7 +886,6 @@ describe('ChunkCardList Integration', () => {
|
||||
],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -1071,7 +894,6 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('React components are building blocks.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lifecycle methods control component behavior.')).toBeInTheDocument()
|
||||
expect(screen.getByText('C-1')).toBeInTheDocument()
|
||||
@@ -1080,7 +902,6 @@ describe('ChunkCardList Integration', () => {
|
||||
})
|
||||
|
||||
it('should render complete QA chunking workflow', () => {
|
||||
// Arrange
|
||||
const qaChunks = createQAChunks({
|
||||
qa_chunks: [
|
||||
{
|
||||
@@ -1094,7 +915,6 @@ describe('ChunkCardList Integration', () => {
|
||||
],
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -1102,7 +922,6 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const qLabels = screen.getAllByText('Q')
|
||||
const aLabels = screen.getAllByText('A')
|
||||
expect(qLabels.length).toBe(2)
|
||||
@@ -1115,10 +934,8 @@ describe('ChunkCardList Integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for type switching scenarios
|
||||
describe('Type Switching', () => {
|
||||
it('should handle switching from text to QA type', () => {
|
||||
// Arrange
|
||||
const textChunks = createGeneralChunks([{ content: 'Text content' }])
|
||||
const qaChunks = createQAChunks()
|
||||
|
||||
@@ -1129,10 +946,8 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert initial text state
|
||||
expect(screen.getByText('Text content')).toBeInTheDocument()
|
||||
|
||||
// Act - switch to QA
|
||||
rerender(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.qa}
|
||||
@@ -1140,13 +955,11 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert QA state
|
||||
expect(screen.queryByText('Text content')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('What is the answer to life?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching from text to parent-child type', () => {
|
||||
// Arrange
|
||||
const textChunks = createGeneralChunks([{ content: 'Simple text' }])
|
||||
const parentChildChunks = createParentChildChunks()
|
||||
|
||||
@@ -1157,11 +970,9 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert initial state
|
||||
expect(screen.getByText('Simple text')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Chunk-01/)).toBeInTheDocument()
|
||||
|
||||
// Act - switch to parent-child
|
||||
rerender(
|
||||
<ChunkCardList
|
||||
chunkType={ChunkingMode.parentChild}
|
||||
@@ -1170,9 +981,7 @@ describe('ChunkCardList Integration', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert parent-child state
|
||||
expect(screen.queryByText('Simple text')).not.toBeInTheDocument()
|
||||
// Multiple Parent-Chunk elements exist, so use getAllByText
|
||||
expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import RagPipelinePanel from './index'
|
||||
import RagPipelinePanel from '../index'
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Dependencies
|
||||
// ============================================================================
|
||||
|
||||
// Mock reactflow to avoid zustand provider error
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => [],
|
||||
useStoreApi: () => ({
|
||||
@@ -26,20 +21,12 @@ vi.mock('reactflow', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Use vi.hoisted to create variables that can be used in vi.mock
|
||||
const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => {
|
||||
let counter = 0
|
||||
const mockInputFieldEditorProps = vi.fn()
|
||||
|
||||
const createMockComponent = () => {
|
||||
const index = counter++
|
||||
// Order matches the imports in index.tsx:
|
||||
// 0: Record
|
||||
// 1: TestRunPanel
|
||||
// 2: InputFieldPanel
|
||||
// 3: InputFieldEditorPanel
|
||||
// 4: PreviewPanel
|
||||
// 5: GlobalVariablePanel
|
||||
switch (index) {
|
||||
case 0:
|
||||
return () => <div data-testid="record-panel">Record Panel</div>
|
||||
@@ -69,14 +56,12 @@ const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => {
|
||||
return { dynamicMocks: { createMockComponent }, mockInputFieldEditorProps }
|
||||
})
|
||||
|
||||
// Mock next/dynamic
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record<string, unknown>) => {
|
||||
return dynamicMocks.createMockComponent()
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
let mockHistoryWorkflowData: Record<string, unknown> | null = null
|
||||
let mockShowDebugAndPreviewPanel = false
|
||||
let mockShowGlobalVariablePanel = false
|
||||
@@ -138,7 +123,6 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Panel component to capture props and render children
|
||||
let capturedPanelProps: PanelProps | null = null
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: (props: PanelProps) => {
|
||||
@@ -152,10 +136,6 @@ vi.mock('@/app/components/workflow/panel', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
type SetupMockOptions = {
|
||||
historyWorkflowData?: Record<string, unknown> | null
|
||||
showDebugAndPreviewPanel?: boolean
|
||||
@@ -177,35 +157,24 @@ const setupMocks = (options?: SetupMockOptions) => {
|
||||
capturedPanelProps = null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RagPipelinePanel Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('RagPipelinePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render Panel component with correct structure', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('panel-left')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('panel-right')).toBeInTheDocument()
|
||||
@@ -213,13 +182,10 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
|
||||
it('should pass versionHistoryPanelProps to Panel', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'my-pipeline-456' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined()
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
|
||||
@@ -229,18 +195,12 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests - versionHistoryPanelProps
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization - versionHistoryPanelProps', () => {
|
||||
it('should compute correct getVersionListUrl based on pipelineId', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'pipeline-abc' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
|
||||
'/rag/pipelines/pipeline-abc/workflows',
|
||||
@@ -249,13 +209,10 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
|
||||
it('should compute correct deleteVersionUrl function', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'pipeline-xyz' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const deleteUrl = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')
|
||||
expect(deleteUrl).toBe('/rag/pipelines/pipeline-xyz/workflows/version-1')
|
||||
@@ -263,13 +220,10 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
|
||||
it('should compute correct updateVersionUrl function', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'pipeline-def' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const updateUrl = capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-2')
|
||||
expect(updateUrl).toBe('/rag/pipelines/pipeline-def/workflows/version-2')
|
||||
@@ -277,63 +231,46 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
|
||||
it('should set latestVersionId to empty string', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests - panelProps
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization - panelProps', () => {
|
||||
it('should pass components.left to Panel', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.components?.left).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass components.right to Panel', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.components?.right).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass versionHistoryPanelProps to panelProps', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Component Memoization Tests (React.memo)
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', async () => {
|
||||
// The component should not break when re-rendered
|
||||
const { rerender } = render(<RagPipelinePanel />)
|
||||
|
||||
// Act - rerender without prop changes
|
||||
rerender(<RagPipelinePanel />)
|
||||
|
||||
// Assert - component should still render correctly
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
|
||||
})
|
||||
@@ -341,138 +278,98 @@ describe('RagPipelinePanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// RagPipelinePanelOnRight Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('RagPipelinePanelOnRight', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conditional Rendering - Record Panel
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Record Panel Conditional Rendering', () => {
|
||||
it('should render Record panel when historyWorkflowData exists', async () => {
|
||||
// Arrange
|
||||
setupMocks({ historyWorkflowData: { id: 'history-1' } })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Record panel when historyWorkflowData is null', async () => {
|
||||
// Arrange
|
||||
setupMocks({ historyWorkflowData: null })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Record panel when historyWorkflowData is undefined', async () => {
|
||||
// Arrange
|
||||
setupMocks({ historyWorkflowData: undefined })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conditional Rendering - TestRun Panel
|
||||
// -------------------------------------------------------------------------
|
||||
describe('TestRun Panel Conditional Rendering', () => {
|
||||
it('should render TestRun panel when showDebugAndPreviewPanel is true', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showDebugAndPreviewPanel: true })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render TestRun panel when showDebugAndPreviewPanel is false', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showDebugAndPreviewPanel: false })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conditional Rendering - GlobalVariable Panel
|
||||
// -------------------------------------------------------------------------
|
||||
describe('GlobalVariable Panel Conditional Rendering', () => {
|
||||
it('should render GlobalVariable panel when showGlobalVariablePanel is true', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showGlobalVariablePanel: true })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render GlobalVariable panel when showGlobalVariablePanel is false', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showGlobalVariablePanel: false })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('global-variable-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Multiple Panels Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Multiple Panels Rendering', () => {
|
||||
it('should render all right panels when all conditions are true', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
historyWorkflowData: { id: 'history-1' },
|
||||
showDebugAndPreviewPanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
|
||||
@@ -481,17 +378,14 @@ describe('RagPipelinePanelOnRight', () => {
|
||||
})
|
||||
|
||||
it('should render no right panels when all conditions are false', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
historyWorkflowData: null,
|
||||
showDebugAndPreviewPanel: false,
|
||||
showGlobalVariablePanel: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('test-run-panel')).not.toBeInTheDocument()
|
||||
@@ -500,17 +394,14 @@ describe('RagPipelinePanelOnRight', () => {
|
||||
})
|
||||
|
||||
it('should render only Record and TestRun panels', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
historyWorkflowData: { id: 'history-1' },
|
||||
showDebugAndPreviewPanel: true,
|
||||
showGlobalVariablePanel: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
|
||||
@@ -520,53 +411,36 @@ describe('RagPipelinePanelOnRight', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// RagPipelinePanelOnLeft Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('RagPipelinePanelOnLeft', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conditional Rendering - Preview Panel
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Preview Panel Conditional Rendering', () => {
|
||||
it('should render Preview panel when showInputFieldPreviewPanel is true', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showInputFieldPreviewPanel: true })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render Preview panel when showInputFieldPreviewPanel is false', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showInputFieldPreviewPanel: false })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conditional Rendering - InputFieldEditor Panel
|
||||
// -------------------------------------------------------------------------
|
||||
describe('InputFieldEditor Panel Conditional Rendering', () => {
|
||||
it('should render InputFieldEditor panel when inputFieldEditPanelProps is provided', async () => {
|
||||
// Arrange
|
||||
const editProps = {
|
||||
onClose: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
@@ -574,30 +448,24 @@ describe('RagPipelinePanelOnLeft', () => {
|
||||
}
|
||||
setupMocks({ inputFieldEditPanelProps: editProps })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render InputFieldEditor panel when inputFieldEditPanelProps is null', async () => {
|
||||
// Arrange
|
||||
setupMocks({ inputFieldEditPanelProps: null })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass props to InputFieldEditor panel', async () => {
|
||||
// Arrange
|
||||
const editProps = {
|
||||
onClose: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
@@ -605,10 +473,8 @@ describe('RagPipelinePanelOnLeft', () => {
|
||||
}
|
||||
setupMocks({ inputFieldEditPanelProps: editProps })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockInputFieldEditorProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -621,53 +487,38 @@ describe('RagPipelinePanelOnLeft', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Conditional Rendering - InputField Panel
|
||||
// -------------------------------------------------------------------------
|
||||
describe('InputField Panel Conditional Rendering', () => {
|
||||
it('should render InputField panel when showInputFieldPanel is true', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showInputFieldPanel: true })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('input-field-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not render InputField panel when showInputFieldPanel is false', async () => {
|
||||
// Arrange
|
||||
setupMocks({ showInputFieldPanel: false })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('input-field-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Multiple Panels Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Multiple Left Panels Rendering', () => {
|
||||
it('should render all left panels when all conditions are true', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
showInputFieldPreviewPanel: true,
|
||||
inputFieldEditPanelProps: { onClose: vi.fn(), onSubmit: vi.fn() },
|
||||
showInputFieldPanel: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('input-field-editor-panel')).toBeInTheDocument()
|
||||
@@ -676,17 +527,14 @@ describe('RagPipelinePanelOnLeft', () => {
|
||||
})
|
||||
|
||||
it('should render no left panels when all conditions are false', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
showInputFieldPreviewPanel: false,
|
||||
inputFieldEditPanelProps: null,
|
||||
showInputFieldPanel: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('preview-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
|
||||
@@ -695,17 +543,14 @@ describe('RagPipelinePanelOnLeft', () => {
|
||||
})
|
||||
|
||||
it('should render only Preview and InputField panels', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
showInputFieldPreviewPanel: true,
|
||||
inputFieldEditPanelProps: null,
|
||||
showInputFieldPanel: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('input-field-editor-panel')).not.toBeInTheDocument()
|
||||
@@ -715,28 +560,18 @@ describe('RagPipelinePanelOnLeft', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty/Undefined Values
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Empty/Undefined Values', () => {
|
||||
it('should handle empty pipelineId gracefully', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: '' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
|
||||
'/rag/pipelines//workflows',
|
||||
@@ -745,13 +580,10 @@ describe('Edge Cases', () => {
|
||||
})
|
||||
|
||||
it('should handle special characters in pipelineId', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'pipeline-with-special_chars.123' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe(
|
||||
'/rag/pipelines/pipeline-with-special_chars.123/workflows',
|
||||
@@ -760,12 +592,8 @@ describe('Edge Cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Props Spreading Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Props Spreading', () => {
|
||||
it('should correctly spread inputFieldEditPanelProps to editor component', async () => {
|
||||
// Arrange
|
||||
const customProps = {
|
||||
onClose: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
@@ -778,10 +606,8 @@ describe('Edge Cases', () => {
|
||||
}
|
||||
setupMocks({ inputFieldEditPanelProps: customProps })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockInputFieldEditorProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -792,12 +618,8 @@ describe('Edge Cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State Combinations
|
||||
// -------------------------------------------------------------------------
|
||||
describe('State Combinations', () => {
|
||||
it('should handle all panels visible simultaneously', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
historyWorkflowData: { id: 'h1' },
|
||||
showDebugAndPreviewPanel: true,
|
||||
@@ -807,10 +629,8 @@ describe('Edge Cases', () => {
|
||||
showInputFieldPanel: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert - All panels should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
|
||||
@@ -823,10 +643,6 @@ describe('Edge Cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// URL Generator Functions Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('URL Generator Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -834,13 +650,10 @@ describe('URL Generator Functions', () => {
|
||||
})
|
||||
|
||||
it('should return consistent URLs for same versionId', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'stable-pipeline' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x')
|
||||
const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-x')
|
||||
@@ -849,13 +662,10 @@ describe('URL Generator Functions', () => {
|
||||
})
|
||||
|
||||
it('should return different URLs for different versionIds', async () => {
|
||||
// Arrange
|
||||
setupMocks({ pipelineId: 'stable-pipeline' })
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const deleteUrl1 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')
|
||||
const deleteUrl2 = capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-2')
|
||||
@@ -866,10 +676,6 @@ describe('URL Generator Functions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Type Safety Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Type Safety', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -877,10 +683,8 @@ describe('Type Safety', () => {
|
||||
})
|
||||
|
||||
it('should pass correct PanelProps structure', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert - Check structure matches PanelProps
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps).toHaveProperty('components')
|
||||
expect(capturedPanelProps).toHaveProperty('versionHistoryPanelProps')
|
||||
@@ -890,10 +694,8 @@ describe('Type Safety', () => {
|
||||
})
|
||||
|
||||
it('should pass correct versionHistoryPanelProps structure', async () => {
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('getVersionListUrl')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps).toHaveProperty('deleteVersionUrl')
|
||||
@@ -903,10 +705,6 @@ describe('Type Safety', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Performance Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Performance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -914,24 +712,17 @@ describe('Performance', () => {
|
||||
})
|
||||
|
||||
it('should handle multiple rerenders without issues', async () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<RagPipelinePanel />)
|
||||
|
||||
// Act - Multiple rerenders
|
||||
for (let i = 0; i < 10; i++)
|
||||
rerender(<RagPipelinePanel />)
|
||||
|
||||
// Assert - Component should still work
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -939,28 +730,23 @@ describe('Integration Tests', () => {
|
||||
})
|
||||
|
||||
it('should pass correct components to Panel', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
historyWorkflowData: { id: 'h1' },
|
||||
showInputFieldPanel: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.components?.left).toBeDefined()
|
||||
expect(capturedPanelProps?.components?.right).toBeDefined()
|
||||
|
||||
// Check that the components are React elements
|
||||
expect(React.isValidElement(capturedPanelProps?.components?.left)).toBe(true)
|
||||
expect(React.isValidElement(capturedPanelProps?.components?.right)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should correctly consume all store selectors', async () => {
|
||||
// Arrange
|
||||
setupMocks({
|
||||
historyWorkflowData: { id: 'test-history' },
|
||||
showDebugAndPreviewPanel: true,
|
||||
@@ -971,10 +757,8 @@ describe('Integration Tests', () => {
|
||||
pipelineId: 'integration-test-pipeline',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<RagPipelinePanel />)
|
||||
|
||||
// Assert - All store-dependent rendering should work
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('record-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('test-run-panel')).toBeInTheDocument()
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import FooterTip from './footer-tip'
|
||||
import FooterTip from '../footer-tip'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -45,7 +45,6 @@ describe('FooterTip', () => {
|
||||
it('should render the drag icon', () => {
|
||||
const { container } = render(<FooterTip />)
|
||||
|
||||
// The RiDragDropLine icon should be rendered
|
||||
const icon = container.querySelector('.size-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useFloatingRight } from './hooks'
|
||||
import { useFloatingRight } from '../hooks'
|
||||
|
||||
// Mock reactflow
|
||||
const mockGetNodes = vi.fn()
|
||||
vi.mock('reactflow', () => ({
|
||||
useStore: (selector: (s: { getNodes: () => { id: string, data: { selected: boolean } }[] }) => unknown) => {
|
||||
@@ -10,12 +9,10 @@ vi.mock('reactflow', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock zustand/react/shallow
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: (fn: (...args: unknown[]) => unknown) => fn,
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
let mockNodePanelWidth = 400
|
||||
let mockWorkflowCanvasWidth: number | undefined = 1200
|
||||
let mockOtherPanelWidth = 0
|
||||
@@ -67,8 +64,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(400))
|
||||
|
||||
// leftWidth = 1000 - 0 (no selected node) - 0 - 400 - 4 = 596
|
||||
// 596 >= 404 so floatingRight should be false
|
||||
expect(result.current.floatingRight).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -80,8 +75,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(400))
|
||||
|
||||
// leftWidth = 1200 - 400 (node panel) - 0 - 400 - 4 = 396
|
||||
// 396 < 404 so floatingRight should be true
|
||||
expect(result.current.floatingRight).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -103,7 +96,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(600))
|
||||
|
||||
// When floating and no selected node, width = min(600, 0 + 200) = 200
|
||||
expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600)
|
||||
})
|
||||
|
||||
@@ -115,7 +107,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(600))
|
||||
|
||||
// When floating with selected node, width = min(600, 300 + 100) = 400
|
||||
expect(result.current.floatingRightWidth).toBeLessThanOrEqual(600)
|
||||
})
|
||||
})
|
||||
@@ -127,7 +118,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(400))
|
||||
|
||||
// Should not throw and should maintain initial state
|
||||
expect(result.current.floatingRight).toBe(false)
|
||||
})
|
||||
|
||||
@@ -145,7 +135,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(10000))
|
||||
|
||||
// Should be floating due to limited space
|
||||
expect(result.current.floatingRight).toBe(true)
|
||||
})
|
||||
|
||||
@@ -159,7 +148,6 @@ describe('useFloatingRight', () => {
|
||||
|
||||
const { result } = renderHook(() => useFloatingRight(400))
|
||||
|
||||
// Should have selected node so node panel is considered
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -5,19 +5,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import InputFieldPanel from './index'
|
||||
import InputFieldPanel from '../index'
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Dependencies
|
||||
// ============================================================================
|
||||
|
||||
// Mock reactflow hooks - use getter to allow dynamic updates
|
||||
let mockNodesData: Node<DataSourceNodeType>[] = []
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockNodesData,
|
||||
}))
|
||||
|
||||
// Mock useInputFieldPanel hook
|
||||
const mockCloseAllInputFieldPanels = vi.fn()
|
||||
const mockToggleInputFieldPreviewPanel = vi.fn()
|
||||
let mockIsPreviewing = false
|
||||
@@ -32,7 +26,6 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useStore (workflow store)
|
||||
let mockRagPipelineVariables: RAGPipelineVariables = []
|
||||
const mockSetRagPipelineVariables = vi.fn()
|
||||
|
||||
@@ -56,7 +49,6 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useNodesSyncDraft hook
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
@@ -65,8 +57,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock FieldList component
|
||||
vi.mock('./field-list', () => ({
|
||||
vi.mock('../field-list', () => ({
|
||||
default: ({
|
||||
nodeId,
|
||||
LabelRightContent,
|
||||
@@ -124,13 +115,11 @@ vi.mock('./field-list', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock FooterTip component
|
||||
vi.mock('./footer-tip', () => ({
|
||||
vi.mock('../footer-tip', () => ({
|
||||
default: () => <div data-testid="footer-tip">Footer Tip</div>,
|
||||
}))
|
||||
|
||||
// Mock Datasource label component
|
||||
vi.mock('./label-right-content/datasource', () => ({
|
||||
vi.mock('../label-right-content/datasource', () => ({
|
||||
default: ({ nodeData }: { nodeData: DataSourceNodeType }) => (
|
||||
<div data-testid={`datasource-label-${nodeData.title}`}>
|
||||
{nodeData.title}
|
||||
@@ -138,15 +127,10 @@ vi.mock('./label-right-content/datasource', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock GlobalInputs label component
|
||||
vi.mock('./label-right-content/global-inputs', () => ({
|
||||
vi.mock('../label-right-content/global-inputs', () => ({
|
||||
default: () => <div data-testid="global-inputs-label">Global Inputs</div>,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createInputVar = (overrides?: Partial<InputVar>): InputVar => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Test Label',
|
||||
@@ -189,10 +173,6 @@ const createDataSourceNode = (
|
||||
} as DataSourceNodeType,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
const setupMocks = (options?: {
|
||||
nodes?: Node<DataSourceNodeType>[]
|
||||
ragPipelineVariables?: RAGPipelineVariables
|
||||
@@ -205,148 +185,110 @@ const setupMocks = (options?: {
|
||||
mockIsEditing = options?.isEditing || false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InputFieldPanel Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('InputFieldPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render panel without crashing', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.inputFieldPanel.title'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render panel title correctly', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.inputFieldPanel.title'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render panel description', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.inputFieldPanel.description'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render preview button', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.operations.preview'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
const closeButton = screen.getByRole('button', { name: '' })
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer tip component', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('footer-tip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unique inputs section title', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.inputFieldPanel.uniqueInputs.title'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render global inputs field list', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('global-inputs-label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DataSource Node Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('DataSource Node Rendering', () => {
|
||||
it('should render field list for each datasource node', () => {
|
||||
// Arrange
|
||||
const nodes = [
|
||||
createDataSourceNode('node-1', 'DataSource 1'),
|
||||
createDataSourceNode('node-2', 'DataSource 2'),
|
||||
]
|
||||
setupMocks({ nodes })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('field-list-node-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render datasource label for each node', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'My DataSource')]
|
||||
setupMocks({ nodes })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByTestId('datasource-label-My DataSource'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render any datasource field lists when no nodes exist', () => {
|
||||
// Arrange
|
||||
setupMocks({ nodes: [] })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('field-list-node-1')).not.toBeInTheDocument()
|
||||
// Global inputs should still render
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter only DataSource type nodes', () => {
|
||||
// Arrange
|
||||
const dataSourceNode = createDataSourceNode('ds-node', 'DataSource Node')
|
||||
// Create a non-datasource node to verify filtering
|
||||
const otherNode = {
|
||||
id: 'other-node',
|
||||
type: 'custom',
|
||||
@@ -359,10 +301,8 @@ describe('InputFieldPanel', () => {
|
||||
} as Node<DataSourceNodeType>
|
||||
mockNodesData = [dataSourceNode, otherNode]
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-ds-node')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('field-list-other-node'),
|
||||
@@ -370,12 +310,8 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input Fields Map Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Input Fields Map', () => {
|
||||
it('should correctly distribute variables to their nodes', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'var1' }),
|
||||
@@ -384,28 +320,22 @@ describe('InputFieldPanel', () => {
|
||||
]
|
||||
setupMocks({ nodes, ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should show zero fields for nodes without variables', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
setupMocks({ nodes, ragPipelineVariables: [] })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should pass all variable names to field lists', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'var1' }),
|
||||
@@ -413,10 +343,8 @@ describe('InputFieldPanel', () => {
|
||||
]
|
||||
setupMocks({ nodes, ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-all-vars-node-1')).toHaveTextContent(
|
||||
'var1,var2',
|
||||
)
|
||||
@@ -426,48 +354,35 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
// Helper to identify close button by its class
|
||||
const isCloseButton = (btn: HTMLElement) =>
|
||||
btn.classList.contains('size-6')
|
||||
|| btn.className.includes('shrink-0 items-center justify-center p-0.5')
|
||||
|
||||
it('should call closeAllInputFieldPanels when close button is clicked', () => {
|
||||
// Arrange
|
||||
render(<InputFieldPanel />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const closeButton = buttons.find(isCloseButton)
|
||||
|
||||
// Act
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
// Assert
|
||||
expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call toggleInputFieldPreviewPanel when preview button is clicked', () => {
|
||||
// Arrange
|
||||
render(<InputFieldPanel />)
|
||||
const previewButton = screen.getByText('datasetPipeline.operations.preview')
|
||||
|
||||
// Act
|
||||
fireEvent.click(previewButton)
|
||||
|
||||
// Assert
|
||||
expect(mockToggleInputFieldPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable preview button when editing', () => {
|
||||
// Arrange
|
||||
setupMocks({ isEditing: true })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
const previewButton = screen
|
||||
.getByText('datasetPipeline.operations.preview')
|
||||
.closest('button')
|
||||
@@ -475,13 +390,10 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
|
||||
it('should not disable preview button when not editing', () => {
|
||||
// Arrange
|
||||
setupMocks({ isEditing: false })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
const previewButton = screen
|
||||
.getByText('datasetPipeline.operations.preview')
|
||||
.closest('button')
|
||||
@@ -489,18 +401,12 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Preview State Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Preview State', () => {
|
||||
it('should apply active styling when previewing', () => {
|
||||
// Arrange
|
||||
setupMocks({ isPreviewing: true })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
const previewButton = screen
|
||||
.getByText('datasetPipeline.operations.preview')
|
||||
.closest('button')
|
||||
@@ -509,81 +415,62 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
|
||||
it('should set readonly to true when previewing', () => {
|
||||
// Arrange
|
||||
setupMocks({ isPreviewing: true })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent(
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should set readonly to true when editing', () => {
|
||||
// Arrange
|
||||
setupMocks({ isEditing: true })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent(
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should set readonly to false when not previewing or editing', () => {
|
||||
// Arrange
|
||||
setupMocks({ isPreviewing: false, isEditing: false })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent(
|
||||
'false',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input Fields Change Handler Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Input Fields Change Handler', () => {
|
||||
it('should update rag pipeline variables when input fields change', async () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
setupMocks({ nodes })
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSyncWorkflowDraft when fields change', async () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
setupMocks({ nodes })
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should place datasource node fields before global fields', async () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
const variables = [
|
||||
createRAGPipelineVariable('shared', { variable: 'shared_var' }),
|
||||
@@ -591,15 +478,12 @@ describe('InputFieldPanel', () => {
|
||||
setupMocks({ nodes, ragPipelineVariables: variables })
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Verify datasource fields come before shared fields
|
||||
const setVarsCall = mockSetRagPipelineVariables.mock.calls[0][0] as RAGPipelineVariables
|
||||
const isNotShared = (v: RAGPipelineVariable) => v.belong_to_node_id !== 'shared'
|
||||
const isShared = (v: RAGPipelineVariable) => v.belong_to_node_id === 'shared'
|
||||
@@ -614,7 +498,6 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
|
||||
it('should handle removing all fields from a node', async () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'var1' }),
|
||||
@@ -623,24 +506,19 @@ describe('InputFieldPanel', () => {
|
||||
setupMocks({ nodes, ragPipelineVariables: variables })
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('trigger-remove-node-1'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update global input fields correctly', async () => {
|
||||
// Arrange
|
||||
setupMocks()
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('trigger-change-shared'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
|
||||
})
|
||||
@@ -652,54 +530,39 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Label Class Name Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Label Class Names', () => {
|
||||
it('should pass correct className to datasource field lists', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
setupMocks({ nodes })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByTestId('field-list-classname-node-1'),
|
||||
).toHaveTextContent('pt-1 pb-1')
|
||||
})
|
||||
|
||||
it('should pass correct className to global inputs field list', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-classname-shared')).toHaveTextContent(
|
||||
'pt-2 pb-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should memoize datasourceNodeDataMap based on nodes', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
setupMocks({ nodes })
|
||||
const { rerender } = render(<InputFieldPanel />)
|
||||
|
||||
// Act - rerender with same nodes reference
|
||||
rerender(<InputFieldPanel />)
|
||||
|
||||
// Assert - component should not break and should render correctly
|
||||
expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should compute allVariableNames correctly', () => {
|
||||
// Arrange
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'alpha' }),
|
||||
createRAGPipelineVariable('node-1', { variable: 'beta' }),
|
||||
@@ -707,21 +570,15 @@ describe('InputFieldPanel', () => {
|
||||
]
|
||||
setupMocks({ ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
|
||||
'alpha,beta,gamma',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Callback Stability Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Callback Stability', () => {
|
||||
// Helper to find close button - moved outside test to reduce nesting
|
||||
const findCloseButton = (buttons: HTMLElement[]) => {
|
||||
const isCloseButton = (btn: HTMLElement) =>
|
||||
btn.classList.contains('size-6')
|
||||
@@ -730,10 +587,8 @@ describe('InputFieldPanel', () => {
|
||||
}
|
||||
|
||||
it('should maintain closePanel callback reference', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
const buttons1 = screen.getAllByRole('button')
|
||||
fireEvent.click(findCloseButton(buttons1)!)
|
||||
const callCount1 = mockCloseAllInputFieldPanels.mock.calls.length
|
||||
@@ -742,126 +597,97 @@ describe('InputFieldPanel', () => {
|
||||
const buttons2 = screen.getAllByRole('button')
|
||||
fireEvent.click(findCloseButton(buttons2)!)
|
||||
|
||||
// Assert
|
||||
expect(mockCloseAllInputFieldPanels.mock.calls.length).toBe(callCount1 + 1)
|
||||
})
|
||||
|
||||
it('should maintain togglePreviewPanel callback reference', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.preview'))
|
||||
const callCount1 = mockToggleInputFieldPreviewPanel.mock.calls.length
|
||||
|
||||
rerender(<InputFieldPanel />)
|
||||
fireEvent.click(screen.getByText('datasetPipeline.operations.preview'))
|
||||
|
||||
// Assert
|
||||
expect(mockToggleInputFieldPreviewPanel.mock.calls.length).toBe(
|
||||
callCount1 + 1,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty ragPipelineVariables', () => {
|
||||
// Arrange
|
||||
setupMocks({ ragPipelineVariables: [] })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
|
||||
'',
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle undefined ragPipelineVariables', () => {
|
||||
// Arrange - intentionally testing undefined case
|
||||
// @ts-expect-error Testing edge case with undefined value
|
||||
mockRagPipelineVariables = undefined
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null variable names in allVariableNames', () => {
|
||||
// Arrange - intentionally testing edge case with empty variable name
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'valid_var' }),
|
||||
createRAGPipelineVariable('node-1', { variable: '' }),
|
||||
]
|
||||
setupMocks({ ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert - should not crash
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle large number of datasource nodes', () => {
|
||||
// Arrange
|
||||
const nodes = Array.from({ length: 10 }, (_, i) =>
|
||||
createDataSourceNode(`node-${i}`, `DataSource ${i}`))
|
||||
setupMocks({ nodes })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
nodes.forEach((_, i) => {
|
||||
expect(screen.getByTestId(`field-list-node-${i}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle large number of variables', () => {
|
||||
// Arrange
|
||||
const variables = Array.from({ length: 100 }, (_, i) =>
|
||||
createRAGPipelineVariable('shared', { variable: `var_${i}` }))
|
||||
setupMocks({ ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent(
|
||||
'100',
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle special characters in variable names', () => {
|
||||
// Arrange
|
||||
const variables = [
|
||||
createRAGPipelineVariable('shared', { variable: 'var_with_underscore' }),
|
||||
createRAGPipelineVariable('shared', { variable: 'varWithCamelCase' }),
|
||||
]
|
||||
setupMocks({ ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
|
||||
'var_with_underscore,varWithCamelCase',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Multiple Nodes Interaction Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Multiple Nodes Interaction', () => {
|
||||
it('should handle changes to multiple nodes sequentially', async () => {
|
||||
// Arrange
|
||||
const nodes = [
|
||||
createDataSourceNode('node-1', 'DataSource 1'),
|
||||
createDataSourceNode('node-2', 'DataSource 2'),
|
||||
@@ -869,18 +695,15 @@ describe('InputFieldPanel', () => {
|
||||
setupMocks({ nodes })
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
|
||||
fireEvent.click(screen.getByTestId('trigger-change-node-2'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain separate field lists for different nodes', () => {
|
||||
// Arrange
|
||||
const nodes = [
|
||||
createDataSourceNode('node-1', 'DataSource 1'),
|
||||
createDataSourceNode('node-2', 'DataSource 2'),
|
||||
@@ -892,42 +715,31 @@ describe('InputFieldPanel', () => {
|
||||
]
|
||||
setupMocks({ nodes, ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('field-list-fields-count-node-2')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Component Structure Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Component Structure', () => {
|
||||
it('should have correct panel width class', () => {
|
||||
// Act
|
||||
const { container } = render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
const panel = container.firstChild as HTMLElement
|
||||
expect(panel).toHaveClass('w-[400px]')
|
||||
})
|
||||
|
||||
it('should have overflow scroll on content area', () => {
|
||||
// Act
|
||||
const { container } = render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
const scrollContainer = container.querySelector('.overflow-y-auto')
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render header section with proper spacing', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.inputFieldPanel.title'),
|
||||
).toBeInTheDocument()
|
||||
@@ -937,12 +749,8 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integration with FieldList Component Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Integration with FieldList Component', () => {
|
||||
it('should pass correct props to FieldList for datasource nodes', () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'test_var' }),
|
||||
@@ -953,38 +761,29 @@ describe('InputFieldPanel', () => {
|
||||
isPreviewing: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-node-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('field-list-readonly-node-1')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('field-list-fields-count-node-1')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should pass correct props to FieldList for shared node', () => {
|
||||
// Arrange
|
||||
const variables = [
|
||||
createRAGPipelineVariable('shared', { variable: 'shared_var' }),
|
||||
]
|
||||
setupMocks({ ragPipelineVariables: variables, isEditing: true })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('field-list-readonly-shared')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('field-list-fields-count-shared')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Variable Ordering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Variable Ordering', () => {
|
||||
it('should maintain correct variable order in allVariableNames', () => {
|
||||
// Arrange
|
||||
const variables = [
|
||||
createRAGPipelineVariable('node-1', { variable: 'first' }),
|
||||
createRAGPipelineVariable('node-1', { variable: 'second' }),
|
||||
@@ -992,10 +791,8 @@ describe('InputFieldPanel', () => {
|
||||
]
|
||||
setupMocks({ ragPipelineVariables: variables })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('field-list-all-vars-shared')).toHaveTextContent(
|
||||
'first,second,third',
|
||||
)
|
||||
@@ -1003,13 +800,8 @@ describe('InputFieldPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// useFloatingRight Hook Integration Tests (via InputFieldPanel)
|
||||
// ============================================================================
|
||||
|
||||
describe('useFloatingRight Hook Integration', () => {
|
||||
// Note: The hook is tested indirectly through the InputFieldPanel component
|
||||
// as it's used internally. Direct hook tests are in hooks.spec.tsx if exists.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1017,16 +809,11 @@ describe('useFloatingRight Hook Integration', () => {
|
||||
})
|
||||
|
||||
it('should render panel correctly with default floating state', () => {
|
||||
// The hook is mocked via the component's behavior
|
||||
render(<InputFieldPanel />)
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// FooterTip Component Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('FooterTip Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1034,18 +821,12 @@ describe('FooterTip Integration', () => {
|
||||
})
|
||||
|
||||
it('should render footer tip at the bottom of the panel', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('footer-tip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Label Components Integration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Label Components Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1053,25 +834,20 @@ describe('Label Components Integration', () => {
|
||||
})
|
||||
|
||||
it('should render GlobalInputs label for shared field list', () => {
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('global-inputs-label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Datasource label for each datasource node', () => {
|
||||
// Arrange
|
||||
const nodes = [
|
||||
createDataSourceNode('node-1', 'First DataSource'),
|
||||
createDataSourceNode('node-2', 'Second DataSource'),
|
||||
]
|
||||
setupMocks({ nodes })
|
||||
|
||||
// Act
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByTestId('datasource-label-First DataSource'),
|
||||
).toBeInTheDocument()
|
||||
@@ -1081,10 +857,6 @@ describe('Label Components Integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Component Memo Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Component Memo Behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1092,14 +864,10 @@ describe('Component Memo Behavior', () => {
|
||||
})
|
||||
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// InputFieldPanel is exported as memo(InputFieldPanel)
|
||||
// This test ensures the component doesn't break memoization
|
||||
const { rerender } = render(<InputFieldPanel />)
|
||||
|
||||
// Act - rerender without prop changes
|
||||
rerender(<InputFieldPanel />)
|
||||
|
||||
// Assert - component should still render correctly
|
||||
expect(screen.getByTestId('field-list-shared')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('datasetPipeline.inputFieldPanel.title'),
|
||||
@@ -1107,15 +875,12 @@ describe('Component Memo Behavior', () => {
|
||||
})
|
||||
|
||||
it('should handle state updates correctly with memo', async () => {
|
||||
// Arrange
|
||||
const nodes = [createDataSourceNode('node-1', 'DataSource 1')]
|
||||
setupMocks({ nodes })
|
||||
render(<InputFieldPanel />)
|
||||
|
||||
// Act - trigger a state change
|
||||
fireEvent.click(screen.getByTestId('trigger-change-node-1'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalled()
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,366 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useConfigurations, useHiddenConfigurations, useHiddenFieldNames } from '../hooks'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
|
||||
useFileSizeLimit: () => ({
|
||||
imgSizeLimit: 10 * 1024 * 1024,
|
||||
docSizeLimit: 15 * 1024 * 1024,
|
||||
audioSizeLimit: 50 * 1024 * 1024,
|
||||
videoSizeLimit: 100 * 1024 * 1024,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DEFAULT_FILE_UPLOAD_SETTING: {
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
allowed_file_types: ['image', 'document'],
|
||||
allowed_file_extensions: ['.jpg', '.png', '.pdf'],
|
||||
max_length: 5,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../schema', () => ({
|
||||
TEXT_MAX_LENGTH: 256,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${Math.round(size / 1024 / 1024)}MB`,
|
||||
}))
|
||||
|
||||
describe('useHiddenFieldNames', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return field names for textInput type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput))
|
||||
|
||||
expect(result.current).toContain('variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('variableconfig.placeholder')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for paragraph type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.paragraph))
|
||||
|
||||
expect(result.current).toContain('variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('variableconfig.placeholder')
|
||||
expect(result.current).toContain('variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for number type including unit', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.number))
|
||||
|
||||
expect(result.current).toContain('appdebug.variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('appdebug.variableconfig.unit')
|
||||
expect(result.current).toContain('appdebug.variableconfig.placeholder')
|
||||
expect(result.current).toContain('appdebug.variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for select type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.select))
|
||||
|
||||
expect(result.current).toContain('appdebug.variableconfig.defaultvalue')
|
||||
expect(result.current).toContain('appdebug.variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for singleFile type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.singleFile))
|
||||
|
||||
expect(result.current).toContain('appdebug.variableconfig.uploadmethod')
|
||||
expect(result.current).toContain('appdebug.variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for multiFiles type including max number', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.multiFiles))
|
||||
|
||||
expect(result.current).toContain('appdebug.variableconfig.uploadmethod')
|
||||
expect(result.current).toContain('appdebug.variableconfig.maxnumberofuploads')
|
||||
expect(result.current).toContain('appdebug.variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return field names for checkbox type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.checkbox))
|
||||
|
||||
expect(result.current).toContain('appdebug.variableconfig.startchecked')
|
||||
expect(result.current).toContain('appdebug.variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return only tooltips for unknown type', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames('unknown-type' as PipelineInputVarType))
|
||||
|
||||
expect(result.current).toBe('appdebug.variableconfig.tooltips')
|
||||
})
|
||||
|
||||
it('should return comma-separated lowercase string', () => {
|
||||
const { result } = renderHook(() => useHiddenFieldNames(PipelineInputVarType.textInput))
|
||||
|
||||
expect(result.current).toMatch(/,/)
|
||||
expect(result.current).toBe(result.current.toLowerCase())
|
||||
})
|
||||
})
|
||||
|
||||
describe('useConfigurations', () => {
|
||||
let mockGetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>>
|
||||
let mockSetFieldValue: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetFieldValue = vi.fn()
|
||||
mockSetFieldValue = vi.fn()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return array of configurations', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current)).toBe(true)
|
||||
expect(result.current.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should include field type select configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
expect(typeConfig).toBeDefined()
|
||||
expect(typeConfig?.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should include variable name configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const varConfig = result.current.find(c => c.variable === 'variable')
|
||||
expect(varConfig).toBeDefined()
|
||||
expect(varConfig?.required).toBe(true)
|
||||
})
|
||||
|
||||
it('should include display name configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const labelConfig = result.current.find(c => c.variable === 'label')
|
||||
expect(labelConfig).toBeDefined()
|
||||
expect(labelConfig?.required).toBe(false)
|
||||
})
|
||||
|
||||
it('should include required checkbox configuration', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const requiredConfig = result.current.find(c => c.variable === 'required')
|
||||
expect(requiredConfig).toBeDefined()
|
||||
})
|
||||
|
||||
it('should set file defaults when type changes to singleFile', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.singleFile, fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('allowedFileUploadMethods', ['local_file', 'remote_url'])
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('allowedTypesAndExtensions', {
|
||||
allowedFileTypes: ['image', 'document'],
|
||||
allowedFileExtensions: ['.jpg', '.png', '.pdf'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should set maxLength when type changes to multiFiles', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.multiFiles, fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('maxLength', 5)
|
||||
})
|
||||
|
||||
it('should not set file defaults when type changes to text', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const typeConfig = result.current.find(c => c.variable === 'type')
|
||||
typeConfig?.listeners?.onChange?.({ value: PipelineInputVarType.textInput, fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should auto-fill label from variable name on blur', () => {
|
||||
mockGetFieldValue.mockReturnValue('')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const varConfig = result.current.find(c => c.variable === 'variable')
|
||||
varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'myVariable')
|
||||
})
|
||||
|
||||
it('should not auto-fill label if label already exists', () => {
|
||||
mockGetFieldValue.mockReturnValue('Existing Label')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const varConfig = result.current.find(c => c.variable === 'variable')
|
||||
varConfig?.listeners?.onBlur?.({ value: 'myVariable', fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset label to variable name when display name is cleared', () => {
|
||||
mockGetFieldValue.mockReturnValue('existingVar')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigurations({
|
||||
getFieldValue: mockGetFieldValue,
|
||||
setFieldValue: mockSetFieldValue,
|
||||
supportFile: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const labelConfig = result.current.find(c => c.variable === 'label')
|
||||
labelConfig?.listeners?.onBlur?.({ value: '', fieldApi: {} as never })
|
||||
|
||||
expect(mockSetFieldValue).toHaveBeenCalledWith('label', 'existingVar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useHiddenConfigurations', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return array of hidden configurations', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
expect(Array.isArray(result.current)).toBe(true)
|
||||
expect(result.current.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should include default value config for textInput', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const defaultConfigs = result.current.filter(c => c.variable === 'default')
|
||||
expect(defaultConfigs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should include tooltips configuration for all types', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const tooltipsConfig = result.current.find(c => c.variable === 'tooltips')
|
||||
expect(tooltipsConfig).toBeDefined()
|
||||
expect(tooltipsConfig?.showConditions).toEqual([])
|
||||
})
|
||||
|
||||
it('should build select options from provided options', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: ['opt1', 'opt2'] }),
|
||||
)
|
||||
|
||||
const selectDefault = result.current.find(
|
||||
c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select),
|
||||
)
|
||||
expect(selectDefault?.options).toBeDefined()
|
||||
expect(selectDefault?.options?.[0]?.value).toBe('')
|
||||
expect(selectDefault?.options?.[1]?.value).toBe('opt1')
|
||||
expect(selectDefault?.options?.[2]?.value).toBe('opt2')
|
||||
})
|
||||
|
||||
it('should return empty options when options prop is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const selectDefault = result.current.find(
|
||||
c => c.variable === 'default' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.select),
|
||||
)
|
||||
expect(selectDefault?.options).toEqual([])
|
||||
})
|
||||
|
||||
it('should include upload method configs for file types', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const uploadMethods = result.current.filter(c => c.variable === 'allowedFileUploadMethods')
|
||||
expect(uploadMethods.length).toBe(2) // singleFile + multiFiles
|
||||
})
|
||||
|
||||
it('should include maxLength slider for multiFiles', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useHiddenConfigurations({ options: undefined }),
|
||||
)
|
||||
|
||||
const maxLength = result.current.find(
|
||||
c => c.variable === 'maxLength' && c.showConditions?.some(sc => sc.value === PipelineInputVarType.multiFiles),
|
||||
)
|
||||
expect(maxLength).toBeDefined()
|
||||
expect(maxLength?.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { createInputFieldSchema, TEXT_MAX_LENGTH } from '../schema'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
MAX_VAR_KEY_LENGTH: 30,
|
||||
}))
|
||||
|
||||
const t: TFunction = ((key: string) => key) as unknown as TFunction
|
||||
|
||||
const defaultOptions = { maxFileUploadLimit: 10 }
|
||||
|
||||
describe('TEXT_MAX_LENGTH', () => {
|
||||
it('should be 256', () => {
|
||||
expect(TEXT_MAX_LENGTH).toBe(256)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInputFieldSchema', () => {
|
||||
describe('common schema validation', () => {
|
||||
it('should reject empty variable name', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: '',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject variable starting with number', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: '123abc',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept valid variable name', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'valid_var',
|
||||
label: 'Test',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject empty label', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'my_var',
|
||||
label: '',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('text input type', () => {
|
||||
it('should validate maxLength within range', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
|
||||
const valid = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'text_var',
|
||||
label: 'Text',
|
||||
required: false,
|
||||
maxLength: 100,
|
||||
})
|
||||
expect(valid.success).toBe(true)
|
||||
|
||||
const tooLow = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'text_var',
|
||||
label: 'Text',
|
||||
required: false,
|
||||
maxLength: 0,
|
||||
})
|
||||
expect(tooLow.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow optional default and tooltips', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.textInput, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'text-input',
|
||||
variable: 'text_var',
|
||||
label: 'Text',
|
||||
required: false,
|
||||
maxLength: 48,
|
||||
default: 'default value',
|
||||
tooltips: 'Some help text',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('paragraph type', () => {
|
||||
it('should use same schema as text input', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.paragraph, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'paragraph',
|
||||
variable: 'para_var',
|
||||
label: 'Paragraph',
|
||||
required: false,
|
||||
maxLength: 100,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('number type', () => {
|
||||
it('should allow optional unit and placeholder', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.number, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'number',
|
||||
variable: 'num_var',
|
||||
label: 'Number',
|
||||
required: false,
|
||||
default: 42,
|
||||
unit: 'kg',
|
||||
placeholder: 'Enter weight',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select type', () => {
|
||||
it('should require non-empty options array', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions)
|
||||
|
||||
const empty = schema.safeParse({
|
||||
type: 'select',
|
||||
variable: 'sel_var',
|
||||
label: 'Select',
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
expect(empty.success).toBe(false)
|
||||
|
||||
const valid = schema.safeParse({
|
||||
type: 'select',
|
||||
variable: 'sel_var',
|
||||
label: 'Select',
|
||||
required: false,
|
||||
options: ['opt1', 'opt2'],
|
||||
})
|
||||
expect(valid.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject duplicate options', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.select, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'select',
|
||||
variable: 'sel_var',
|
||||
label: 'Select',
|
||||
required: false,
|
||||
options: ['opt1', 'opt1'],
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleFile type', () => {
|
||||
it('should require file upload methods and types', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.singleFile, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'file',
|
||||
variable: 'file_var',
|
||||
label: 'File',
|
||||
required: false,
|
||||
allowedFileUploadMethods: ['local_file'],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: ['document'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiFiles type', () => {
|
||||
it('should validate maxLength against maxFileUploadLimit', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.multiFiles, t, { maxFileUploadLimit: 5 })
|
||||
|
||||
const valid = schema.safeParse({
|
||||
type: 'file-list',
|
||||
variable: 'files_var',
|
||||
label: 'Files',
|
||||
required: false,
|
||||
allowedFileUploadMethods: ['local_file'],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: ['image'],
|
||||
},
|
||||
maxLength: 3,
|
||||
})
|
||||
expect(valid.success).toBe(true)
|
||||
|
||||
const tooMany = schema.safeParse({
|
||||
type: 'file-list',
|
||||
variable: 'files_var',
|
||||
label: 'Files',
|
||||
required: false,
|
||||
allowedFileUploadMethods: ['local_file'],
|
||||
allowedTypesAndExtensions: {
|
||||
allowedFileTypes: ['image'],
|
||||
},
|
||||
maxLength: 10,
|
||||
})
|
||||
expect(tooMany.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkbox / default type', () => {
|
||||
it('should use common schema for checkbox type', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'checkbox',
|
||||
variable: 'check_var',
|
||||
label: 'Agree',
|
||||
required: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow passthrough of extra fields', () => {
|
||||
const schema = createInputFieldSchema(PipelineInputVarType.checkbox, t, defaultOptions)
|
||||
const result = schema.safeParse({
|
||||
type: 'checkbox',
|
||||
variable: 'check_var',
|
||||
label: 'Agree',
|
||||
required: true,
|
||||
default: true,
|
||||
extraField: 'should pass through',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useFieldList } from '../hooks'
|
||||
|
||||
const mockToggleInputFieldEditPanel = vi.fn()
|
||||
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
|
||||
useInputFieldPanel: () => ({
|
||||
toggleInputFieldEditPanel: mockToggleInputFieldEditPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockHandleInputVarRename = vi.fn()
|
||||
const mockIsVarUsedInNodes = vi.fn()
|
||||
const mockRemoveUsedVarInNodes = vi.fn()
|
||||
vi.mock('../../../../../hooks/use-pipeline', () => ({
|
||||
usePipeline: () => ({
|
||||
handleInputVarRename: mockHandleInputVarRename,
|
||||
isVarUsedInNodes: mockIsVarUsedInNodes,
|
||||
removeUsedVarInNodes: mockRemoveUsedVarInNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (...args: unknown[]) => mockToastNotify(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
ChangeType: {
|
||||
changeVarName: 'changeVarName',
|
||||
remove: 'remove',
|
||||
},
|
||||
}))
|
||||
|
||||
function createInputVar(overrides?: Partial<InputVar>): InputVar {
|
||||
return {
|
||||
type: 'text-input',
|
||||
variable: 'test_var',
|
||||
label: 'Test Var',
|
||||
required: false,
|
||||
...overrides,
|
||||
} as InputVar
|
||||
}
|
||||
|
||||
function createDefaultProps(overrides?: Partial<Parameters<typeof useFieldList>[0]>) {
|
||||
return {
|
||||
initialInputFields: [] as InputVar[],
|
||||
onInputFieldsChange: vi.fn(),
|
||||
nodeId: 'node-1',
|
||||
allVariableNames: [] as string[],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useFieldList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should return inputFields from initialInputFields', () => {
|
||||
const fields = [createInputVar({ variable: 'var1' })]
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps({ initialInputFields: fields })))
|
||||
|
||||
expect(result.current.inputFields).toEqual(fields)
|
||||
})
|
||||
|
||||
it('should return empty inputFields when initialized with empty array', () => {
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps()))
|
||||
|
||||
expect(result.current.inputFields).toEqual([])
|
||||
})
|
||||
|
||||
it('should return all expected functions', () => {
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps()))
|
||||
|
||||
expect(typeof result.current.handleListSortChange).toBe('function')
|
||||
expect(typeof result.current.handleRemoveField).toBe('function')
|
||||
expect(typeof result.current.handleOpenInputFieldEditor).toBe('function')
|
||||
expect(typeof result.current.hideRemoveVarConfirm).toBe('function')
|
||||
expect(typeof result.current.onRemoveVarConfirm).toBe('function')
|
||||
})
|
||||
|
||||
it('should have isShowRemoveVarConfirm as false initially', () => {
|
||||
const { result } = renderHook(() => useFieldList(createDefaultProps()))
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleListSortChange', () => {
|
||||
it('should reorder input fields and notify parent', () => {
|
||||
const var1 = createInputVar({ variable: 'var1', label: 'V1' })
|
||||
const var2 = createInputVar({ variable: 'var2', label: 'V2' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1, var2],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleListSortChange([
|
||||
{ ...var2, id: '1', chosen: false, selected: false },
|
||||
{ ...var1, id: '0', chosen: false, selected: false },
|
||||
])
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([var2, var1])
|
||||
})
|
||||
|
||||
it('should strip sortable metadata (id, chosen, selected) from items', () => {
|
||||
const var1 = createInputVar({ variable: 'var1' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleListSortChange([
|
||||
{ ...var1, id: '0', chosen: true, selected: true },
|
||||
])
|
||||
})
|
||||
|
||||
const updatedFields = onInputFieldsChange.mock.calls[0][0]
|
||||
expect(updatedFields[0]).not.toHaveProperty('id')
|
||||
expect(updatedFields[0]).not.toHaveProperty('chosen')
|
||||
expect(updatedFields[0]).not.toHaveProperty('selected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRemoveField', () => {
|
||||
it('should remove field when variable is not used in nodes', () => {
|
||||
const var1 = createInputVar({ variable: 'var1' })
|
||||
const var2 = createInputVar({ variable: 'var2' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
mockIsVarUsedInNodes.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1, var2],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([var2])
|
||||
})
|
||||
|
||||
it('should show confirmation when variable is used in other nodes', () => {
|
||||
const var1 = createInputVar({ variable: 'used_var' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
expect(onInputFieldsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onRemoveVarConfirm', () => {
|
||||
it('should remove field and clean up variable references after confirmation', () => {
|
||||
const var1 = createInputVar({ variable: 'used_var' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
nodeId: 'node-1',
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.onRemoveVarConfirm()
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([])
|
||||
expect(mockRemoveUsedVarInNodes).toHaveBeenCalledWith(['rag', 'node-1', 'used_var'])
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleOpenInputFieldEditor', () => {
|
||||
it('should open editor with existing field data when id matches', () => {
|
||||
const var1 = createInputVar({ variable: 'var1', label: 'Label 1' })
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({ initialInputFields: [var1] })),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor('var1')
|
||||
})
|
||||
|
||||
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialData: var1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should open editor for new field when id does not match', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps()),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor('non-existent')
|
||||
})
|
||||
|
||||
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialData: undefined,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should open editor for new field when no id provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps()),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor()
|
||||
})
|
||||
|
||||
expect(mockToggleInputFieldEditPanel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialData: undefined,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('field submission (via editor)', () => {
|
||||
it('should add new field when editingFieldIndex is -1', () => {
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({ onInputFieldsChange })),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor()
|
||||
})
|
||||
|
||||
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
|
||||
const newField = createInputVar({ variable: 'new_var', label: 'New' })
|
||||
|
||||
act(() => {
|
||||
editorProps.onSubmit(newField)
|
||||
})
|
||||
|
||||
expect(onInputFieldsChange).toHaveBeenCalledWith([newField])
|
||||
})
|
||||
|
||||
it('should show error toast for duplicate variable names', () => {
|
||||
const var1 = createInputVar({ variable: 'existing_var' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
allVariableNames: ['existing_var'],
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor()
|
||||
})
|
||||
|
||||
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
|
||||
const duplicateField = createInputVar({ variable: 'existing_var' })
|
||||
|
||||
act(() => {
|
||||
editorProps.onSubmit(duplicateField)
|
||||
})
|
||||
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
expect(onInputFieldsChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trigger variable rename when ChangeType is changeVarName', () => {
|
||||
const var1 = createInputVar({ variable: 'old_name' })
|
||||
const onInputFieldsChange = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({
|
||||
initialInputFields: [var1],
|
||||
onInputFieldsChange,
|
||||
nodeId: 'node-1',
|
||||
allVariableNames: ['old_name'],
|
||||
})),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpenInputFieldEditor('old_name')
|
||||
})
|
||||
|
||||
const editorProps = mockToggleInputFieldEditPanel.mock.calls[0][0]
|
||||
const updatedField = createInputVar({ variable: 'new_name' })
|
||||
|
||||
act(() => {
|
||||
editorProps.onSubmit(updatedField, {
|
||||
type: 'changeVarName',
|
||||
payload: { beforeKey: 'old_name', afterKey: 'new_name' },
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockHandleInputVarRename).toHaveBeenCalledWith(
|
||||
'node-1',
|
||||
['rag', 'node-1', 'old_name'],
|
||||
['rag', 'node-1', 'new_name'],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideRemoveVarConfirm', () => {
|
||||
it('should hide the confirmation dialog', () => {
|
||||
const var1 = createInputVar({ variable: 'used_var' })
|
||||
mockIsVarUsedInNodes.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFieldList(createDefaultProps({ initialInputFields: [var1] })),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.hideRemoveVarConfirm()
|
||||
})
|
||||
expect(result.current.isShowRemoveVarConfirm).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,9 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Datasource from './datasource'
|
||||
import GlobalInputs from './global-inputs'
|
||||
import Datasource from '../datasource'
|
||||
import GlobalInputs from '../global-inputs'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock BlockIcon
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: ({ type, toolIcon, className }: { type: BlockEnum, toolIcon?: string, className?: string }) => (
|
||||
<div
|
||||
@@ -24,12 +16,10 @@ vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock useToolIcon
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useToolIcon: (nodeData: DataSourceNodeType) => nodeData.provider_name || 'default-icon',
|
||||
}))
|
||||
|
||||
// Mock Tooltip
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ popupContent, popupClassName }: { popupContent: string, popupClassName?: string }) => (
|
||||
<div data-testid="tooltip" data-content={popupContent} className={popupClassName} />
|
||||
@@ -132,7 +122,6 @@ describe('Datasource', () => {
|
||||
|
||||
render(<Datasource nodeData={nodeData} />)
|
||||
|
||||
// Should still render without the title text
|
||||
expect(screen.getByTestId('block-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -160,13 +149,13 @@ describe('GlobalInputs', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title with correct translation key', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
expect(screen.getByText('inputFieldPanel.globalInputs.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip component', () => {
|
||||
@@ -179,7 +168,7 @@ describe('GlobalInputs', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
const tooltip = screen.getByTestId('tooltip')
|
||||
expect(tooltip).toHaveAttribute('data-content', 'inputFieldPanel.globalInputs.tooltip')
|
||||
expect(tooltip).toHaveAttribute('data-content', 'datasetPipeline.inputFieldPanel.globalInputs.tooltip')
|
||||
})
|
||||
|
||||
it('should have correct tooltip className', () => {
|
||||
@@ -199,7 +188,7 @@ describe('GlobalInputs', () => {
|
||||
it('should have correct title styling', () => {
|
||||
render(<GlobalInputs />)
|
||||
|
||||
const titleElement = screen.getByText('inputFieldPanel.globalInputs.title')
|
||||
const titleElement = screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')
|
||||
expect(titleElement).toHaveClass('system-sm-semibold-uppercase', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,9 @@ import type { WorkflowRunningData } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import Header from './header'
|
||||
// Import components after mocks
|
||||
import TestRunPanel from './index'
|
||||
import Header from '../header'
|
||||
import TestRunPanel from '../index'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
// Mock workflow store
|
||||
const mockIsPreparingDataSource = vi.fn(() => true)
|
||||
const mockSetIsPreparingDataSource = vi.fn()
|
||||
const mockWorkflowRunningData = vi.fn<() => WorkflowRunningData | undefined>(() => undefined)
|
||||
@@ -34,7 +28,6 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow interactions
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowInteractions: () => ({
|
||||
@@ -46,22 +39,18 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useToolIcon: () => 'mock-tool-icon',
|
||||
}))
|
||||
|
||||
// Mock data source provider
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store/provider', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div data-testid="data-source-provider">{children}</div>,
|
||||
}))
|
||||
|
||||
// Mock Preparation component
|
||||
vi.mock('./preparation', () => ({
|
||||
vi.mock('../preparation', () => ({
|
||||
default: () => <div data-testid="preparation-component">Preparation</div>,
|
||||
}))
|
||||
|
||||
// Mock Result component (for TestRunPanel tests only)
|
||||
vi.mock('./result', () => ({
|
||||
vi.mock('../result', () => ({
|
||||
default: () => <div data-testid="result-component">Result</div>,
|
||||
}))
|
||||
|
||||
// Mock ResultPanel from workflow
|
||||
vi.mock('@/app/components/workflow/run/result-panel', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-testid="result-panel">
|
||||
@@ -72,7 +61,6 @@ vi.mock('@/app/components/workflow/run/result-panel', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock TracingPanel from workflow
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
default: (props: { list: unknown[] }) => (
|
||||
<div data-testid="tracing-panel">
|
||||
@@ -85,20 +73,14 @@ vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Loading component
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div data-testid="loading">Loading...</div>,
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
RAG_PIPELINE_PREVIEW_CHUNK_NUM: 5,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockWorkflowRunningData = (overrides: Partial<WorkflowRunningData> = {}): WorkflowRunningData => ({
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
@@ -141,10 +123,6 @@ const createMockQAOutputs = () => ({
|
||||
],
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// TestRunPanel Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('TestRunPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -152,7 +130,6 @@ describe('TestRunPanel', () => {
|
||||
mockWorkflowRunningData.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
// Basic rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render with correct container styles', () => {
|
||||
const { container } = render(<TestRunPanel />)
|
||||
@@ -168,7 +145,6 @@ describe('TestRunPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Conditional rendering based on isPreparingDataSource
|
||||
describe('Conditional Content Rendering', () => {
|
||||
it('should render Preparation inside DataSourceProvider when isPreparingDataSource is true', () => {
|
||||
mockIsPreparingDataSource.mockReturnValue(true)
|
||||
@@ -192,17 +168,12 @@ describe('TestRunPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Header Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsPreparingDataSource.mockReturnValue(true)
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render title with correct translation key', () => {
|
||||
render(<Header />)
|
||||
@@ -225,7 +196,6 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Close button interactions
|
||||
describe('Close Button Interaction', () => {
|
||||
it('should call setIsPreparingDataSource(false) and handleCancelDebugAndPreviewPanel when clicked and isPreparingDataSource is true', () => {
|
||||
mockIsPreparingDataSource.mockReturnValue(true)
|
||||
@@ -253,19 +223,13 @@ describe('Header', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Result Component Tests (Real Implementation)
|
||||
// ============================================================================
|
||||
|
||||
// Unmock Result for these tests
|
||||
vi.doUnmock('./result')
|
||||
vi.doUnmock('../result')
|
||||
|
||||
describe('Result', () => {
|
||||
// Dynamically import Result to get real implementation
|
||||
let Result: typeof import('./result').default
|
||||
let Result: typeof import('../result').default
|
||||
|
||||
beforeAll(async () => {
|
||||
const resultModule = await import('./result')
|
||||
const resultModule = await import('../result')
|
||||
Result = resultModule.default
|
||||
})
|
||||
|
||||
@@ -274,7 +238,6 @@ describe('Result', () => {
|
||||
mockWorkflowRunningData.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render with RESULT tab active by default', async () => {
|
||||
render(<Result />)
|
||||
@@ -294,7 +257,6 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Tab switching tests
|
||||
describe('Tab Switching', () => {
|
||||
it('should switch to DETAIL tab when clicked', async () => {
|
||||
mockWorkflowRunningData.mockReturnValue(createMockWorkflowRunningData())
|
||||
@@ -321,7 +283,6 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Loading states
|
||||
describe('Loading States', () => {
|
||||
it('should show loading in DETAIL tab when no result data', async () => {
|
||||
mockWorkflowRunningData.mockReturnValue({
|
||||
@@ -352,18 +313,13 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ResultPreview Component Tests
|
||||
// ============================================================================
|
||||
|
||||
// We need to import ResultPreview directly
|
||||
vi.doUnmock('./result/result-preview')
|
||||
vi.doUnmock('../result/result-preview')
|
||||
|
||||
describe('ResultPreview', () => {
|
||||
let ResultPreview: typeof import('./result/result-preview').default
|
||||
let ResultPreview: typeof import('../result/result-preview').default
|
||||
|
||||
beforeAll(async () => {
|
||||
const previewModule = await import('./result/result-preview')
|
||||
const previewModule = await import('../result/result-preview')
|
||||
ResultPreview = previewModule.default
|
||||
})
|
||||
|
||||
@@ -373,7 +329,6 @@ describe('ResultPreview', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Loading state
|
||||
describe('Loading State', () => {
|
||||
it('should show loading spinner when isRunning is true and no outputs', () => {
|
||||
render(
|
||||
@@ -402,7 +357,6 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Error state
|
||||
describe('Error State', () => {
|
||||
it('should show error message when not running and has error', () => {
|
||||
render(
|
||||
@@ -448,7 +402,6 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Success state with outputs
|
||||
describe('Success State with Outputs', () => {
|
||||
it('should render chunk content when outputs are available', () => {
|
||||
render(
|
||||
@@ -460,7 +413,6 @@ describe('ResultPreview', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check that chunk content is rendered (the real ChunkCardList renders the content)
|
||||
expect(screen.getByText('test chunk content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -492,7 +444,6 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty outputs gracefully', () => {
|
||||
render(
|
||||
@@ -504,7 +455,6 @@ describe('ResultPreview', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should not crash and should not show chunk card list
|
||||
expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -523,17 +473,13 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tabs Component Tests
|
||||
// ============================================================================
|
||||
|
||||
vi.doUnmock('./result/tabs')
|
||||
vi.doUnmock('../result/tabs')
|
||||
|
||||
describe('Tabs', () => {
|
||||
let Tabs: typeof import('./result/tabs').default
|
||||
let Tabs: typeof import('../result/tabs').default
|
||||
|
||||
beforeAll(async () => {
|
||||
const tabsModule = await import('./result/tabs')
|
||||
const tabsModule = await import('../result/tabs')
|
||||
Tabs = tabsModule.default
|
||||
})
|
||||
|
||||
@@ -543,7 +489,6 @@ describe('Tabs', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render all three tabs', () => {
|
||||
render(
|
||||
@@ -560,7 +505,6 @@ describe('Tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Active tab styling
|
||||
describe('Active Tab Styling', () => {
|
||||
it('should highlight RESULT tab when currentTab is RESULT', () => {
|
||||
render(
|
||||
@@ -589,7 +533,6 @@ describe('Tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Tab click handling
|
||||
describe('Tab Click Handling', () => {
|
||||
it('should call switchTab with RESULT when RESULT tab is clicked', () => {
|
||||
render(
|
||||
@@ -634,7 +577,6 @@ describe('Tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled state when no data
|
||||
describe('Disabled State', () => {
|
||||
it('should disable tabs when workflowRunningData is undefined', () => {
|
||||
render(
|
||||
@@ -651,17 +593,13 @@ describe('Tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tab Component Tests
|
||||
// ============================================================================
|
||||
|
||||
vi.doUnmock('./result/tabs/tab')
|
||||
vi.doUnmock('../result/tabs/tab')
|
||||
|
||||
describe('Tab', () => {
|
||||
let Tab: typeof import('./result/tabs/tab').default
|
||||
let Tab: typeof import('../result/tabs/tab').default
|
||||
|
||||
beforeAll(async () => {
|
||||
const tabModule = await import('./result/tabs/tab')
|
||||
const tabModule = await import('../result/tabs/tab')
|
||||
Tab = tabModule.default
|
||||
})
|
||||
|
||||
@@ -671,7 +609,6 @@ describe('Tab', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render tab with label', () => {
|
||||
render(
|
||||
@@ -688,7 +625,6 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Active state styling
|
||||
describe('Active State', () => {
|
||||
it('should have active styles when isActive is true', () => {
|
||||
render(
|
||||
@@ -721,7 +657,6 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Click handling
|
||||
describe('Click Handling', () => {
|
||||
it('should call onClick with value when clicked', () => {
|
||||
render(
|
||||
@@ -753,12 +688,10 @@ describe('Tab', () => {
|
||||
const tab = screen.getByRole('button')
|
||||
fireEvent.click(tab)
|
||||
|
||||
// The click handler is still called, but button is disabled
|
||||
expect(tab).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled state
|
||||
describe('Disabled State', () => {
|
||||
it('should be disabled when workflowRunningData is undefined', () => {
|
||||
render(
|
||||
@@ -793,19 +726,14 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// formatPreviewChunks Utility Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('formatPreviewChunks', () => {
|
||||
let formatPreviewChunks: typeof import('./result/result-preview/utils').formatPreviewChunks
|
||||
let formatPreviewChunks: typeof import('../result/result-preview/utils').formatPreviewChunks
|
||||
|
||||
beforeAll(async () => {
|
||||
const utilsModule = await import('./result/result-preview/utils')
|
||||
const utilsModule = await import('../result/result-preview/utils')
|
||||
formatPreviewChunks = utilsModule.formatPreviewChunks
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should return undefined for null outputs', () => {
|
||||
expect(formatPreviewChunks(null)).toBeUndefined()
|
||||
@@ -824,7 +752,6 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// General (text) chunks
|
||||
describe('General Chunks (ChunkingMode.text)', () => {
|
||||
it('should format general chunks correctly', () => {
|
||||
const outputs = createMockGeneralOutputs(['content1', 'content2', 'content3'])
|
||||
@@ -842,7 +769,6 @@ describe('formatPreviewChunks', () => {
|
||||
const outputs = createMockGeneralOutputs(manyChunks)
|
||||
const result = formatPreviewChunks(outputs) as GeneralChunks
|
||||
|
||||
// RAG_PIPELINE_PREVIEW_CHUNK_NUM is mocked to 5
|
||||
expect(result).toHaveLength(5)
|
||||
expect(result).toEqual([
|
||||
{ content: 'chunk0', summary: undefined },
|
||||
@@ -861,7 +787,6 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Parent-child chunks
|
||||
describe('Parent-Child Chunks (ChunkingMode.parentChild)', () => {
|
||||
it('should format paragraph mode parent-child chunks correctly', () => {
|
||||
const outputs = createMockParentChildOutputs('paragraph')
|
||||
@@ -902,7 +827,6 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// QA chunks
|
||||
describe('QA Chunks (ChunkingMode.qa)', () => {
|
||||
it('should format QA chunks correctly', () => {
|
||||
const outputs = createMockQAOutputs()
|
||||
@@ -931,14 +855,10 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Types Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Types', () => {
|
||||
describe('TestRunStep Enum', () => {
|
||||
it('should have correct enum values', async () => {
|
||||
const { TestRunStep } = await import('./types')
|
||||
const { TestRunStep } = await import('../types')
|
||||
|
||||
expect(TestRunStep.dataSource).toBe('dataSource')
|
||||
expect(TestRunStep.documentProcessing).toBe('documentProcessing')
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useDatasourceOptions, useOnlineDocument, useOnlineDrive, useTestRunSteps, useWebsiteCrawl } from '../hooks'
|
||||
|
||||
const mockNodes: Array<{ id: string, data: Partial<DataSourceNodeType> & { type: string } }> = []
|
||||
vi.mock('reactflow', () => ({
|
||||
useNodes: () => mockNodes,
|
||||
}))
|
||||
|
||||
const mockDataSourceStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/datasets/documents/create-from-pipeline/data-source/store', () => ({
|
||||
useDataSourceStore: () => ({
|
||||
getState: mockDataSourceStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
BlockEnum: {
|
||||
DataSource: 'data-source',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../types', () => ({
|
||||
TestRunStep: {
|
||||
dataSource: 'dataSource',
|
||||
documentProcessing: 'documentProcessing',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
CrawlStep: {
|
||||
init: 'init',
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useTestRunSteps', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with step 1', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should return 2 steps (dataSource and documentProcessing)', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.steps).toHaveLength(2)
|
||||
expect(result.current.steps[0].value).toBe('dataSource')
|
||||
expect(result.current.steps[1].value).toBe('documentProcessing')
|
||||
})
|
||||
|
||||
it('should increment step on handleNextStep', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
})
|
||||
|
||||
it('should decrement step on handleBackStep', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
act(() => {
|
||||
result.current.handleNextStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(2)
|
||||
|
||||
act(() => {
|
||||
result.current.handleBackStep()
|
||||
})
|
||||
expect(result.current.currentStep).toBe(1)
|
||||
})
|
||||
|
||||
it('should have translated step labels', () => {
|
||||
const { result } = renderHook(() => useTestRunSteps())
|
||||
|
||||
expect(result.current.steps[0].label).toBeDefined()
|
||||
expect(typeof result.current.steps[0].label).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDatasourceOptions', () => {
|
||||
beforeEach(() => {
|
||||
mockNodes.length = 0
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty options when no DataSource nodes', () => {
|
||||
mockNodes.push({ id: 'n1', data: { type: BlockEnum.LLM, title: 'LLM' } })
|
||||
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('should return options from DataSource nodes', () => {
|
||||
mockNodes.push(
|
||||
{ id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source A' } },
|
||||
{ id: 'ds-2', data: { type: BlockEnum.DataSource, title: 'Source B' } },
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0]).toEqual({
|
||||
label: 'Source A',
|
||||
value: 'ds-1',
|
||||
data: expect.objectContaining({ type: 'data-source' }),
|
||||
})
|
||||
expect(result.current[1]).toEqual({
|
||||
label: 'Source B',
|
||||
value: 'ds-2',
|
||||
data: expect.objectContaining({ type: 'data-source' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter out non-DataSource nodes', () => {
|
||||
mockNodes.push(
|
||||
{ id: 'ds-1', data: { type: BlockEnum.DataSource, title: 'Source' } },
|
||||
{ id: 'llm-1', data: { type: BlockEnum.LLM, title: 'LLM' } },
|
||||
{ id: 'end-1', data: { type: BlockEnum.End, title: 'End' } },
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useDatasourceOptions())
|
||||
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].value).toBe('ds-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnlineDocument', () => {
|
||||
it('should clear all online document data', () => {
|
||||
const mockSetDocumentsData = vi.fn()
|
||||
const mockSetSearchValue = vi.fn()
|
||||
const mockSetSelectedPagesId = vi.fn()
|
||||
const mockSetOnlineDocuments = vi.fn()
|
||||
const mockSetCurrentDocument = vi.fn()
|
||||
|
||||
mockDataSourceStoreGetState.mockReturnValue({
|
||||
setDocumentsData: mockSetDocumentsData,
|
||||
setSearchValue: mockSetSearchValue,
|
||||
setSelectedPagesId: mockSetSelectedPagesId,
|
||||
setOnlineDocuments: mockSetOnlineDocuments,
|
||||
setCurrentDocument: mockSetCurrentDocument,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnlineDocument())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDocumentData()
|
||||
})
|
||||
|
||||
expect(mockSetDocumentsData).toHaveBeenCalledWith([])
|
||||
expect(mockSetSearchValue).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedPagesId).toHaveBeenCalledWith(new Set())
|
||||
expect(mockSetOnlineDocuments).toHaveBeenCalledWith([])
|
||||
expect(mockSetCurrentDocument).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWebsiteCrawl', () => {
|
||||
it('should clear all website crawl data', () => {
|
||||
const mockSetStep = vi.fn()
|
||||
const mockSetCrawlResult = vi.fn()
|
||||
const mockSetWebsitePages = vi.fn()
|
||||
const mockSetPreviewIndex = vi.fn()
|
||||
const mockSetCurrentWebsite = vi.fn()
|
||||
|
||||
mockDataSourceStoreGetState.mockReturnValue({
|
||||
setStep: mockSetStep,
|
||||
setCrawlResult: mockSetCrawlResult,
|
||||
setWebsitePages: mockSetWebsitePages,
|
||||
setPreviewIndex: mockSetPreviewIndex,
|
||||
setCurrentWebsite: mockSetCurrentWebsite,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWebsiteCrawl())
|
||||
|
||||
act(() => {
|
||||
result.current.clearWebsiteCrawlData()
|
||||
})
|
||||
|
||||
expect(mockSetStep).toHaveBeenCalledWith('init')
|
||||
expect(mockSetCrawlResult).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetCurrentWebsite).toHaveBeenCalledWith(undefined)
|
||||
expect(mockSetWebsitePages).toHaveBeenCalledWith([])
|
||||
expect(mockSetPreviewIndex).toHaveBeenCalledWith(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOnlineDrive', () => {
|
||||
it('should clear all online drive data', () => {
|
||||
const mockSetOnlineDriveFileList = vi.fn()
|
||||
const mockSetBucket = vi.fn()
|
||||
const mockSetPrefix = vi.fn()
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetSelectedFileIds = vi.fn()
|
||||
|
||||
mockDataSourceStoreGetState.mockReturnValue({
|
||||
setOnlineDriveFileList: mockSetOnlineDriveFileList,
|
||||
setBucket: mockSetBucket,
|
||||
setPrefix: mockSetPrefix,
|
||||
setKeywords: mockSetKeywords,
|
||||
setSelectedFileIds: mockSetSelectedFileIds,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnlineDrive())
|
||||
|
||||
act(() => {
|
||||
result.current.clearOnlineDriveData()
|
||||
})
|
||||
|
||||
expect(mockSetOnlineDriveFileList).toHaveBeenCalledWith([])
|
||||
expect(mockSetBucket).toHaveBeenCalledWith('')
|
||||
expect(mockSetPrefix).toHaveBeenCalledWith([])
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
expect(mockSetSelectedFileIds).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,33 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Actions from './index'
|
||||
|
||||
// ============================================================================
|
||||
// Actions Component Tests
|
||||
// ============================================================================
|
||||
import Actions from '../index'
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render button with translated text', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Translation mock returns key with namespace prefix
|
||||
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct container structure', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { container } = render(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper.className).toContain('flex')
|
||||
expect(wrapper.className).toContain('justify-end')
|
||||
@@ -52,197 +36,143 @@ describe('Actions', () => {
|
||||
})
|
||||
|
||||
it('should render span with px-0.5 class around text', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { container } = render(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
const span = container.querySelector('span')
|
||||
expect(span).toBeInTheDocument()
|
||||
expect(span?.className).toContain('px-0.5')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Props Variations Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should pass disabled=true to button when disabled prop is true', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should pass disabled=false to button when disabled prop is false', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions disabled={false} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not disable button when disabled prop is undefined', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle disabled switching from true to false', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions disabled={true} handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Assert - Initially disabled
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
|
||||
// Act - Rerender with disabled=false
|
||||
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Now enabled
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle disabled switching from false to true', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions disabled={false} handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Assert - Initially enabled
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
// Act - Rerender with disabled=true
|
||||
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Now disabled
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle undefined disabled becoming true', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Assert - Initially not disabled (undefined)
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
// Act - Rerender with disabled=true
|
||||
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Now disabled
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Interaction Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleNextStep when button is clicked', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleNextStep exactly once per click', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalled()
|
||||
expect(handleNextStep.mock.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should call handleNextStep multiple times on multiple clicks', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should not call handleNextStep when button is disabled and clicked', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert - Disabled button should not trigger onClick
|
||||
expect(handleNextStep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid clicks when not disabled', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
// Simulate rapid clicks
|
||||
for (let i = 0; i < 10; i++)
|
||||
fireEvent.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(10)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Callback Stability Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Callback Stability', () => {
|
||||
it('should use the new handleNextStep when prop changes', () => {
|
||||
// Arrange
|
||||
const handleNextStep1 = vi.fn()
|
||||
const handleNextStep2 = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions handleNextStep={handleNextStep1} />,
|
||||
)
|
||||
@@ -251,16 +181,13 @@ describe('Actions', () => {
|
||||
rerender(<Actions handleNextStep={handleNextStep2} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep1).toHaveBeenCalledTimes(1)
|
||||
expect(handleNextStep2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should maintain functionality after rerender with same props', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions handleNextStep={handleNextStep} />,
|
||||
)
|
||||
@@ -269,17 +196,14 @@ describe('Actions', () => {
|
||||
rerender(<Actions handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should work correctly when handleNextStep changes multiple times', () => {
|
||||
// Arrange
|
||||
const handleNextStep1 = vi.fn()
|
||||
const handleNextStep2 = vi.fn()
|
||||
const handleNextStep3 = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions handleNextStep={handleNextStep1} />,
|
||||
)
|
||||
@@ -291,77 +215,58 @@ describe('Actions', () => {
|
||||
rerender(<Actions handleNextStep={handleNextStep3} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep1).toHaveBeenCalledTimes(1)
|
||||
expect(handleNextStep2).toHaveBeenCalledTimes(1)
|
||||
expect(handleNextStep3).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act - Verify component is memoized by checking display name pattern
|
||||
const { rerender } = render(
|
||||
<Actions handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Rerender with same props should work without issues
|
||||
rerender(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Component should render correctly after rerender
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not break when props remain the same across rerenders', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions disabled={false} handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Multiple rerenders with same props
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
|
||||
}
|
||||
|
||||
// Assert - Should still function correctly
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update correctly when only disabled prop changes', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions disabled={false} handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Assert - Initially not disabled
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
// Act - Change only disabled prop
|
||||
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Should reflect the new disabled state
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should update correctly when only handleNextStep prop changes', () => {
|
||||
// Arrange
|
||||
const handleNextStep1 = vi.fn()
|
||||
const handleNextStep2 = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions disabled={false} handleNextStep={handleNextStep1} />,
|
||||
)
|
||||
@@ -369,169 +274,124 @@ describe('Actions', () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleNextStep1).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Act - Change only handleNextStep prop
|
||||
rerender(<Actions disabled={false} handleNextStep={handleNextStep2} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert - New callback should be used
|
||||
expect(handleNextStep1).toHaveBeenCalledTimes(1)
|
||||
expect(handleNextStep2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should call handleNextStep even if it has side effects', () => {
|
||||
// Arrange
|
||||
let sideEffectValue = 0
|
||||
const handleNextStep = vi.fn(() => {
|
||||
sideEffectValue = 42
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1)
|
||||
expect(sideEffectValue).toBe(42)
|
||||
})
|
||||
|
||||
it('should handle handleNextStep that returns a value', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn(() => 'return value')
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1)
|
||||
expect(handleNextStep).toHaveReturnedWith('return value')
|
||||
})
|
||||
|
||||
it('should handle handleNextStep that is async', async () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render correctly with both disabled=true and handleNextStep', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle component unmount gracefully', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { unmount } = render(<Actions handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Unmount should not throw
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle disabled as boolean-like falsy value', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act - Test with explicit false
|
||||
render(<Actions disabled={false} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessibility Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Accessibility', () => {
|
||||
it('should have button element that can receive focus', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions handleNextStep={handleNextStep} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
// Assert - Button should be focusable (not disabled by default)
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should indicate disabled state correctly', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toHaveAttribute('disabled')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integration Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Integration', () => {
|
||||
it('should work in a typical workflow: enable -> click -> disable', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act - Start enabled
|
||||
const { rerender } = render(
|
||||
<Actions disabled={false} handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Assert - Can click when enabled
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Act - Disable after click (simulating loading state)
|
||||
rerender(<Actions disabled={true} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Cannot click when disabled
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(1) // Still 1, not 2
|
||||
|
||||
// Act - Re-enable
|
||||
rerender(<Actions disabled={false} handleNextStep={handleNextStep} />)
|
||||
|
||||
// Assert - Can click again
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleNextStep).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should maintain consistent rendering across multiple state changes', () => {
|
||||
// Arrange
|
||||
const handleNextStep = vi.fn()
|
||||
|
||||
// Act
|
||||
const { rerender } = render(
|
||||
<Actions disabled={false} handleNextStep={handleNextStep} />,
|
||||
)
|
||||
|
||||
// Toggle disabled state multiple times
|
||||
const states = [true, false, true, false, true]
|
||||
states.forEach((disabled) => {
|
||||
rerender(<Actions disabled={disabled} handleNextStep={handleNextStep} />)
|
||||
@@ -541,7 +401,6 @@ describe('Actions', () => {
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Assert - Button should still render correctly
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument()
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,41 +5,19 @@ import * as React from 'react'
|
||||
import { BlockEnum, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { RAG_PIPELINE_PREVIEW_CHUNK_NUM } from '@/config'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import Result from './index'
|
||||
import ResultPreview from './result-preview'
|
||||
import { formatPreviewChunks } from './result-preview/utils'
|
||||
import Tabs from './tabs'
|
||||
import Tab from './tabs/tab'
|
||||
|
||||
// ============================================================================
|
||||
// Pre-declare variables used in mocks (hoisting)
|
||||
// ============================================================================
|
||||
import Result from '../index'
|
||||
import ResultPreview from '../result-preview'
|
||||
import { formatPreviewChunks } from '../result-preview/utils'
|
||||
import Tabs from '../tabs'
|
||||
import Tab from '../tabs/tab'
|
||||
|
||||
let mockWorkflowRunningData: WorkflowRunningData | undefined
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Dependencies
|
||||
// ============================================================================
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, count?: number }) => {
|
||||
const ns = options?.ns ? `${options.ns}.` : ''
|
||||
if (options?.count !== undefined)
|
||||
return `${ns}${key} (count: ${options.count})`
|
||||
return `${ns}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow store
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: <T,>(selector: (state: { workflowRunningData: WorkflowRunningData | undefined }) => T) =>
|
||||
selector({ workflowRunningData: mockWorkflowRunningData }),
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('@/app/components/workflow/run/result-panel', () => ({
|
||||
default: ({
|
||||
inputs,
|
||||
@@ -102,10 +80,6 @@ vi.mock('@/app/components/rag-pipeline/components/chunk-card-list', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockWorkflowRunningData = (
|
||||
overrides?: Partial<WorkflowRunningData>,
|
||||
): WorkflowRunningData => ({
|
||||
@@ -191,26 +165,15 @@ const createQAChunkOutputs = (qaCount: number = 5) => ({
|
||||
})),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
const resetAllMocks = () => {
|
||||
mockWorkflowRunningData = undefined
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tab Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render tab with label', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
@@ -283,9 +246,6 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Interaction Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick with value when clicked', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
@@ -325,9 +285,6 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should maintain stable handleClick callback reference', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
@@ -353,33 +310,26 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Props Variation Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should render with all combinations of isActive and workflowRunningData', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const workflowData = createMockWorkflowRunningData()
|
||||
|
||||
// Active with data
|
||||
const { rerender } = render(
|
||||
<Tab isActive={true} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />,
|
||||
)
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
// Inactive with data
|
||||
rerender(
|
||||
<Tab isActive={false} label="Tab" value="tab" workflowRunningData={workflowData} onClick={mockOnClick} />,
|
||||
)
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
// Active without data
|
||||
rerender(
|
||||
<Tab isActive={true} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />,
|
||||
)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
|
||||
// Inactive without data
|
||||
rerender(
|
||||
<Tab isActive={false} label="Tab" value="tab" workflowRunningData={undefined} onClick={mockOnClick} />,
|
||||
)
|
||||
@@ -388,18 +338,11 @@ describe('Tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tabs Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Tabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render all three tabs', () => {
|
||||
render(
|
||||
@@ -440,18 +383,12 @@ describe('Tabs', () => {
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// RESULT tab
|
||||
expect(buttons[0]).toHaveClass('border-transparent')
|
||||
// DETAIL tab (active)
|
||||
expect(buttons[1]).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
// TRACING tab
|
||||
expect(buttons[2]).toHaveClass('border-transparent')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Interaction Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call switchTab when RESULT tab is clicked', () => {
|
||||
const mockSwitchTab = vi.fn()
|
||||
@@ -522,9 +459,6 @@ describe('Tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Props Variation Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should handle all currentTab values', () => {
|
||||
const mockSwitchTab = vi.fn()
|
||||
@@ -554,14 +488,7 @@ describe('Tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// formatPreviewChunks Utility Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('formatPreviewChunks', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should return undefined when outputs is null', () => {
|
||||
expect(formatPreviewChunks(null)).toBeUndefined()
|
||||
@@ -581,9 +508,6 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// General Chunks Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('General Chunks (text mode)', () => {
|
||||
it('should format general chunks correctly', () => {
|
||||
const outputs = createGeneralChunkOutputs(3)
|
||||
@@ -613,9 +537,6 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Parent-Child Chunks Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Parent-Child Chunks (hierarchical mode)', () => {
|
||||
it('should format paragraph mode chunks correctly', () => {
|
||||
const outputs = createParentChildChunkOutputs('paragraph', 3)
|
||||
@@ -678,9 +599,6 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// QA Chunks Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('QA Chunks (qa mode)', () => {
|
||||
it('should format QA chunks correctly', () => {
|
||||
const outputs = createQAChunkOutputs(3)
|
||||
@@ -710,18 +628,11 @@ describe('formatPreviewChunks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// ResultPreview Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ResultPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render loading state when isRunning is true and no outputs', () => {
|
||||
render(
|
||||
@@ -778,7 +689,7 @@ describe('ResultPreview', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText(`pipeline.result.resultPreview.footerTip (count: ${RAG_PIPELINE_PREVIEW_CHUNK_NUM})`),
|
||||
screen.getByText(`pipeline.result.resultPreview.footerTip:{"count":${RAG_PIPELINE_PREVIEW_CHUNK_NUM}}`),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -799,9 +710,6 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User Interaction Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSwitchToDetail when view details button is clicked', () => {
|
||||
const mockOnSwitchToDetail = vi.fn()
|
||||
@@ -821,9 +729,6 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Props Variation Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should render with general chunks output', () => {
|
||||
const outputs = createGeneralChunkOutputs(3)
|
||||
@@ -874,9 +779,6 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle outputs with no previewChunks result', () => {
|
||||
const outputs = {
|
||||
@@ -893,7 +795,6 @@ describe('ResultPreview', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should not render chunk card list when formatPreviewChunks returns undefined
|
||||
expect(screen.queryByTestId('chunk-card-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -907,14 +808,10 @@ describe('ResultPreview', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Error section should not render when isRunning is true
|
||||
expect(screen.queryByText('pipeline.result.resultPreview.error')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should memoize previewChunks calculation', () => {
|
||||
const outputs = createGeneralChunkOutputs(3)
|
||||
@@ -927,7 +824,6 @@ describe('ResultPreview', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Re-render with same outputs - should use memoized value
|
||||
rerender(
|
||||
<ResultPreview
|
||||
isRunning={false}
|
||||
@@ -942,19 +838,12 @@ describe('ResultPreview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Result Component Tests (Main Component)
|
||||
// ============================================================================
|
||||
|
||||
describe('Result', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render tabs and result preview by default', () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData({
|
||||
@@ -967,7 +856,6 @@ describe('Result', () => {
|
||||
|
||||
render(<Result />)
|
||||
|
||||
// Tabs should be rendered
|
||||
expect(screen.getByText('runLog.result')).toBeInTheDocument()
|
||||
expect(screen.getByText('runLog.detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('runLog.tracing')).toBeInTheDocument()
|
||||
@@ -1003,9 +891,6 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tab Switching Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Tab Switching', () => {
|
||||
it('should switch to DETAIL tab when clicked', async () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData()
|
||||
@@ -1042,13 +927,11 @@ describe('Result', () => {
|
||||
|
||||
render(<Result />)
|
||||
|
||||
// Switch to DETAIL
|
||||
fireEvent.click(screen.getByText('runLog.detail'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Switch back to RESULT
|
||||
fireEvent.click(screen.getByText('runLog.result'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chunk-card-list')).toBeInTheDocument()
|
||||
@@ -1056,9 +939,6 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DETAIL Tab Content Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('DETAIL Tab Content', () => {
|
||||
it('should render ResultPanel with correct props', async () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData({
|
||||
@@ -1109,9 +989,6 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TRACING Tab Content Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('TRACING Tab Content', () => {
|
||||
it('should render TracingPanel with tracing data', async () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData()
|
||||
@@ -1137,15 +1014,11 @@ describe('Result', () => {
|
||||
fireEvent.click(screen.getByText('runLog.tracing'))
|
||||
|
||||
await waitFor(() => {
|
||||
// Both TracingPanel and Loading should be rendered
|
||||
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Switch to Detail from Result Preview Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Switch to Detail from Result Preview', () => {
|
||||
it('should switch to DETAIL tab when onSwitchToDetail is triggered from ResultPreview', async () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData({
|
||||
@@ -1159,7 +1032,6 @@ describe('Result', () => {
|
||||
|
||||
render(<Result />)
|
||||
|
||||
// Click the view details button in error state
|
||||
fireEvent.click(screen.getByText('pipeline.result.resultPreview.viewDetails'))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1168,16 +1040,12 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined workflowRunningData', () => {
|
||||
mockWorkflowRunningData = undefined
|
||||
|
||||
render(<Result />)
|
||||
|
||||
// All tabs should be disabled
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toBeDisabled()
|
||||
@@ -1193,7 +1061,6 @@ describe('Result', () => {
|
||||
|
||||
render(<Result />)
|
||||
|
||||
// Should show loading in RESULT tab (isRunning condition)
|
||||
expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1223,36 +1090,28 @@ describe('Result', () => {
|
||||
|
||||
render(<Result />)
|
||||
|
||||
// Should show error when stopped
|
||||
expect(screen.getByText('pipeline.result.resultPreview.error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State Management Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('State Management', () => {
|
||||
it('should maintain tab state across re-renders', async () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData()
|
||||
|
||||
const { rerender } = render(<Result />)
|
||||
|
||||
// Switch to DETAIL tab
|
||||
fireEvent.click(screen.getByText('runLog.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Re-render component
|
||||
rerender(<Result />)
|
||||
|
||||
// Should still be on DETAIL tab
|
||||
expect(screen.getByTestId('result-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different states based on workflowRunningData', () => {
|
||||
// Test 1: Running state with no outputs
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData({
|
||||
result: {
|
||||
...createMockWorkflowRunningData().result,
|
||||
@@ -1265,7 +1124,6 @@ describe('Result', () => {
|
||||
expect(screen.getByText('pipeline.result.resultPreview.loading')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Test 2: Completed state with outputs
|
||||
const outputs = createGeneralChunkOutputs(3)
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData({
|
||||
result: {
|
||||
@@ -1280,19 +1138,14 @@ describe('Result', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized', () => {
|
||||
mockWorkflowRunningData = createMockWorkflowRunningData()
|
||||
|
||||
const { rerender } = render(<Result />)
|
||||
|
||||
// Re-render without changes
|
||||
rerender(<Result />)
|
||||
|
||||
// Component should still be rendered correctly
|
||||
expect(screen.getByText('runLog.result')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
// ============================================================================
|
||||
// Import Components After Mocks
|
||||
// ============================================================================
|
||||
import RagPipelineHeader from '../index'
|
||||
import InputFieldButton from '../input-field-button'
|
||||
import Publisher from '../publisher'
|
||||
import Popup from '../publisher/popup'
|
||||
import RunMode from '../run-mode'
|
||||
|
||||
import RagPipelineHeader from './index'
|
||||
import InputFieldButton from './input-field-button'
|
||||
import Publisher from './publisher'
|
||||
import Popup from './publisher/popup'
|
||||
import RunMode from './run-mode'
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Dependencies
|
||||
// ============================================================================
|
||||
|
||||
// Mock workflow store
|
||||
const mockSetShowInputFieldPanel = vi.fn()
|
||||
const mockSetShowEnvPanel = vi.fn()
|
||||
const mockSetIsPreparingDataSource = vi.fn()
|
||||
@@ -51,7 +42,6 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow hooks
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
|
||||
const mockHandleStopRun = vi.fn()
|
||||
@@ -72,7 +62,6 @@ vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Header component
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: ({ normal, viewHistory }: {
|
||||
normal?: { components?: { left?: ReactNode, middle?: ReactNode }, runAndHistoryProps?: unknown }
|
||||
@@ -87,21 +76,18 @@ vi.mock('@/app/components/workflow/header', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'test-dataset-id' }),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, ...props }: PropsWithChildren<{ href: string }>) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: Date.now() })
|
||||
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
|
||||
|
||||
@@ -127,7 +113,6 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock context hooks
|
||||
const mockMutateDatasetRes = vi.fn()
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: () => mockMutateDatasetRes,
|
||||
@@ -145,7 +130,6 @@ vi.mock('@/context/provider-context', () => ({
|
||||
selector(mockProviderContextValue),
|
||||
}))
|
||||
|
||||
// Mock event emitter context
|
||||
const mockEventEmitter = {
|
||||
useSubscription: vi.fn(),
|
||||
}
|
||||
@@ -156,7 +140,6 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: () => '/api/docs',
|
||||
}))
|
||||
@@ -167,12 +150,10 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock amplitude tracking
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
@@ -180,13 +161,11 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow utils
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: (key: string) => key,
|
||||
getKeyboardKeyNameBySystem: (key: string) => key,
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => {
|
||||
let value = initial
|
||||
@@ -202,7 +181,6 @@ vi.mock('ahooks', () => ({
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock portal components - keep actual behavior for open state
|
||||
let portalOpenState = false
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{
|
||||
@@ -224,8 +202,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock PublishAsKnowledgePipelineModal
|
||||
vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({
|
||||
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
||||
default: ({ onConfirm, onCancel }: {
|
||||
onConfirm: (name: string, icon: unknown, description?: string) => void
|
||||
onCancel: () => void
|
||||
@@ -238,10 +215,6 @@ vi.mock('../../publish-as-knowledge-pipeline-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Suites
|
||||
// ============================================================================
|
||||
|
||||
describe('RagPipelineHeader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -259,9 +232,6 @@ describe('RagPipelineHeader', () => {
|
||||
mockProviderContextValue = createMockProviderContextValue()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<RagPipelineHeader />)
|
||||
@@ -286,19 +256,14 @@ describe('RagPipelineHeader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should compute viewHistoryProps based on pipelineId', () => {
|
||||
// Test with first pipelineId
|
||||
mockStoreState.pipelineId = 'pipeline-alpha'
|
||||
const { unmount } = render(<RagPipelineHeader />)
|
||||
let viewHistoryContent = screen.getByTestId('header-view-history').textContent
|
||||
expect(viewHistoryContent).toContain('pipeline-alpha')
|
||||
unmount()
|
||||
|
||||
// Test with different pipelineId
|
||||
mockStoreState.pipelineId = 'pipeline-beta'
|
||||
render(<RagPipelineHeader />)
|
||||
viewHistoryContent = screen.getByTestId('header-view-history').textContent
|
||||
@@ -320,9 +285,6 @@ describe('InputFieldButton', () => {
|
||||
mockStoreState.setShowEnvPanel = mockSetShowEnvPanel
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render button with correct text', () => {
|
||||
render(<InputFieldButton />)
|
||||
@@ -337,9 +299,6 @@ describe('InputFieldButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Event Handler Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Event Handlers', () => {
|
||||
it('should call setShowInputFieldPanel(true) when clicked', () => {
|
||||
render(<InputFieldButton />)
|
||||
@@ -367,16 +326,12 @@ describe('InputFieldButton', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined setShowInputFieldPanel gracefully', () => {
|
||||
mockStoreState.setShowInputFieldPanel = undefined as unknown as typeof mockSetShowInputFieldPanel
|
||||
|
||||
render(<InputFieldButton />)
|
||||
|
||||
// Should not throw when clicked
|
||||
expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -388,9 +343,6 @@ describe('Publisher', () => {
|
||||
portalOpenState = false
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render publish button', () => {
|
||||
render(<Publisher />)
|
||||
@@ -410,9 +362,6 @@ describe('Publisher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Interaction Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Interactions', () => {
|
||||
it('should call handleSyncWorkflowDraft when opening', () => {
|
||||
render(<Publisher />)
|
||||
@@ -430,7 +379,6 @@ describe('Publisher', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// After click, handleOpenChange should be called
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -447,9 +395,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render popup container', () => {
|
||||
render(<Popup />)
|
||||
@@ -475,7 +420,6 @@ describe('Popup', () => {
|
||||
it('should render keyboard shortcuts', () => {
|
||||
render(<Popup />)
|
||||
|
||||
// Should show the keyboard shortcut keys
|
||||
expect(screen.getByText('ctrl')).toBeInTheDocument()
|
||||
expect(screen.getByText('⇧')).toBeInTheDocument()
|
||||
expect(screen.getByText('P')).toBeInTheDocument()
|
||||
@@ -500,9 +444,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Button State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Button States', () => {
|
||||
it('should disable goToAddDocuments when not published', () => {
|
||||
mockStoreState.publishedAt = 0
|
||||
@@ -532,9 +473,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Premium Badge Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Premium Badge', () => {
|
||||
it('should show premium badge when not allowed to publish as template', () => {
|
||||
mockProviderContextValue = createMockProviderContextValue({
|
||||
@@ -557,9 +495,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Interaction Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Interactions', () => {
|
||||
it('should call handleCheckBeforePublish when publish button clicked', async () => {
|
||||
render(<Popup />)
|
||||
@@ -598,9 +533,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Auto-save Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Auto-save Display', () => {
|
||||
it('should show auto-saved time when not published', () => {
|
||||
mockStoreState.publishedAt = 0
|
||||
@@ -629,9 +561,6 @@ describe('RunMode', () => {
|
||||
mockEventEmitterEnabled = true
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render run button with default text', () => {
|
||||
render(<RunMode />)
|
||||
@@ -654,9 +583,6 @@ describe('RunMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Running State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Running States', () => {
|
||||
it('should show processing state when running', () => {
|
||||
mockStoreState.workflowRunningData = {
|
||||
@@ -677,7 +603,6 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// There should be two buttons: run button and stop button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
@@ -751,7 +676,6 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Should only have one button (run button)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBe(1)
|
||||
})
|
||||
@@ -781,9 +705,6 @@ describe('RunMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Disabled State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Disabled States', () => {
|
||||
it('should be disabled when running', () => {
|
||||
mockStoreState.workflowRunningData = {
|
||||
@@ -818,9 +739,6 @@ describe('RunMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Interaction Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Interactions', () => {
|
||||
it('should call handleWorkflowStartRunInWorkflow when clicked', () => {
|
||||
render(<RunMode />)
|
||||
@@ -838,7 +756,6 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Click the stop button (second button)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
@@ -850,7 +767,6 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Click the cancel button (second button)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
@@ -883,14 +799,10 @@ describe('RunMode', () => {
|
||||
const runButton = screen.getAllByRole('button')[0]
|
||||
fireEvent.click(runButton)
|
||||
|
||||
// Should not be called because button is disabled
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Event Emitter Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Event Emitter', () => {
|
||||
it('should subscribe to event emitter', () => {
|
||||
render(<RunMode />)
|
||||
@@ -904,7 +816,6 @@ describe('RunMode', () => {
|
||||
result: { status: WorkflowRunningStatus.Running },
|
||||
}
|
||||
|
||||
// Capture the subscription callback
|
||||
let subscriptionCallback: ((v: { type: string }) => void) | null = null
|
||||
mockEventEmitter.useSubscription.mockImplementation((callback: (v: { type: string }) => void) => {
|
||||
subscriptionCallback = callback
|
||||
@@ -912,7 +823,6 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Simulate the EVENT_WORKFLOW_STOP event (actual value is 'WORKFLOW_STOP')
|
||||
expect(subscriptionCallback).not.toBeNull()
|
||||
subscriptionCallback!({ type: 'WORKFLOW_STOP' })
|
||||
|
||||
@@ -932,7 +842,6 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Simulate a different event type
|
||||
subscriptionCallback!({ type: 'some_other_event' })
|
||||
|
||||
expect(mockHandleStopRun).not.toHaveBeenCalled()
|
||||
@@ -941,7 +850,6 @@ describe('RunMode', () => {
|
||||
it('should handle undefined eventEmitter gracefully', () => {
|
||||
mockEventEmitterEnabled = false
|
||||
|
||||
// Should not throw when eventEmitter is undefined
|
||||
expect(() => render(<RunMode />)).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -951,14 +859,10 @@ describe('RunMode', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// useSubscription should not be called
|
||||
expect(mockEventEmitter.useSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Style Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Styles', () => {
|
||||
it('should have rounded-md class when not disabled', () => {
|
||||
render(<RunMode />)
|
||||
@@ -1053,21 +957,13 @@ describe('RunMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be wrapped in React.memo', () => {
|
||||
// RunMode is exported as default from run-mode.tsx with React.memo
|
||||
// We can verify it's memoized by checking the component's $$typeof symbol
|
||||
expect((RunMode as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests
|
||||
// ============================================================================
|
||||
describe('Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1087,10 +983,8 @@ describe('Integration', () => {
|
||||
it('should render all child components in RagPipelineHeader', () => {
|
||||
render(<RagPipelineHeader />)
|
||||
|
||||
// InputFieldButton
|
||||
expect(screen.getByText(/inputField/i)).toBeInTheDocument()
|
||||
|
||||
// Publisher (via header-middle slot)
|
||||
expect(screen.getByTestId('header-middle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1104,9 +998,6 @@ describe('Integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases
|
||||
// ============================================================================
|
||||
describe('Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -1136,20 +1027,17 @@ describe('Edge Cases', () => {
|
||||
result: undefined as unknown as { status: WorkflowRunningStatus },
|
||||
}
|
||||
|
||||
// Component will crash when accessing result.status - this documents current behavior
|
||||
expect(() => render(<RunMode />)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RunMode Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
// Ensure clean state for each test
|
||||
mockStoreState.workflowRunningData = null
|
||||
mockStoreState.isPreparingDataSource = false
|
||||
})
|
||||
|
||||
it('should handle both isPreparingDataSource and isRunning being true', () => {
|
||||
// This shouldn't happen in practice, but test the priority
|
||||
mockStoreState.isPreparingDataSource = true
|
||||
mockStoreState.workflowRunningData = {
|
||||
task_id: 'task-123',
|
||||
@@ -1158,7 +1046,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Button should be disabled
|
||||
const runButton = screen.getAllByRole('button')[0]
|
||||
expect(runButton).toBeDisabled()
|
||||
})
|
||||
@@ -1169,7 +1056,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Verify the button is enabled and shows testRun text
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toBeDisabled()
|
||||
expect(button.textContent).toContain('pipeline.common.testRun')
|
||||
@@ -1193,7 +1079,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<RunMode text="Start Pipeline" />)
|
||||
|
||||
// Should show reRun, not custom text
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.textContent).toContain('pipeline.common.reRun')
|
||||
expect(screen.queryByText('Start Pipeline')).not.toBeInTheDocument()
|
||||
@@ -1205,7 +1090,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Verify keyboard shortcut elements exist
|
||||
expect(screen.getByText('alt')).toBeInTheDocument()
|
||||
expect(screen.getByText('R')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1216,7 +1100,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Should have svg icon in the button
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1229,7 +1112,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<RunMode />)
|
||||
|
||||
// Should have animate-spin class on the loader icon
|
||||
const runButton = screen.getAllByRole('button')[0]
|
||||
const spinningIcon = runButton.querySelector('.animate-spin')
|
||||
expect(spinningIcon).toBeInTheDocument()
|
||||
@@ -1252,7 +1134,6 @@ describe('Edge Cases', () => {
|
||||
|
||||
render(<Popup />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(screen.getByText(/workflow.common.autoSaved/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import RunMode from '../run-mode'
|
||||
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockSetIsPreparingDataSource = vi.fn()
|
||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||
|
||||
let mockWorkflowRunningData: { task_id: string, result: { status: string } } | undefined
|
||||
let mockIsPreparingDataSource = false
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowRun: () => ({
|
||||
handleStopRun: mockHandleStopRun,
|
||||
}),
|
||||
useWorkflowStartRun: () => ({
|
||||
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
workflowRunningData: mockWorkflowRunningData,
|
||||
isPreparingDataSource: mockIsPreparingDataSource,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setIsPreparingDataSource: mockSetIsPreparingDataSource,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
WorkflowRunningStatus: { Running: 'running' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/variable-inspect/types', () => ({
|
||||
EVENT_WORKFLOW_STOP: 'EVENT_WORKFLOW_STOP',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: { useSubscription: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string').join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCloseLine: () => <span data-testid="close-icon" />,
|
||||
RiDatabase2Line: () => <span data-testid="database-icon" />,
|
||||
RiLoader2Line: () => <span data-testid="loader-icon" />,
|
||||
RiPlayLargeLine: () => <span data-testid="play-icon" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
|
||||
StopCircle: () => <span data-testid="stop-icon" />,
|
||||
}))
|
||||
|
||||
describe('RunMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowRunningData = undefined
|
||||
mockIsPreparingDataSource = false
|
||||
})
|
||||
|
||||
describe('Idle state', () => {
|
||||
it('should render test run text when no data', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText('pipeline.common.testRun')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom text when provided', () => {
|
||||
render(<RunMode text="Custom Run" />)
|
||||
|
||||
expect(screen.getByText('Custom Run')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render play icon', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByTestId('play-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render keyboard shortcuts', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call start run when button clicked', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
fireEvent.click(screen.getByText('pipeline.common.testRun'))
|
||||
|
||||
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Running state', () => {
|
||||
beforeEach(() => {
|
||||
mockWorkflowRunningData = {
|
||||
task_id: 'task-1',
|
||||
result: { status: 'running' },
|
||||
}
|
||||
})
|
||||
|
||||
it('should show processing text', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText('pipeline.common.processing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show stop button', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable run button', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
const button = screen.getByText('pipeline.common.processing').closest('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call handleStopRun with task_id when stop clicked', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('stop-icon').closest('button')!)
|
||||
|
||||
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('After run completed', () => {
|
||||
it('should show reRun text when previous run data exists', () => {
|
||||
mockWorkflowRunningData = {
|
||||
task_id: 'task-1',
|
||||
result: { status: 'succeeded' },
|
||||
}
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText('pipeline.common.reRun')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preparing data source state', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPreparingDataSource = true
|
||||
})
|
||||
|
||||
it('should show preparing text', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByText('pipeline.common.preparingDataSource')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show database icon', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByTestId('database-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show cancel button with close icon', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
expect(screen.getByTestId('close-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cancel preparing when close clicked', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-icon').closest('button')!)
|
||||
|
||||
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Popup from '../popup'
|
||||
|
||||
const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' })
|
||||
const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({})
|
||||
const mockNotify = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
|
||||
const mockSetPublishedAt = vi.fn()
|
||||
const mockMutateDatasetRes = vi.fn()
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockInvalidPublishedPipelineInfo = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
const mockInvalidCustomizedTemplateList = vi.fn()
|
||||
|
||||
let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z'
|
||||
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
|
||||
let mockPipelineId: string | undefined = 'pipeline-123'
|
||||
let mockIsAllowPublishAsCustom = true
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'ds-123' }),
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: (initial: boolean) => {
|
||||
const state = { value: initial }
|
||||
return [state.value, {
|
||||
setFalse: vi.fn(),
|
||||
setTrue: vi.fn(),
|
||||
}]
|
||||
},
|
||||
useKeyPress: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
publishedAt: mockPublishedAt,
|
||||
draftUpdatedAt: mockDraftUpdatedAt,
|
||||
pipelineId: mockPipelineId,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/button', () => ({
|
||||
default: ({ children, onClick, disabled, variant, className }: Record<string, unknown>) => (
|
||||
<button
|
||||
onClick={onClick as () => void}
|
||||
disabled={disabled as boolean}
|
||||
data-variant={variant as string}
|
||||
className={className as string}
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title }: {
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
title: string
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="confirm-modal">
|
||||
<span>{title}</span>
|
||||
<button data-testid="publish-confirm" onClick={onConfirm}>OK</button>
|
||||
<button data-testid="publish-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/common', () => ({
|
||||
SparklesSoft: () => <span data-testid="sparkles" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/premium-badge', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <span data-testid="premium-badge">{children}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useChecklistBeforePublish: () => ({
|
||||
handleCheckBeforePublish: mockHandleCheckBeforePublish,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span data-testid="shortcuts">{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', () => ({
|
||||
getKeyboardKeyCodeBySystem: () => 'ctrl',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: () => mockMutateDatasetRes,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => () => 'https://docs.dify.ai',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: () => mockSetShowPricingModal,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: () => mockIsAllowPublishAsCustom,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-api-access-url', () => ({
|
||||
useDatasetApiAccessUrl: () => '/api/datasets/ds-123',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (time: string) => `formatted:${time}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-base', () => ({
|
||||
useInvalid: () => mockInvalidPublishedPipelineInfo,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
publishedPipelineInfoQueryKeyPrefix: ['published-pipeline'],
|
||||
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
|
||||
usePublishAsCustomizedPipeline: () => ({
|
||||
mutateAsync: mockPublishAsCustomizedPipeline,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
usePublishWorkflow: () => ({
|
||||
mutateAsync: mockPublishWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/classnames', () => ({
|
||||
cn: (...args: string[]) => args.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({
|
||||
default: ({ onConfirm, onCancel }: { onConfirm: (name: string, icon: unknown, desc: string) => void, onCancel: () => void }) => (
|
||||
<div data-testid="publish-as-modal">
|
||||
<button data-testid="publish-as-confirm" onClick={() => onConfirm('My Pipeline', { icon_type: 'emoji' }, 'desc')}>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="publish-as-cancel" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiArrowRightUpLine: () => <span />,
|
||||
RiHammerLine: () => <span />,
|
||||
RiPlayCircleLine: () => <span />,
|
||||
RiTerminalBoxLine: () => <span />,
|
||||
}))
|
||||
|
||||
describe('Popup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPublishedAt = '2024-01-01T00:00:00Z'
|
||||
mockDraftUpdatedAt = '2024-06-01T00:00:00Z'
|
||||
mockPipelineId = 'pipeline-123'
|
||||
mockIsAllowPublishAsCustom = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render when published', () => {
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument()
|
||||
expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unpublished state', () => {
|
||||
mockPublishedAt = undefined
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText('workflow.common.currentDraftUnpublished')).toBeInTheDocument()
|
||||
expect(screen.getByText(/workflow\.common\.autoSaved/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render publish button with shortcuts', () => {
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('shortcuts')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Go to Add Documents" button', () => {
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText('pipeline.common.goToAddDocuments')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "API Reference" button', () => {
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Publish As" button', () => {
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Premium Badge', () => {
|
||||
it('should not show premium badge when allowed', () => {
|
||||
mockIsAllowPublishAsCustom = true
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.queryByTestId('premium-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show premium badge when not allowed', () => {
|
||||
mockIsAllowPublishAsCustom = false
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByTestId('premium-badge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to add documents page', () => {
|
||||
render(<Popup />)
|
||||
|
||||
fireEvent.click(screen.getByText('pipeline.common.goToAddDocuments'))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/ds-123/documents/create-from-pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button disable states', () => {
|
||||
it('should disable add documents button when not published', () => {
|
||||
mockPublishedAt = undefined
|
||||
render(<Popup />)
|
||||
|
||||
const btn = screen.getByText('pipeline.common.goToAddDocuments').closest('button')
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable publish-as button when not published', () => {
|
||||
mockPublishedAt = undefined
|
||||
render(<Popup />)
|
||||
|
||||
const btn = screen.getByText('pipeline.common.publishAs').closest('button')
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish As Knowledge Pipeline', () => {
|
||||
it('should show pricing modal when not allowed', () => {
|
||||
mockIsAllowPublishAsCustom = false
|
||||
render(<Popup />)
|
||||
|
||||
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
||||
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time formatting', () => {
|
||||
it('should format published time', () => {
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText(/formatted:2024-01-01/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format draft updated time when unpublished', () => {
|
||||
mockPublishedAt = undefined
|
||||
render(<Popup />)
|
||||
|
||||
expect(screen.getByText(/formatted:2024-06-01/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,6 @@ import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
// ============================================================================
|
||||
// Import hooks after mocks
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
useAvailableNodesMetaData,
|
||||
useDSL,
|
||||
@@ -20,16 +16,11 @@ import {
|
||||
usePipelineRefreshDraft,
|
||||
usePipelineRun,
|
||||
usePipelineStartRun,
|
||||
} from './index'
|
||||
import { useConfigsMap } from './use-configs-map'
|
||||
import { useConfigurations, useInitialData } from './use-input-fields'
|
||||
import { usePipelineTemplate } from './use-pipeline-template'
|
||||
} from '../index'
|
||||
import { useConfigsMap } from '../use-configs-map'
|
||||
import { useConfigurations, useInitialData } from '../use-input-fields'
|
||||
import { usePipelineTemplate } from '../use-pipeline-template'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
// Mock the workflow store
|
||||
const _mockGetState = vi.fn()
|
||||
const mockUseStore = vi.fn()
|
||||
const mockUseWorkflowStore = vi.fn()
|
||||
@@ -39,14 +30,6 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockUseWorkflowStore(),
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
@@ -54,7 +37,6 @@ vi.mock('@/app/components/base/toast', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock event emitter context
|
||||
const mockEventEmit = vi.fn()
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
@@ -64,19 +46,16 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock i18n docLink
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
// Mock workflow constants
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE',
|
||||
START_INITIAL_POSITION: { x: 100, y: 100 },
|
||||
}))
|
||||
|
||||
// Mock workflow constants/node
|
||||
vi.mock('@/app/components/workflow/constants/node', () => ({
|
||||
WORKFLOW_COMMON_NODES: [
|
||||
{
|
||||
@@ -90,7 +69,6 @@ vi.mock('@/app/components/workflow/constants/node', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock data source defaults
|
||||
vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({
|
||||
default: {
|
||||
metaData: { type: BlockEnum.DataSourceEmpty },
|
||||
@@ -112,7 +90,6 @@ vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock workflow utils with all needed exports
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return {
|
||||
@@ -123,7 +100,6 @@ vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock pipeline service
|
||||
const mockExportPipelineConfig = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
@@ -131,7 +107,6 @@ vi.mock('@/service/use-pipeline', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock workflow service
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn().mockResolvedValue({
|
||||
graph: { nodes: [], edges: [], viewport: {} },
|
||||
@@ -139,10 +114,6 @@ vi.mock('@/service/workflow', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useConfigsMap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -307,11 +278,10 @@ describe('useInputFieldPanel', () => {
|
||||
|
||||
it('should set edit panel props when toggleInputFieldEditPanel is called', () => {
|
||||
const { result } = renderHook(() => useInputFieldPanel())
|
||||
const editContent = { type: 'edit', data: {} }
|
||||
const editContent = { onClose: vi.fn(), onSubmit: vi.fn() }
|
||||
|
||||
act(() => {
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
result.current.toggleInputFieldEditPanel(editContent as any)
|
||||
result.current.toggleInputFieldEditPanel(editContent)
|
||||
})
|
||||
|
||||
expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent)
|
||||
@@ -1,8 +1,7 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDSL } from './use-DSL'
|
||||
import { useDSL } from '../use-DSL'
|
||||
|
||||
// Mock dependencies
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
@@ -14,7 +13,7 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
}))
|
||||
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
vi.mock('./use-nodes-sync-draft', () => ({
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }),
|
||||
}))
|
||||
|
||||
@@ -37,21 +36,10 @@ const mockDownloadBlob = vi.fn()
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useDSL', () => {
|
||||
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> }
|
||||
let originalCreateElement: typeof document.createElement
|
||||
@@ -62,7 +50,6 @@ describe('useDSL', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a proper mock link element with all required properties for downloadBlob
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
@@ -71,7 +58,6 @@ describe('useDSL', () => {
|
||||
remove: vi.fn(),
|
||||
}
|
||||
|
||||
// Save original and mock selectively - only intercept 'a' elements
|
||||
originalCreateElement = document.createElement.bind(document)
|
||||
document.createElement = vi.fn((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
@@ -80,15 +66,12 @@ describe('useDSL', () => {
|
||||
return originalCreateElement(tagName)
|
||||
}) as typeof document.createElement
|
||||
|
||||
// Mock document.body.appendChild for downloadBlob
|
||||
originalAppendChild = document.body.appendChild.bind(document.body)
|
||||
document.body.appendChild = vi.fn(<T extends Node>(node: T): T => node) as typeof document.body.appendChild
|
||||
|
||||
// downloadBlob uses window.URL, not URL
|
||||
mockCreateObjectURL = vi.spyOn(window.URL, 'createObjectURL').mockReturnValue('blob:test-url')
|
||||
mockRevokeObjectURL = vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
// Default store state
|
||||
mockGetState.mockReturnValue({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
knowledgeName: 'Test Knowledge Base',
|
||||
@@ -170,7 +153,7 @@ describe('useDSL', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
message: 'app.exportFailed',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -251,7 +234,7 @@ describe('useDSL', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'exportFailed',
|
||||
message: 'app.exportFailed',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai${path || ''}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/constants/node', () => ({
|
||||
WORKFLOW_COMMON_NODES: [
|
||||
{
|
||||
metaData: { type: BlockEnum.LLM },
|
||||
defaultValue: { title: 'LLM' },
|
||||
},
|
||||
{
|
||||
metaData: { type: BlockEnum.HumanInput },
|
||||
defaultValue: { title: 'Human Input' },
|
||||
},
|
||||
{
|
||||
metaData: { type: BlockEnum.HttpRequest },
|
||||
defaultValue: { title: 'HTTP Request' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({
|
||||
default: {
|
||||
metaData: { type: BlockEnum.DataSourceEmpty },
|
||||
defaultValue: { title: 'Data Source Empty' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/data-source/default', () => ({
|
||||
default: {
|
||||
metaData: { type: BlockEnum.DataSource },
|
||||
defaultValue: { title: 'Data Source' },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({
|
||||
default: {
|
||||
metaData: { type: BlockEnum.KnowledgeBase },
|
||||
defaultValue: { title: 'Knowledge Base' },
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useAvailableNodesMetaData', () => {
|
||||
it('should return nodes and nodesMap', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
expect(result.current.nodes).toBeDefined()
|
||||
expect(result.current.nodesMap).toBeDefined()
|
||||
})
|
||||
|
||||
it('should filter out HumanInput node', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(n => n.metaData.type)
|
||||
|
||||
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
|
||||
it('should include DataSource with _dataSourceStartToAdd flag', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const dsNode = result.current.nodes.find(n => n.metaData.type === BlockEnum.DataSource)
|
||||
|
||||
expect(dsNode).toBeDefined()
|
||||
expect(dsNode!.defaultValue._dataSourceStartToAdd).toBe(true)
|
||||
})
|
||||
|
||||
it('should include KnowledgeBase and DataSourceEmpty nodes', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(n => n.metaData.type)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.KnowledgeBase)
|
||||
expect(nodeTypes).toContain(BlockEnum.DataSourceEmpty)
|
||||
})
|
||||
|
||||
it('should translate title and description for each node', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
result.current.nodes.forEach((node) => {
|
||||
expect(node.metaData.title).toMatch(/^workflow\.blocks\./)
|
||||
expect(node.metaData.description).toMatch(/^workflow\.blocksAbout\./)
|
||||
})
|
||||
})
|
||||
|
||||
it('should set helpLinkUri on each node metaData', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
result.current.nodes.forEach((node) => {
|
||||
expect(node.metaData.helpLinkUri).toContain('https://docs.dify.ai')
|
||||
expect(node.metaData.helpLinkUri).toContain('knowledge-pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
it('should set type and title on defaultValue', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
result.current.nodes.forEach((node) => {
|
||||
expect(node.defaultValue.type).toBe(node.metaData.type)
|
||||
expect(node.defaultValue.title).toBe(node.metaData.title)
|
||||
})
|
||||
})
|
||||
|
||||
it('should build nodesMap indexed by BlockEnum type', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const { nodesMap } = result.current
|
||||
|
||||
expect(nodesMap[BlockEnum.LLM]).toBeDefined()
|
||||
expect(nodesMap[BlockEnum.DataSource]).toBeDefined()
|
||||
expect(nodesMap[BlockEnum.KnowledgeBase]).toBeDefined()
|
||||
})
|
||||
|
||||
it('should alias VariableAssigner to VariableAggregator in nodesMap', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const { nodesMap } = result.current
|
||||
|
||||
expect(nodesMap[BlockEnum.VariableAssigner]).toBe(nodesMap[BlockEnum.VariableAggregator])
|
||||
})
|
||||
|
||||
it('should include common nodes except HumanInput', () => {
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
const nodeTypes = result.current.nodes.map(n => n.metaData.type)
|
||||
|
||||
expect(nodeTypes).toContain(BlockEnum.LLM)
|
||||
expect(nodeTypes).toContain(BlockEnum.HttpRequest)
|
||||
expect(nodeTypes).not.toContain(BlockEnum.HumanInput)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useConfigsMap } from '../use-configs-map'
|
||||
|
||||
const mockPipelineId = 'pipeline-xyz'
|
||||
const mockFileUploadConfig = { max_size: 10 }
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
pipelineId: mockPipelineId,
|
||||
fileUploadConfig: mockFileUploadConfig,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/types/app', () => ({
|
||||
Resolution: { high: 'high' },
|
||||
TransferMethod: { local_file: 'local_file', remote_url: 'remote_url' },
|
||||
}))
|
||||
|
||||
vi.mock('@/types/common', () => ({
|
||||
FlowType: { ragPipeline: 'rag-pipeline' },
|
||||
}))
|
||||
|
||||
describe('useConfigsMap', () => {
|
||||
it('should return flowId from pipelineId', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.flowId).toBe('pipeline-xyz')
|
||||
})
|
||||
|
||||
it('should return ragPipeline as flowType', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.flowType).toBe('rag-pipeline')
|
||||
})
|
||||
|
||||
it('should include file settings with image disabled', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.fileSettings.image.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should set image detail to high resolution', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.fileSettings.image.detail).toBe('high')
|
||||
})
|
||||
|
||||
it('should set image number_limits to 3', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.fileSettings.image.number_limits).toBe(3)
|
||||
})
|
||||
|
||||
it('should include both transfer methods for image', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.fileSettings.image.transfer_methods).toEqual(['local_file', 'remote_url'])
|
||||
})
|
||||
|
||||
it('should pass through fileUploadConfig from store', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current.fileSettings.fileUploadConfig).toEqual({ max_size: 10 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
pipelineId: 'pipeline-test-123',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useGetRunAndTraceUrl', () => {
|
||||
it('should return a function getWorkflowRunAndTraceUrl', () => {
|
||||
const { result } = renderHook(() => useGetRunAndTraceUrl())
|
||||
|
||||
expect(typeof result.current.getWorkflowRunAndTraceUrl).toBe('function')
|
||||
})
|
||||
|
||||
it('should generate correct runUrl', () => {
|
||||
const { result } = renderHook(() => useGetRunAndTraceUrl())
|
||||
const { runUrl } = result.current.getWorkflowRunAndTraceUrl('run-abc')
|
||||
|
||||
expect(runUrl).toBe('/rag/pipelines/pipeline-test-123/workflow-runs/run-abc')
|
||||
})
|
||||
|
||||
it('should generate correct traceUrl', () => {
|
||||
const { result } = renderHook(() => useGetRunAndTraceUrl())
|
||||
const { traceUrl } = result.current.getWorkflowRunAndTraceUrl('run-abc')
|
||||
|
||||
expect(traceUrl).toBe('/rag/pipelines/pipeline-test-123/workflow-runs/run-abc/node-executions')
|
||||
})
|
||||
|
||||
it('should handle different runIds', () => {
|
||||
const { result } = renderHook(() => useGetRunAndTraceUrl())
|
||||
|
||||
const r1 = result.current.getWorkflowRunAndTraceUrl('id-1')
|
||||
const r2 = result.current.getWorkflowRunAndTraceUrl('id-2')
|
||||
|
||||
expect(r1.runUrl).toContain('id-1')
|
||||
expect(r2.runUrl).toContain('id-2')
|
||||
expect(r1.runUrl).not.toBe(r2.runUrl)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user